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 两个硬前提

  1. HTTPS(或 localhost):navigator.serviceWorker 在非安全上下文里直接是 undefined。

  2. 同源作用域: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))
	);
});

两个细节值得记住:

  1. self 就是 SW 的全局对象,写 this 也能跑,但 self 更明确,箭头函数里也不会丢上下文。
  2. caches.delete 返回 Promise,必须用 Promise.all 收集,否则 waitUntil 不等它。

3.4 用 DevTools 验证

  1. F12 → Application → Manifest:确认无红字,图标正常显示。
  2. Application → Service Workers:看到 #1 activated and is running。
  3. Application → Cache Storage:展开 precache-v1,条目应为 3 条。
  4. Network 面板勾 Offline,刷新:页面仍能打开 → 预缓存生效。
  5. 改一行 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

copyLibraries 生成的 workbox 本地文件

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
← 返回帖子 更多前端内容 →