POST · 前端 · 文章
PWA 实践:Service Worker、缓存策略与 Workbox
2025/06/13 · by ZHANG XING YUN · 阅读 3 次 · 6313 字 · 预计阅读 16 分钟
适用环境:Node ≥ 18、Chrome/Edge ≥ 91(调试用)、localhost 或 HTTPS
一、先明确边界:PWA 能做什么、不能做什么
1.1 概念
PWA(Progressive Web App,渐进式 Web 应用)不是一项技术,而是一组 Web 能力的组合。常见的说法是:这个词由 Alex Russell 和 Frances Berriman 在 2015 年提出,2016 年 Google I/O 之后被大规模推广(很多文章写成"2016 年提出")。它的核心主张是:用 Web 技术做出接近原生应用的体验,包括离线可用、桌面图标、全屏运行、消息推送、后台同步。
"渐进式"是关键词:不支持这些能力的浏览器,看到的仍然是一个普通网页,基准功能不会中断。这一点决定了它和"套壳 App"的本质区别——没有原生代码,没有应用商店审核。
1.2 四个核心能力
| 能力 | 依赖 | 解决的问题 |
|---|---|---|
| 离线可用 | Service Worker + Cache Storage | 弱网/断网时首屏秒开 |
| 可安装 | Web App Manifest + SW | 桌面图标、独立窗口、全屏 |
| 消息推送 | Push API + Notification API + VAPID | 用户离开页面后仍能触达 |
| 后台同步 | Background Sync | 断网时把操作排队,联网后补发 |
1.3 不能做什么(比"能做什么"更重要)
- 不能访问原生能力:蓝牙、精细文件系统、后台常驻进程、电话/短信等,能力边界由浏览器决定。
- 不能保证推送必达:系统省电策略、用户关闭通知、厂商 push 服务抖动都会影响。
- 不能绕过 HTTPS:Service Worker 只在安全上下文注册(
http://localhost、127.0.0.1例外)。 - 不能像 App 一样"后台常驻":SW 随时可能被浏览器终止,所有逻辑都要假设"我随时会死"。
1.4 两个硬前提
HTTPS(或 localhost):
navigator.serviceWorker在非安全上下文里直接是undefined。同源作用域:SW 文件必须在你想控制的路径之下。用
/public/js/service-worker.js想控制/,必须由服务端返回响应头:Service-Worker-Allowed: /否则
register(..., { scope: '/' })会抛The origin of the provided scriptURL does not match the current origin或被降级到脚本所在目录。
1.5 iOS 的现实
| 能力 | iOS Safari 现状 |
|---|---|
| Service Worker | 11.3+ 支持 |
| 添加到主屏幕 | 支持,但必须用户手动"分享 → 添加到主屏幕" |
安装提示(beforeinstallprompt) |
不支持,只能自己做引导文案 |
| Web Push | iOS 16.4+ 支持,且必须已添加到主屏幕,否则 Notification.requestPermission() 不可用 |
| Background Sync / Periodic Sync | 不支持 |
| 启动画面/图标 | 依赖 apple-touch-icon 与 manifest,表现与 Android 不同 |
结论:Android/桌面 Chrome 与 iOS 需要两套验收标准,别拿 Android 的表现去承诺 iOS。
二、前置条件与验收标准
2.1 环境
node -v # ≥ 18
npx serve . # 或任何静态服务器;用 http-server / vite preview 也可以
2.2 本地怎么算"安全上下文"
http://localhost:3000✅(浏览器把 localhost 视为安全上下文)http://192.168.1.10:3000❌(局域网 IP 不算,手机调试时要么上 HTTPS,要么用chrome://flags/#unsafely-treat-insecure-origin-as-secure临时放行)
2.3 验收标准(跑完第三节应全部满足)
- DevTools → Application → Manifest:无红色报错,能看到图标
- Application → Service Workers:状态
activated and is running - Application → Cache Storage:有你的缓存名,条目数正确
- 勾选 Network 的
Offline后刷新:页面仍能打开(说明预缓存生效) - 地址栏出现安装图标(或 iOS 手动添加后能全屏打开)
三、最小可运行 demo(三步跑通)
3.1 第一步:manifest.json
必须是严格 JSON,不能有注释、不能有尾逗号——浏览器的 manifest 解析器不是 JSON5,注释会让整个文件被忽略(这是新手第一个坑)。
{
"name": "pwa-demo",
"short_name": "pwa-demo",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/public/images/icon/icon.png",
"sizes": "500x500",
"type": "image/png",
"purpose": "any"
}
]
}
| 字段 | 作用 | 常见错误 |
|---|---|---|
name / short_name |
安装后的应用名 / 主屏短名 | 漏 short_name 时桌面图标名字被截断 |
start_url |
从图标启动时打开的地址 | 写 ./index.html 在子路径部署时会 404,建议 / |
scope |
应用"管辖"的路径范围 | 写太小会导致站内跳转被踢回浏览器 |
display |
standalone(去掉地址栏)/ fullscreen / minimal-ui |
写 fullscreen 后无法返回,体验反而差 |
icons |
图标数组,必须带 type |
只给 500x500 时 Chrome 可能不给安装入口 |
theme_color |
状态栏/标题栏颜色 | 与页面主题不一致,割裂感 |
生产环境建议同时提供
192x192和512x512(purpose分别为any和any maskable),Chrome 的可安装性判断依赖这两个尺寸。
在 HTML 里引用:
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#ffffff" />
<link rel="apple-touch-icon" href="/public/images/icon/icon.png" />
3.2 第二步:注册 Service Worker
// main.js(页面主线程),等 load 之后再注册,避免和首屏抢带宽
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/service-worker.js", { scope: "/" })
.then(registration => {
console.log("SW 注册成功", registration.scope);
})
.catch(error => {
console.error("SW 注册失败", error);
});
});
}
调试期经常需要"注销所有 SW",可以临时用下面这段(用完记得删):
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => registration.unregister());
});
3.3 第三步:第一个 Service Worker
一个能离线打开首页的最小实现:
// service-worker.js
const CACHE_VERSION = "v1";
const PRECACHE = `precache-${CACHE_VERSION}`;
const PRECACHE_URLS = ["/", "/index.html", "/manifest.json"];
// 安装:把关键资源写进缓存。addAll 的 Promise 必须 return 给 waitUntil,
// 否则 SW 会在缓存写完之前就激活,离线首屏就会失败。
self.addEventListener("install", event => {
event.waitUntil(
caches
.open(PRECACHE)
.then(cache => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});
// 激活:清掉旧版本缓存,并接管当前页面
self.addEventListener("activate", event => {
event.waitUntil(
caches
.keys()
.then(keys =>
Promise.all(
keys
.filter(key => key !== PRECACHE)
.map(key => caches.delete(key))
)
)
.then(() => self.clients.claim())
);
});
// 拦截请求:导航请求走网络优先,其余走缓存优先
self.addEventListener("fetch", event => {
if (event.request.method !== "GET") return;
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});
两个细节值得记住:
self就是 SW 的全局对象,写this也能跑,但self更明确,箭头函数里也不会丢上下文。caches.delete返回 Promise,必须用Promise.all收集,否则waitUntil不等它。
3.4 用 DevTools 验证
- F12 → Application → Manifest:确认无红字,图标正常显示。
- Application → Service Workers:看到
#1 activated and is running。 - Application → Cache Storage:展开
precache-v1,条目应为 3 条。 - Network 面板勾
Offline,刷新:页面仍能打开 → 预缓存生效。 - 改一行
service-worker.js保存 → Service Workers 面板出现waiting to activate→ 这就是第五节的"更新机制"要处理的状态。
四、五种缓存策略
fetch 事件 + Cache API 的组合方式,官方归纳为五种。判断标准只有一句话:你更怕"看到旧内容"还是更怕"打不开"。
| 策略 | 速度 | 新鲜度 | 典型场景 |
|---|---|---|---|
| 仅缓存 Cache Only | 最快 | 最差(要靠 SW 更新) | 预缓存好的 App Shell、字体 |
| 仅网络 Network Only | 最慢/不可用 | 最好 | 支付、登录、实时数据 |
| 缓存优先 Cache First | 快 | 差 | 图片、JS/CSS 等带 hash 的静态资源 |
| 网络优先 Network First | 中 | 好 | HTML、接口数据 |
| 重新验证时过时 SWR | 最快 | 中(下一次请求才更新) | 头像、列表页、可容忍一次延迟的数据 |
下面每个函数都是完整可运行的,直接放进 service-worker.js。
4.1 仅缓存
async function cacheOnly(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (!cached) {
throw new Error(`cache miss: ${request.url}`);
}
return cached;
}

4.2 仅网络
function networkOnly(request) {
// 不做任何缓存读写,直接穿透
return fetch(request);
}

4.3 缓存优先
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
// 只缓存同源的、状态正常的完整响应
if (response.ok && response.type === "basic") {
cache.put(request, response.clone());
}
return response;
}

4.4 网络优先
async function networkFirst(request, cacheName, fallbackUrl) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
} catch (error) {
// 断网:优先返回缓存;导航请求再退到离线兜底页
const cached = await cache.match(request);
if (cached) return cached;
if (fallbackUrl) {
const fallback = await cache.match(fallbackUrl);
if (fallback) return fallback;
}
throw error;
}
}
4.5 重新验证时过时(SWR)
function staleWhileRevalidate(request, cacheName) {
return caches.open(cacheName).then(cache =>
cache.match(request).then(cached => {
// 后台请求最新版本,无论成功与否都不影响本次返回
const network = fetch(request)
.then(response => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => cached);
return cached || network;
})
);
}
4.6 怎么选
self.addEventListener("fetch", event => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return; // 跨域交给浏览器默认行为
if (request.mode === "navigate") {
event.respondWith(networkFirst(request, "pages-v1", "/index.html"));
return;
}
if (/\.(?:png|jpe?g|webp|svg|gif|ico)$/.test(url.pathname)) {
event.respondWith(staleWhileRevalidate(request, "images-v1"));
return;
}
event.respondWith(cacheFirst(request, "assets-v1"));
});
一个必须知道的限制:Cache API 只能存 GET 请求的响应。对 POST 调用 cache.put 会抛 Request method 'POST' is unsupported。网上有"把 URL 拼上缓存名再用字符串当 key"的写法,绕过了报错但语义全乱(缓存键和真实请求对不上,永远命中不了正确的条目),不要这么干——要缓存写请求,用第九节的 Background Sync 或自己写 IndexedDB 队列。
五、更新机制(线上事故高发区)
5.1 生命周期里的"等待中"状态
浏览器发现 service-worker.js 内容变了 → 下载新版 → 触发新 SW 的 install → 如果旧 SW 还在控制页面,新 SW 会停在 waiting,直到所有页面关闭。这意味着:你发布了新版本,用户刷新十次还是旧页面。
5.2 skipWaiting / clients.claim 的代价
self.addEventListener("install", event => {
event.waitUntil(self.skipWaiting()); // 立刻激活,不等旧页面关闭
});
self.addEventListener("activate", event => {
event.waitUntil(self.clients.claim()); // 激活后立刻接管已打开的页面
});
代价是:新 SW 可能在页面运行中途接管,页面上的懒加载资源可能来自新版本,造成版本错配(典型现象:点击按钮报 undefined is not a function)。所以:
- 内容型站点(新闻、文档、后台管理):建议提示用户刷新,而不是静默
skipWaiting。 - 对一致性要求高的应用:宁可让用户看到"有新版本,点击刷新"。
5.3 推荐做法:提示用户刷新
主线程:
let refreshing = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
// 新 SW 接管后刷新一次,避免版本错配
if (refreshing) return;
refreshing = true;
window.location.reload();
});
navigator.serviceWorker.register("/service-worker.js").then(registration => {
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
// 这里换成你的 UI 提示(toast / 弹窗),用户点击后再发消息
const confirmed = window.confirm("有新版本,是否立即更新?");
if (confirmed) {
registration.waiting?.postMessage({ type: "SKIP_WAITING" });
}
}
});
});
});
SW 内:
self.addEventListener("message", event => {
if (event.data?.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
5.4 版本号与缓存清理
只用"按版本号命名 + 激活时删除非当前版本"这一种方案就够:
const VERSION = "v3";
const PRECACHE = `precache-${VERSION}`;
self.addEventListener("activate", event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(key => !key.endsWith(`-${VERSION}`)).map(key => caches.delete(key))
)
)
);
});
注意:改版本号只是让新 SW 有差异可检测;真正触发更新的是 service-worker.js 文件内容变化(浏览器做字节级比对)。如果你的 SW 由构建工具生成并由 self.__WB_MANIFEST 注入,hash 变化天然会触发更新。
六、Workbox
6.1 为什么用它
手写 SW 的痛点:路由匹配、缓存过期、并发请求去重、版本管理,全都要自己写。Workbox 是 Google 官方的一组库,把这些做成可组合的模块。
6.2 两种模式
| 模式 | 产出 | 适用 |
|---|---|---|
generateSW |
直接生成 service-worker.js,你只写配置 |
需求能用 runtimeCaching 表达(80% 的项目) |
injectManifest |
你手写 SW,只把"预缓存清单"注入 self.__WB_MANIFEST |
需要自定义逻辑(路由、复杂策略、消息处理) |
6.3 模块与插件
| 模块 | 作用 | 关键 API |
|---|---|---|
workbox-routing |
请求匹配与路由注册 | registerRoute、Route、NavigationRoute |
workbox-strategies |
五种策略 | CacheFirst、NetworkFirst、StaleWhileRevalidate、CacheOnly、NetworkOnly |
workbox-precaching |
预缓存清单注入与匹配 | precacheAndRoute、createHandlerBoundToURL |
workbox-expiration |
缓存条数与时长 | ExpirationPlugin、CacheExpiration |
workbox-cacheable-response |
什么样的响应才允许缓存 | CacheableResponsePlugin |
workbox-background-sync |
断网请求排队、联网重放 | BackgroundSyncPlugin |
workbox-broadcast-update |
后台更新后通知页面 | BroadcastUpdatePlugin |
workbox-range-requests |
音视频分片请求 | RangeRequestsPlugin |
workbox-core |
缓存命名、claim、日志 | cacheNames、setCacheNameDetails、clientsClaim |
workbox-window |
主线程侧封装:注册、更新、通信 | new Workbox(url) |
策略与插件的最小可用写法(ESM,配合打包器):
import { registerRoute, Route } from "workbox-routing";
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
import { ExpirationPlugin } from "workbox-expiration";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { BackgroundSyncPlugin } from "workbox-background-sync";
// 图片:缓存优先 + 20 条 / 7 天
registerRoute(
({ request }) => request.destination === "image",
new CacheFirst({
cacheName: "image-cache",
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 7 * 24 * 60 * 60 })
]
})
);
// 接口:SWR,只缓存 200,并把失败的写请求排队
registerRoute(
({ url }) => url.pathname.startsWith("/api/"),
new StaleWhileRevalidate({
cacheName: "api-cache",
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new BackgroundSyncPlugin("api-queue", { maxRetentionTime: 24 * 60 })
]
})
);
// 页面导航:网络优先,离线回退到预缓存的 index.html
registerRoute(
({ request }) => request.mode === "navigate",
new NetworkFirst({ cacheName: "pages-cache" })
);
// 也可以显式构造 Route(when 里能拿到 request、url、sameOrigin)
const apiRoute = new Route(
({ request, sameOrigin }) => sameOrigin && request.method === "GET" && /^\/api\//.test(request.url),
new NetworkFirst({ cacheName: "api-get-cache" })
);
registerRoute(apiRoute);
statuses: [0, 200]里的0代表 opaque 响应(跨域图片),允许缓存但拿不到内容,只能原样返回。
6.4 workbox-sw / CLI / build
workbox-sw:不用打包器时,用 importScripts 引入,所有模块挂在全局 workbox 上。
importScripts("./workbox-v7.1.0/workbox-sw.js"); // 本地副本,避免 CDN 不稳定
const { registerRoute } = workbox.routing;
const { CacheFirst } = workbox.strategies;
也可以从 CDN 引入(版本号必须写死,不要用 latest):
importScripts("https://storage.googleapis.com/workbox-cdn/releases/7.1.0/workbox-sw.js");
生成这一套本地文件:
npx workbox-cli copyLibraries ./public

workbox-cli 四个命令:
| 命令 | 作用 |
|---|---|
workbox wizard |
交互式生成 workbox-config.js |
workbox generateSW <config> |
按配置生成 SW |
workbox injectManifest <config> |
把预缓存清单注入已有 SW |
workbox copyLibraries <dir> |
复制 Workbox 运行时代码 |
injectManifest 的完整流水线(示例工程采用这条路线):
// workbox-config.js
module.exports = {
swSrc: "public/service-worker.js", // 你手写的 SW
globDirectory: "build", // 构建产物目录,不是 src!
globPatterns: ["**/*.{html,js,css,json,ico,png,svg,txt}"],
globIgnores: ["**/*.map"],
swDest: "build/service-worker.js"
};
{
"scripts": {
"build": "react-scripts build && npm run workbox",
"workbox": "workbox injectManifest workbox-config.js"
}
}
workbox 命令来自 workbox-cli,必须装成项目依赖,否则 CI 上 command not found:
npm i -D workbox-cli
SW 里用 self.__WB_MANIFEST 接收清单:
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
// 导航请求回退到预缓存的 index.html,交给前端路由处理
const handler = workbox.precaching.createHandlerBoundToURL("/index.html");
workbox.routing.registerRoute(
new workbox.routing.NavigationRoute(handler, { denylist: [/^\/api\//] })
);
createHandlerBoundToURL("/index.html")要求该 URL 真的在预缓存清单里。precacheAndRoute会把相对路径按 SW 所在位置归一化,所以清单里的index.html与/index.html能对上;但如果你把 SW 放到/sw/子目录,路径就要相应调整。
6.5 直接调用 workbox-build(不依赖 CLI)
// scripts/build-sw.js
const { generateSW } = require("workbox-build");
generateSW({
globDirectory: "build",
globPatterns: ["**/*.{html,js,css,png,svg,ico}"],
globIgnores: ["**/*.map"],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
navigateFallback: "index.html",
runtimeCaching: [
{
urlPattern: ({ request }) => request.destination === "image",
handler: "StaleWhileRevalidate",
options: {
cacheName: "image-cache",
expiration: { maxEntries: 20, maxAgeSeconds: 7 * 24 * 60 * 60 }
}
},
{
urlPattern: ({ request }) => request.destination === "script",
handler: "NetworkFirst",
options: { cacheName: "script-cache" }
},
{
urlPattern: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith("/api/"),
handler: "StaleWhileRevalidate",
options: {
cacheName: "api-cache",
cacheableResponse: { statuses: [200] },
backgroundSync: { name: "api-queue", options: { maxRetentionTime: 24 * 60 } }
}
}
],
skipWaiting: true,
clientsClaim: true,
swDest: "build/service-worker.js"
}).then(({ count, size, warnings }) => {
console.log(`预缓存 ${count} 个文件,共 ${(size / 1024).toFixed(1)} KB`);
if (warnings.length) console.warn(warnings);
});
七、工程化集成
7.1 workbox-webpack-plugin
// webpack.config.js
const { GenerateSW, InjectManifest } = require("workbox-webpack-plugin");
module.exports = {
// ...其他配置
plugins: [
// 二选一
new GenerateSW({
clientsClaim: true,
skipWaiting: true,
navigateFallback: "/index.html",
exclude: [/\.map$/, /^manifest.*\.js$/]
})
// new InjectManifest({ swSrc: "./src/sw.js" })
]
};
7.2 vite-plugin-pwa
npm i -D vite-plugin-pwa
// vite.config.js
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
VitePWA({
registerType: "autoUpdate", // 或 'prompt',配合第五节的更新提示
strategies: "generateSW", // 默认值;要手写 SW 改成 'injectManifest'
workbox: {
// strategies 为 generateSW 时生效,配置项同 workbox-build
globPatterns: ["**/*.{js,css,html,svg,png,ico}"]
},
manifest: {
name: "pwa-demo",
short_name: "pwa-demo",
start_url: "/",
display: "standalone",
theme_color: "#ffffff",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" }
]
},
devOptions: { enabled: true } // 开发环境也能验证 SW,生产环境记得关掉
})
]
});
注册(autoUpdate 模式下由插件注入):
import { registerSW } from "virtual:pwa-register";
registerSW({
onNeedRefresh() {
// 提示用户:有新版本
},
onOfflineReady() {
// 提示用户:离线可用
}
});
7.3 CRA(已弃用,仅作历史参考)
create-react-app 已在 2025-02 停止维护,它的 PWA 模板(cra-template-pwa)只适合学习。新项目请用 Vite;本文的 workbox 示例工程仍是 CRA,就是为了对照历史写法,不要照搬到新项目。
八、消息推送(VAPID)
8.1 链路
你的服务端 --(web-push + VAPID 私钥)--> 浏览器厂商的 Push Service(Chrome 用 FCM)
|
v
用户的浏览器 / Service Worker
|
push 事件 → showNotification
你不需要自己连手机,Push Service 由浏览器厂商提供。VAPID 是一对密钥,用来向 Push Service 证明"这个推送确实来自你的服务端"。
8.2 生成密钥并保管(安全红线)
npx web-push generate-vapid-keys
输出一对 publicKey / privateKey。私钥只能存在于服务端环境变量里:
# .env(不要提交)
VAPID_PUBLIC_KEY=<公钥>
VAPID_PRIVATE_KEY=<私钥>
VAPID_SUBJECT=mailto:you@example.com
.env
.env.local
server/key.json
本项目历史上把
server/key.json(含私钥)提交进了 git。一旦公开,唯一的补救是重新生成一对密钥并作废旧密钥——删文件不会让它从历史里消失。
8.3 客户端订阅
// 工具函数:VAPID 公钥是 base64url,需要转成 Uint8Array
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
const output = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i += 1) {
output[i] = raw.charCodeAt(i);
}
return output;
}
async function subscribePush() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
throw new Error("当前浏览器不支持 Web Push");
}
// 权限必须在用户手势(点击事件)里申请,页面加载时申请会被浏览器拦截
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error("用户拒绝了通知权限");
}
const registration = await navigator.serviceWorker.ready;
const existing = await registration.pushManager.getSubscription();
if (existing) return existing;
return registration.pushManager.subscribe({
userVisibleOnly: true, // 必须为 true,否则 Chrome 直接报错
applicationServerKey: urlBase64ToUint8Array("<你的 VAPID 公钥>")
});
}
// 把订阅信息交给服务端保存
async function sendSubscriptionToServer(subscription) {
await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription)
});
}
8.4 服务端发送
// server/push.js
const webPush = require("web-push");
webPush.setVapidDetails(
process.env.VAPID_SUBJECT,
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
const notification = {
title: "构建完成",
body: "你的任务已在后台完成",
icon: "/public/images/icon/icon.png"
};
// subscription 就是客户端 pushManager.subscribe() 的返回值(建议存库)
async function pushTo(subscription) {
await webPush.sendNotification(subscription, JSON.stringify(notification));
}
如果本机需要走代理访问 FCM,sendNotification 支持 proxy 选项(web-push ≥ 3.6):
await webPush.sendNotification(subscription, payload, {
proxy: process.env.HTTPS_PROXY // 例如 http://127.0.0.1:7890
});
SW 内接收:
self.addEventListener("push", event => {
const payload = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(payload.title ?? "通知", {
body: payload.body,
icon: payload.icon,
badge: payload.icon,
data: { url: payload.url ?? "/" }
})
);
});
self.addEventListener("notificationclick", event => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data?.url ?? "/"));
});
8.5 FCM 的历史与现状
旧方案用 webPush.setGCMAPIKey(<服务端密钥>) 走 Legacy FCM,这条通道 Google 已经关停,setGCMAPIKey 也随之废弃。现在的写法只有 VAPID:Chrome 底层仍然经过 FCM,但你不需要申请 FCM 服务端密钥,也不需要引入 Firebase SDK。网上大量 2022 年前的教程仍在教 Legacy FCM,直接跳过。
九、通信与后台同步
9.1 页面 ⇄ SW 通信
// 页面 → SW
const registration = await navigator.serviceWorker.ready;
registration.active?.postMessage({ type: "GET_VERSION" });
// SW → 页面(用 MessageChannel 要回执,更可靠)
// 页面侧
const channel = new MessageChannel();
channel.port1.onmessage = event => {
console.log("SW 回复:", event.data);
};
registration.active?.postMessage({ type: "GET_VERSION" }, [channel.port2]);
// SW 侧
self.addEventListener("message", event => {
if (event.data?.type === "GET_VERSION") {
const port = event.ports[0];
if (port) {
port.postMessage({ version: "1.0.0" });
} else {
self.clients.matchAll().then(clients => clients.forEach(c => c.postMessage({ version: "1.0.0" })));
}
}
});
workbox-window 把这层封装好了:
import { Workbox } from "workbox-window";
const wb = new Workbox("/service-worker.js");
wb.addEventListener("installed", () => console.log("新版本已安装"));
wb.addEventListener("activated", () => console.log("新版本已激活"));
wb.addEventListener("waiting", () => console.log("等待中的新版本"));
wb.register();
const version = await wb.messageSW({ type: "GET_VERSION" });
console.log("SW 版本", version);
9.2 Background Sync
// 页面:网络断了也能"提交",由浏览器排队
async function submitOrder(order) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register("sync-order");
localStorage.setItem("pending-order", JSON.stringify(order));
}
// SW:联网后自动重放
self.addEventListener("sync", event => {
if (event.tag === "sync-order") {
event.waitUntil(replayPendingOrder());
}
});
async function replayPendingOrder() {
const raw = localStorage.getItem("pending-order");
if (!raw) return;
await fetch("/api/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: raw
});
localStorage.removeItem("pending-order");
}
限制:Chrome/Edge 支持,Safari 与 Firefox 不支持(Firefox 曾实现又移除)。不支持时 registration.sync 为 undefined,要降级成"直接发请求 + 失败重试"。
9.3 非 GET 请求为什么不能进缓存
Cache API 的 put 只接受 GET 请求,POST 会抛 Request method 'POST' is unsupported。想"离线提交",正确做法是上面 9.2 的队列;想"缓存接口数据",只缓存 GET 的响应。把 URL 拼成字符串当缓存键那种写法,等于自造一个永远命不中的键,只会让缓存越滚越大。
十、调试、验收与上线清单
10.1 调试面板
| 位置 | 看什么 |
|---|---|
| Application → Service Workers | 状态、Update on reload、Bypass for network、Unregister |
| Application → Cache Storage | 每个缓存的条目、响应内容、可手动删除 |
| Application → Manifest | 字段解析结果、图标、安装性错误 |
| Application → Storage | 一键 Clear site data(清 SW + 缓存 + IndexedDB) |
| Lighthouse → PWA | 可安装性检查 |
chrome://serviceworker-internals |
所有注册的 SW,含 skipWaiting 按钮 |
10.2 常见现象排查
| 现象 | 原因 | 处理 |
|---|---|---|
| SW 注册报 404 | register 路径不对,或 SW 文件被构建丢掉了 |
public/ 下的文件才不会被处理,改路径 |
Failed to register a ServiceWorker: The script has an unsupported MIME type ('text/html') |
服务器把 404 页面返回成了 200 | 检查静态服务配置 |
| 控制台报 scope 不匹配 | SW 在子目录,却想控制 / |
加 Service-Worker-Allowed: / |
| 改了代码页面不变 | 旧 SW 还在 waiting |
DevTools 勾 Update on reload,或按第五节做更新提示 |
| 离线打不开 | 预缓存没写成功(addAll 没 return、或有 404 资源) |
看 Cache Storage 条目,addAll 里任一 404 会导致整体失败 |
iOS 上 Notification.requestPermission 不存在 |
未添加到主屏幕 / 系统 < 16.4 | 引导"添加到主屏幕" |
| 安卓安装提示不出现 | manifest 缺 192/512 图标或 start_url 不可达 |
用 DevTools → Manifest 看具体报错 |
10.3 上线清单
- 全站 HTTPS,且
manifest、service-worker.js不被 CDN 缓存成旧版本 -
service-worker.js设置了Cache-Control: no-cache(否则浏览器拿不到新版本) - 预缓存清单里没有 404、没有超大文件(
maximumFileSizeToCacheInBytes) - 更新策略已选定(静默 skipWaiting 还是提示刷新),并已告知产品
- 缓存名带版本号,激活时清理旧缓存
- 接口缓存只缓存 GET,鉴权接口不缓存
- VAPID 私钥在环境变量里,不在仓库里
- 已在自己的手机上验证"断网 → 打开 → 有内容"
- 已用 Lighthouse 跑过一遍 PWA 检查
- 有"清除缓存/注销 SW"的兜底入口(后台或帮助页)
十一、兼容性矩阵
| 能力 | Chrome/Edge | Safari(macOS) | Safari(iOS) | Firefox |
|---|---|---|---|---|
| Service Worker | 40+ | 11.1+ | 11.3+ | 44+ |
模块化 SW(type: "module") |
91+ | 15+ | 15+ | 147+ |
| Manifest / 安装 | 支持 | 部分(无 beforeinstallprompt) |
只能手动添加主屏 | 无安装入口 |
| Web Push | 支持 | 16.4+ | 16.4+(须已在主屏) | 支持 |
| Background Sync | 支持 | 不支持 | 不支持 | 不支持 |
| Periodic Background Sync | 支持(需已安装) | 不支持 | 不支持 | 不支持 |
表里的"模块化 SW"指的是
navigator.serviceWorker.register(url, { type: "module" })。importScripts仍然是兼容性最好的写法,但它不是唯一写法——"SW 不支持模块化"这句话在 2021 年之后就不准确了。
十二、FAQ
Q1:为什么我的 manifest.json 完全不生效?
九成是 JSON 里写了注释或尾逗号。manifest 用严格 JSON 解析,一个 // 就让整份配置被忽略。用 DevTools → Application → Manifest 看红字提示。
Q2:SW 注册成功,但离线打不开页面?
按顺序查:① install 里 addAll 的 Promise 有没有 return(没有的话 waitUntil 立刻完成,缓存没写完 SW 就激活了);② 预缓存列表里是否有 404 资源(addAll 是原子操作,一个失败全失败);③ 导航请求有没有被 fetch 处理器接住(request.mode === 'navigate')。
Q3:改了代码,刷新还是旧页面?
新 SW 卡在 waiting,旧 SW 还在控制页面。临时办法:DevTools 勾 Update on reload;正式办法:第五节的新版本提示。
Q4:cache.put 报 Request method 'POST' is unsupported?
Cache API 只支持 GET,改用 Background Sync 或 IndexedDB 队列。
Q5:Service-Worker-Allowed 怎么配?
Express:
app.use(
express.static("static", {
setHeaders: (res, filePath) => {
if (/service-worker\.js$/.test(filePath)) {
res.setHeader("Service-Worker-Allowed", "/");
res.setHeader("Cache-Control", "no-cache");
}
}
})
);
注意匹配的是文件路径,/service-worker\.js$/ 即可;写成 .*\/|\\service-worker\.js$ 这种带分支的正则在 Linux 上会把所有静态文件都加上该响应头。
Q6:Notification.requestPermission() 一直不弹窗?
必须由用户手势触发(点击事件里),且在 HTTPS 下;iOS 还必须已"添加到主屏幕"。
Q7:webPush.sendNotification 报 401/403?
VAPID 公钥与私钥不匹配,或 subject 不是 mailto: / https:// 开头。旧教程里的 setGCMAPIKey 已经废弃,别用。
Q8:Workbox 的 injectManifest 报 Unable to find a place to inject the manifest?
你的 SW 里没有 self.__WB_MANIFEST 这个占位符。要有 precacheAndRoute(self.__WB_MANIFEST) 这样的引用。
Q9:navigateFallback 配了但离线还是 404?
回退页面必须被预缓存(globPatterns 覆盖到),且路径是相对服务器根目录的 URL。
Q10:Safari 上 Background Sync 无效?
Safari 不支持该 API,代码里要做特性检测('sync' in registration)并降级。
十三、版本矩阵与升级提示
| 依赖 | 本文使用 | 说明 |
|---|---|---|
| Node | ≥ 18 | workbox-build 7 要求 |
| workbox-* | 7.x | 7 里 workbox.core.skipWaiting() 仍可用但会打废弃警告,官方建议直接 self.skipWaiting() |
| workbox-cli | 7.x | 必须装成项目依赖,不要依赖全局 |
| web-push | 3.6+ | proxy 选项从 3.6 开始支持 |
| vite-plugin-pwa | 0.20+ | 注意 registerType 与 devOptions |
| Create React App | 已停止维护 | 仅作历史参考,新项目用 Vite |