React 基础体系 · 第 56/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React PWA:Service Worker、缓存更新、离线和安装体验
PWA(Progressive Web App)不是 React 的一个组件,也不是“给网页加一个安装按钮”这么简单。它是一组浏览器能力的组合:通过 Web App Manifest 描述应用身份和启动方式,通过 Service Worker 拦截网络请求并管理缓存,再通过 HTTPS、响应式界面和合适的离线行为,逐步接近已安装应用的体验。
React 负责组件渲染、状态管理和交互;Service Worker 运行在浏览器的独立 Worker 上下文中,不能访问 React 组件、DOM 或 window;Manifest 是静态 JSON 配置;安装提示则由浏览器根据自身规则决定。理解这些边界,是设计 PWA 的前提。
本文使用 React 19、现代 TypeScript 和浏览器原生 Service Worker API。示例假设应用最终部署在站点根路径 /,并以传统静态构建产物为例。Next.js、Remix、React Router 等框架可以复用相同的浏览器机制,但需要把注册时机、服务端渲染和构建插件接入各自的生命周期。
一、PWA 的组成与运行边界
一个典型 PWA 至少涉及四个部分:
- React 应用:渲染页面、显示加载和离线状态、处理安装入口。
- Service Worker:安装、激活、拦截请求、读写 Cache Storage。
- Web App Manifest:定义应用名称、图标、启动 URL、显示模式等。
- 部署环境:HTTPS、正确的响应头、静态资源和 HTML 的发布策略。
它们的关系可以表示为:
flowchart LR
Browser[浏览器页面]
React[React 应用]
SW[Service Worker]
Cache[Cache Storage]
HTTP[网络服务器/CDN]
Manifest[Web App Manifest]
Install[安装入口]
Browser --> React
Browser --> SW
React -->|注册/发送消息| SW
SW -->|读取/写入| Cache
SW -->|请求网络| HTTP
Browser -->|读取 manifest| Manifest
Browser -->|根据条件决定| Install
Service Worker 与 React 之间不是函数调用关系,而是消息和浏览器控制关系:
- React 页面通过
navigator.serviceWorker.register()注册 Worker。 - Worker 安装和激活后,可能控制页面的网络请求。
- React 可以通过
registration.waiting.postMessage()通知等待中的 Worker。 - Worker 可以通过
clientsClaim()或页面重新加载后接管客户端。 - Worker 不能直接调用 React 的
setState;页面需要监听controllerchange、message等事件自行更新界面。
Service Worker 的执行环境独立于页面:
// 下面的代码在 Service Worker 中不可用
document.querySelector("#root");
window.localStorage;
React.useState();
Service Worker 可以使用 fetch、caches、self、clients 等 Worker API,但不能访问 DOM。需要持久化结构化业务数据时,通常使用 IndexedDB,而不是把大量业务数据塞进 Cache Storage。
二、Service Worker 的生命周期
Service Worker 不会在每次页面加载时简单地“执行一遍”。它有自己的生命周期:
stateDiagram-v2
[*] --> Parsed: register()
Parsed --> Installing: 下载新脚本
Installing --> InstalledWaiting: install 成功
Installing --> Redundant: install 失败
InstalledWaiting --> Activating: 无旧 Worker 或 skipWaiting()
InstalledWaiting --> Activated: 等待旧客户端退出
Activating --> Activated: activate 成功
Activating --> Redundant: activate 失败
Activated --> Controlling: clientsClaim() 或页面重新加载
Controlling --> Activated: 控制页面请求
更准确地说,浏览器会把“新版本 Worker”称为 installing、waiting 或 activating 状态,把当前正在控制页面的版本称为 active。新 Worker 下载完成后,不会默认立即替换旧 Worker,因为旧页面可能仍然由旧版本代码控制。
1. install
install 通常用于预缓存必须的应用外壳,例如入口 HTML、图标和明确知道名称的静态资源。
const SHELL_CACHE = "shell-v3";
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => {
return cache.addAll([
"/",
"/index.html",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
]);
})
);
});
event.waitUntil() 很重要。它把异步任务绑定到安装生命周期:
- 所有资源成功缓存,安装成功;
- 任意一个
cache.addAll()请求失败,整个安装通常失败; - 安装失败时,新 Worker 不会进入可用的 active 状态。
因此,预缓存清单中的每个 URL 都必须确实存在,并且响应状态适合缓存。把不存在的文件写入 addAll(),会导致看似“注册成功”,实际 Worker 始终停留在失败状态。
2. activate
activate 通常用于删除旧缓存、迁移缓存结构,并让新的 Worker 接管页面:
const STATIC_CACHE = "static-v3";
const OLD_CACHES = ["shell-v1", "shell-v2", "static-v1", "static-v2"];
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
...OLD_CACHES.map((name) => caches.delete(name)),
self.clients.claim(),
])
);
});
clients.claim() 只会让已经处于 active 状态的 Worker 尝试控制当前作用域内的页面;它不会跳过 waiting 阶段,也不会自动刷新 React 应用。
删除旧缓存存在一个真实风险:旧页面可能还在运行旧 JavaScript,而旧 JavaScript 引用了旧的哈希资源。如果部署系统已经删除这些旧资源,新 Worker 删除旧缓存后,旧页面刷新或动态请求就可能出现 404。
生产环境通常采用以下策略之一:
- CDN 和静态服务器保留一段时间的旧哈希文件;
- 让新 Worker 不立即删除所有旧静态资源;
- 对资源缓存按版本迁移,并允许旧版本资源继续读取;
- 使用 Workbox 等构建工具自动生成预缓存清单,但仍然需要设计更新和部署兼容策略。
3. waiting、skipWaiting 和更新时机
新 Worker 安装完成后,默认会进入 waiting,直到没有旧客户端使用旧 Worker。调用:
self.skipWaiting();
可以让它跳过等待并尽快激活,但这不总是安全。
假设用户正在使用旧版本 React 应用:
- 旧 HTML 加载旧
main.abc.js; - 新 Worker 下载完成;
- 新 Worker 执行
skipWaiting()并接管页面; - 页面中旧代码发起新的请求;
- 新 Worker 按新版本规则处理旧页面请求。
此时同一个页面可能由旧代码和新缓存策略共同组成。若新旧版本的数据协议、路由或资源命名不兼容,就可能出现难以复现的问题。
更稳妥的交互是:
- 新 Worker 安装完成并进入 waiting;
- 页面提示“发现新版本”;
- 用户点击“立即更新”;
- 页面向 waiting Worker 发送消息;
- Worker 执行
skipWaiting(); - 页面监听
controllerchange后刷新。
三、一个可运行的 Service Worker 示例
Service Worker 必须是浏览器可以直接加载的 JavaScript 文件。即使 React 应用使用 TypeScript,public/sw.js 也不能未经构建就直接放入 TypeScript 语法。
下面是一个适用于静态 React 应用的最小实现:
// public/sw.js
const VERSION = "v3";
const SHELL_CACHE = `shell-${VERSION}`;
const STATIC_CACHE = `static-${VERSION}`;
const PRECACHE_URLS = [
"/",
"/index.html",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => {
return cache.addAll(PRECACHE_URLS);
})
);
});
self.addEventListener("activate", (event) => {
const validCaches = new Set([SHELL_CACHE, STATIC_CACHE]);
event.waitUntil(
caches
.keys()
.then((names) =>
Promise.all(
names
.filter((name) => name.startsWith("shell-") || name.startsWith("static-"))
.filter((name) => !validCaches.has(name))
.map((name) => caches.delete(name))
)
)
.then(() => self.clients.claim())
);
});
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
self.addEventListener("fetch", (event) => {
const request = event.request;
const url = new URL(request.url);
// 只处理同源 GET 请求,避免接管 POST、跨域 API 和第三方资源。
if (request.method !== "GET" || url.origin !== self.location.origin) {
return;
}
// 页面导航采用 network-first:
// 在线时优先获得最新 HTML,离线时回退到缓存的应用外壳。
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(SHELL_CACHE).then((cache) => {
cache.put("/index.html", copy);
});
}
return response;
})
.catch(() => caches.match("/index.html"))
);
return;
}
// JS、CSS、字体和图片采用 cache-first:
// 已缓存时直接返回,未缓存时请求网络并写入运行时缓存。
const isStaticAsset =
["script", "style", "font", "image"].includes(request.destination);
if (isStaticAsset) {
event.respondWith(
caches.match(request).then((cached) => {
if (cached) {
return cached;
}
return fetch(request).then((response) => {
// response.ok 只接受成功的同源 HTTP 响应。
// 不缓存 404、500 等错误页面。
if (!response.ok) {
return response;
}
const copy = response.clone();
caches.open(STATIC_CACHE).then((cache) => {
cache.put(request, copy);
});
return response;
});
})
);
}
});
这个示例的请求策略是:
| 请求类型 | 在线时 | 离线时 |
|---|---|---|
| 页面导航 | 请求网络并更新 /index.html 缓存 |
返回缓存的 /index.html |
| JS/CSS/图片/字体 | 命中缓存直接返回,否则请求网络并缓存 | 返回已有缓存,否则失败 |
| POST、跨域请求 | 不拦截 | 交给浏览器默认行为 |
| API GET | 示例中不自动缓存 | 交给浏览器默认行为 |
页面导航使用 network-first,因为 HTML 通常包含最新的资源引用和版本信息。静态哈希资源使用 cache-first,因为文件名变化后内容不可变,缓存命中可以减少网络请求。
这里的 response.clone() 不能省略。响应体通常是一次性可读流;一份响应返回给页面,另一份响应写入缓存,因此需要克隆出两个可消费的响应对象。
四、缓存策略不是越激进越好
缓存策略决定了“新鲜度”和“离线能力”的取舍。可以把一次请求抽象为:
其中:
- 表示 Cache Storage 中的响应;
- 表示网络响应;
- 表示离线回退响应。
不同资源需要不同的正确性条件。
1. Cache-first
缓存命中 -> 返回缓存
缓存未命中 -> 请求网络 -> 写入缓存 -> 返回网络响应
适合:
- 带内容哈希的 JS、CSS;
- 字体;
- 不经常变化的图片;
- 明确版本化的静态资源。
如果资源 URL 不变但内容会变化,Cache-first 可能让用户长期看到旧内容。此时必须通过版本化 URL、显式失效或 network-first 修正。
2. Network-first
请求网络 -> 成功则返回并更新缓存
网络失败 -> 返回缓存
缓存也没有 -> 显示离线错误
适合:
- HTML;
- 需要较新内容的文章列表;
- 可接受离线时显示旧数据的 GET API。
Network-first 并不保证永远拿到最新数据。网络请求成功但服务器返回旧数据、CDN 返回旧数据,Service Worker 无法凭空判断内容是否业务上最新。
3. Stale-while-revalidate
先返回缓存
同时请求网络
网络成功后更新缓存
下次请求获得新内容
它降低了首屏等待,但当前页面看到的是旧数据。对于价格、库存、权限、账户余额等信息,不能仅因为接口是 GET 就盲目采用该策略。
4. 不要缓存所有 API 请求
Service Worker 不能自动理解 API 数据的业务语义。以下内容通常不应直接缓存:
- POST、PUT、PATCH、DELETE;
- 用户权限和会话相关响应;
- 强时效数据;
- 含有个人信息且没有明确隔离策略的数据;
- 服务器通过
Set-Cookie或认证头返回的敏感内容。
如果确实需要离线业务数据,应分别设计:
- IndexedDB 中的数据模型;
- 数据版本和过期时间;
- 离线写入队列;
- 恢复联网后的同步;
- 冲突解决规则;
- 用户可见的同步状态。
Service Worker 的缓存命中不等于业务数据可用。缓存了一个列表页面,只能说明页面资源可读取,不能说明用户可以离线提交订单。
五、离线体验的完整条件
“支持离线”至少要拆成三层:
1. 应用外壳离线
应用外壳通常包括:
- HTML;
- React JavaScript;
- CSS;
- 字体;
- 路由所需的资源;
- 离线页面或错误页面;
- 必需图标。
这些资源构成一个近似闭包:
如果 HTML 能从缓存返回,但它引用的 JS 不在缓存中,页面仍然无法启动。因而“缓存了首页”并不等于“首页可离线运行”。
2. 路由离线
对于使用客户端路由的 React 应用,用户可能直接访问:
/settings/profile
服务器必须在该 URL 返回应用入口 HTML,或者 Service Worker 在导航失败时回退到 /index.html。否则刷新深层路由时可能得到服务器 404。
不过,回退到 /index.html 只解决了应用启动问题,不保证路由页面所需的数据存在。React 页面仍应区分:
- 应用代码可用;
- 页面数据可用;
- 用户操作可提交。
3. 业务数据离线
离线数据需要持久化和同步设计。例如离线创建一条待办事项:
用户操作
-> 写入 IndexedDB,状态为 pending
-> 页面立即显示本地结果
-> 恢复联网后提交服务器
-> 成功:状态改为 synced
-> 失败:状态改为 failed,并允许重试
Service Worker 可以参与后台同步,但 Background Sync 的支持情况和行为受浏览器限制,不能把它当作所有浏览器都可靠执行的任务队列。关键业务应在页面恢复联网时主动重试,并向用户展示失败原因。
六、在 React 中注册 Service Worker
注册必须发生在浏览器端。对于纯客户端 Vite 应用,可以在入口文件中注册;对于 SSR 框架,必须放在客户端生命周期中,不能在服务端模块初始化阶段访问 navigator。
// src/serviceWorkerRegistration.ts
export function registerServiceWorker(
onUpdate?: (registration: ServiceWorkerRegistration) => void,
): void {
if (!("serviceWorker" in navigator)) {
return;
}
window.addEventListener("load", () => {
navigator.serviceWorker
.register("/sw.js", { scope: "/" })
.then((registration) => {
console.info("Service Worker registered:", registration.scope);
registration.addEventListener("updatefound", () => {
const installingWorker = registration.installing;
if (!installingWorker) {
return;
}
installingWorker.addEventListener("statechange", () => {
if (
installingWorker.state === "installed" &&
navigator.serviceWorker.controller
) {
// 已经存在旧控制器,说明这是一次更新,而不是首次安装。
onUpdate?.(registration);
}
});
});
})
.catch((error: unknown) => {
console.error("Service Worker registration failed:", error);
});
});
}
在应用入口调用:
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { registerServiceWorker } from "./serviceWorkerRegistration";
import "./index.css";
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element #root was not found");
}
registerServiceWorker((registration) => {
window.dispatchEvent(
new CustomEvent("pwa:update-available", {
detail: registration,
}),
);
});
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
navigator.serviceWorker.controller 在首次安装时通常为空,因为首次加载的页面并不一定已经由新 Worker 控制。只有在后续加载,或 Worker 通过 clientsClaim() 接管后,页面才有 controller。因此,不能仅用“安装完成”判断“发现了更新”。
处理更新确认
// src/UpdatePrompt.tsx
import { useEffect, useState } from "react";
export function UpdatePrompt() {
const [registration, setRegistration] =
useState<ServiceWorkerRegistration | null>(null);
const [updating, setUpdating] = useState(false);
useEffect(() => {
const handleUpdate = (event: Event) => {
const customEvent = event as CustomEvent<ServiceWorkerRegistration>;
setRegistration(customEvent.detail);
};
window.addEventListener("pwa:update-available", handleUpdate);
return () => {
window.removeEventListener("pwa:update-available", handleUpdate);
};
}, []);
const applyUpdate = () => {
const waiting = registration?.waiting;
if (!waiting) {
return;
}
setUpdating(true);
const reload = () => {
navigator.serviceWorker.removeEventListener("controllerchange", reload);
window.location.reload();
};
navigator.serviceWorker.addEventListener("controllerchange", reload);
waiting.postMessage({ type: "SKIP_WAITING" });
};
if (!registration) {
return null;
}
return (
<aside role="status">
<p>{updating ? "正在更新应用…" : "发现新版本。"}</p>
{!updating && (
<button type="button" onClick={applyUpdate}>
立即更新
</button>
)}
</aside>
);
}
这里的时序是:
新 sw.js 下载
-> 新 Worker install 成功
-> 新 Worker waiting
-> React 收到 updatefound/statechange
-> 用户确认
-> 页面 postMessage(SKIP_WAITING)
-> Worker activate + clientsClaim
-> controllerchange
-> 页面刷新并加载新资源
controllerchange 比固定延时刷新可靠,因为它表示页面实际控制器发生了变化。固定 setTimeout 不能保证 Worker 已经激活。
如果产品允许“下次打开自动更新”,可以不调用 skipWaiting(),让浏览器在旧页面退出后自然切换。这样更新更保守,但用户可能较长时间看不到新版本。
七、缓存更新与部署一致性
Service Worker 脚本本身也会被浏览器更新检查。更新检查不是每次调用 register() 都重新下载任意内容;浏览器会根据自身更新算法、脚本 URL 和 HTTP 缓存规则决定是否重新验证。修改 sw.js、改变版本常量,通常是触发新 Worker 的常见方式,但不应把“改缓存名”误认为“强制清除所有浏览器缓存”。
一个典型的 React 构建输出可能是:
index.html
assets/
index-a81d3c.js
index-72b91e.css
sw.js
manifest.webmanifest
icons/
icon-192.png
icon-512.png
如果新版本把 index-a81d3c.js 改成 index-f90a11.js,更新流程应满足:
- 新 HTML 引用新哈希资源;
- 新 Worker 能获得新 HTML;
- 新哈希资源可以从网络下载或被预缓存;
- 旧页面短时间内仍能获得旧哈希资源;
- 新旧资源不会因为过早清理而互相破坏。
因此,生产部署通常需要“发布原子性”:
- 先上传新的哈希资源;
- 再发布新的 HTML;
- 最后让 Service Worker 获取新版本;
- 不要立刻删除旧哈希文件。
对于 HTML,常见的 HTTP 缓存策略是较短缓存或要求重新验证;对于带内容哈希的静态资源,可以使用长期缓存,例如:
# index.html
Cache-Control: no-cache
# assets/index-a81d3c.js
Cache-Control: public, max-age=31536000, immutable
HTTP 缓存和 Cache Storage 是两层不同机制:
- HTTP 缓存由浏览器根据 HTTP 头管理;
- Cache Storage 由 Service Worker 通过
caches.open()、cache.put()等 API 管理; - Service Worker 的
fetch()逻辑可能先于普通网络请求生效; - 清理 HTTP 缓存不会自动删除 Cache Storage;
- 清理 Cache Storage 也不会自动删除 HTTP 缓存。
诊断缓存更新时,需要同时查看:
- DevTools 的 Application → Service Workers;
- Cache Storage 中的缓存名称和条目;
- Network 面板的
from ServiceWorker、状态码和响应内容; - 页面当前的
navigator.serviceWorker.controller; - 是否存在 waiting Worker;
- 服务器是否仍保留旧哈希资源。
八、Manifest 与安装体验
Manifest 是浏览器读取的 JSON 文件,用于描述可安装应用的身份和启动方式。它不负责缓存,也不能替代 Service Worker。
{
"name": "任务板",
"short_name": "任务板",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}
在 HTML 中声明:
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#ffffff" />
主要字段的含义如下:
name:完整应用名称。short_name:空间不足时使用的短名称。start_url:从已安装应用启动时打开的 URL。scope:应用控制的 URL 范围。display:安装后窗口显示方式。standalone通常隐藏浏览器地址栏,但具体平台表现不同。icons:安装器和启动器使用的图标。theme_color:浏览器或系统 UI 可能使用的主题色。background_color:启动阶段或应用背景可能使用的颜色。
Manifest 中的 start_url 必须位于 scope 内。若应用部署在 /console/,不能机械地使用根路径配置,而应改为:
{
"start_url": "/console/",
"scope": "/console/"
}
同时 Service Worker 注册路径也应与作用域匹配,例如:
navigator.serviceWorker.register("/console/sw.js", {
scope: "/console/",
});
Service Worker 的默认作用域是其脚本所在目录及子目录。要扩大作用域,通常应调整脚本路径,或由服务器返回 Service-Worker-Allowed 响应头;不能仅在 register() 中随意指定超出默认允许范围的 scope。
九、安装提示不是普通按钮调用
在部分 Chromium 浏览器中,浏览器会触发 beforeinstallprompt 事件。这个事件不是所有浏览器都支持,也不是 Web 标准中保证跨平台一致的安装 API。应用可以暂存事件,等用户点击自定义按钮后调用 prompt()。
// src/InstallButton.tsx
import { useEffect, useState } from "react";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{
outcome: "accepted" | "dismissed";
platform: string;
}>;
}
export function InstallButton() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(false);
useEffect(() => {
const handleBeforeInstallPrompt = (event: Event) => {
event.preventDefault();
setDeferredPrompt(event as BeforeInstallPromptEvent);
};
const handleInstalled = () => {
setInstalled(true);
setDeferredPrompt(null);
};
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.addEventListener("appinstalled", handleInstalled);
return () => {
window.removeEventListener(
"beforeinstallprompt",
handleBeforeInstallPrompt,
);
window.removeEventListener("appinstalled", handleInstalled);
};
}, []);
if (installed || !deferredPrompt) {
return null;
}
const install = async () => {
const promptEvent = deferredPrompt;
await promptEvent.prompt();
const choice = await promptEvent.userChoice;
if (choice.outcome === "accepted") {
console.info("User accepted installation");
} else {
console.info("User dismissed installation");
}
// 该事件通常只能使用一次。
setDeferredPrompt(null);
};
return (
<button type="button" onClick={install}>
安装应用
</button>
);
}
此流程中有几个容易误解的事实:
beforeinstallprompt不一定触发;- 事件可能只在浏览器认为应用满足安装条件时触发;
prompt()通常应由用户点击等用户手势触发;- 一个事件通常只能调用一次;
- 用户拒绝后,不应在每次渲染时立刻重新弹窗;
- iOS、Safari、桌面浏览器和 Android 浏览器的安装 UI 不完全一致;
appinstalled表示安装事件,不是所有平台都保证以相同方式触发。
因此,产品界面应提供“浏览器菜单中安装”或“添加到主屏幕”的平台说明作为备用路径,而不是只依赖一个自定义安装按钮。
十、HTTPS、作用域和服务器配置
Service Worker 除了 localhost 等本地开发环境外,通常要求安全上下文,也就是 HTTPS。以下情况会阻止或限制正常使用:
- 生产环境使用 HTTP;
sw.js返回 404;sw.js的 MIME 类型或响应被代理错误修改;- 服务器返回了旧的 Worker 脚本;
- 注册路径和应用实际路径不一致;
- CSP、CDN 或代理阻止了脚本加载;
- iframe、跨域页面和 Worker 作用域不满足安全限制。
可以用浏览器控制台检查:
console.log({
supported: "serviceWorker" in navigator,
controller: navigator.serviceWorker?.controller?.scriptURL ?? null,
onlineHint: navigator.onLine,
});
navigator.onLine 只是网络状态提示,不是可靠的互联网连通性证明。连接到局域网、被 captive portal 拦截、DNS 失败或服务器不可用,都可能让它与实际请求结果不一致。真正判断在线能力,应以实际请求成功或失败为准。
在 React 中显示离线提示时,可以把 online 和 offline 事件用于改善体验,但不要据此决定所有业务请求是否发送:
import { useEffect, useState } from "react";
export function NetworkStatus() {
const [online, setOnline] = useState(() => navigator.onLine);
useEffect(() => {
const goOnline = () => setOnline(true);
const goOffline = () => setOnline(false);
window.addEventListener("online", goOnline);
window.addEventListener("offline", goOffline);
return () => {
window.removeEventListener("online", goOnline);
window.removeEventListener("offline", goOffline);
};
}, []);
return (
<p role="status">
{online ? "网络状态:可能在线" : "网络状态:浏览器判断为离线"}
</p>
);
}
“可能在线”的措辞更准确,因为最终仍需通过业务请求验证。
十一、React 与 SSR、路由框架的边界
在客户端渲染的 React 应用中,注册 Service Worker 可以放在入口文件;在 SSR 中,服务端会先执行模块并生成 HTML,此时不存在 window、navigator 和 document。
错误写法:
// SSR 环境下模块加载时可能直接抛错
navigator.serviceWorker.register("/sw.js");
正确思路是把它放到仅客户端执行的生命周期中:
import { useEffect } from "react";
export function ServiceWorkerBootstrap() {
useEffect(() => {
if ("serviceWorker" in navigator) {
void navigator.serviceWorker.register("/sw.js");
}
}, []);
return null;
}
在 React 19 中,useEffect 的客户端执行语义仍然适合注册这类浏览器副作用。开发模式下 Strict Mode 可能让 effect 经历额外的开发期检查,因此注册函数应当是幂等的;同一个 scope 的重复 register() 通常会复用或更新同一注册,但不能把注册逻辑设计成依赖“只执行一次”的隐含副作用。
框架还会影响以下问题:
- 构建工具是否自动生成预缓存清单;
- 静态资源的 public path 和 base URL;
- SSR HTML 如何缓存;
- 客户端路由的深层 URL 如何回退;
- API 请求是否经过同源代理;
- Service Worker 文件是否被放到了正确的静态目录。
React 文档主要规定 React 组件和 API 的行为,并不定义 PWA、Service Worker 或安装标准。PWA 的核心规范来自浏览器平台 API 和 Web 标准,因此不能把某个 React 组件库的行为当作浏览器保证。
十二、常见失败表现与诊断路径
1. 修改了 sw.js,页面却没有更新
可能原因包括:
- 新 Worker 只是 waiting,还没有激活;
- 页面当前仍由旧 controller 控制;
- 浏览器 DevTools 勾选了“Update on reload”以外的缓存行为;
- CDN 返回旧的
sw.js; - Worker 脚本发生语法错误;
activate阶段失败。
诊断步骤:
DevTools
-> Application
-> Service Workers
-> 查看 registration 的 installing/waiting/active 状态
-> 点击 Update
-> 查看控制台错误
-> 检查 Cache Storage
-> 检查 Network 中 sw.js 的实际内容
2. 离线时只显示空白页
这通常意味着缓存了 HTML,但没有缓存完整应用外壳。依次检查:
- 离线请求
/是否返回 HTML; - HTML 引用的 JS 和 CSS 是否存在于 Cache Storage;
- 静态资源 URL 是否使用了不同的 base path;
- 字体、动态导入 chunk 是否缺失;
- 是否在开发服务器而非生产构建上测试;
- HTML 是否通过缓存引用了已被部署删除的旧哈希文件。
动态 import() 产生的 chunk 是常见遗漏点。只缓存入口 JS 不够,路由页面或懒加载组件的 chunk 也必须在离线场景下可获得。
3. 更新后出现 chunk 404
常见时序是:
旧 HTML -> 旧 chunk
新部署删除旧 chunk
新 Worker 或浏览器请求旧 chunk
服务器返回 404
恢复方法通常不是让用户反复刷新,而是:
- 恢复旧哈希静态资源;
- 保留旧资源一段时间;
- 修正部署顺序;
- 检查是否过早删除了旧缓存;
- 必要时让页面识别 chunk 加载失败并提示重新加载。
4. /settings 刷新时返回 404
这是服务器路由回退问题,而不只是 React Router 问题。服务器需要把应用路由回退到入口 HTML,或者 Service Worker 在导航失败时返回缓存的 /index.html。但 API 路径不应被无条件回退到 HTML,否则前端会把一段 HTML 当成 JSON 解析。
5. 用户一直看到旧数据
可能是三层缓存共同造成的:
Service Worker Cache Storage
-> HTTP Cache
-> CDN/代理缓存
-> 源站
应分别检查:
- Service Worker 是否采用 cache-first;
- Cache Storage 的条目是否过期;
- HTTP 响应的
Cache-Control; - CDN 是否按正确的 URL、查询参数和鉴权策略缓存;
- API 是否实际返回了新数据。
仅修改 React 状态或重新渲染,不能绕过 Service Worker 已经返回的旧响应。
十三、缓存安全和数据一致性
Cache Storage 的键通常包含请求 URL 和匹配条件,但缓存策略仍需要显式设计。尤其要注意:
- 不要把不同用户的认证响应放入共享缓存;
- 不要缓存含敏感信息的 HTML 或 API 响应;
- 不要默认缓存带有用户身份的请求;
- 不要把服务端错误响应当作正常业务数据缓存;
- 需要考虑 URL 查询参数是否影响响应内容;
- 跨域响应可能是 opaque,状态码和内容不可被页面正常检查。
例如,以下写法风险很高:
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
它会无差别接管请求,可能缓存 HTML、API、第三方资源、带认证信息的响应以及写请求失败后的异常结果。Service Worker 的能力越强,错误缓存的影响范围越大。
生产代码应先按请求方法、来源、目的地、路径和响应状态分类,再选择策略。缓存规则本身就是一段数据访问代码,应该接受与 API 客户端类似的安全审查。
十四、一个合理的实现分层
对于多数 React PWA,可以按以下边界组织代码:
React 页面层
- 显示离线状态
- 显示更新提示
- 显示安装入口
- 决定业务数据是否允许离线
Service Worker 层
- 预缓存应用外壳
- 处理导航回退
- 缓存静态资源
- 处理明确允许缓存的 GET 请求
- 响应页面消息
构建与部署层
- 生成哈希资源
- 发布 sw.js 和 manifest
- 保留旧资源
- 配置 HTTPS、路由回退和缓存头
业务数据层
- IndexedDB 数据模型
- 离线写入队列
- 同步、重试和冲突解决
这几层不能互相替代:
- React 不能替代 Service Worker 的网络拦截;
- Service Worker 不能替代业务同步逻辑;
- Manifest 不能替代缓存;
- 缓存 HTML 不能保证 API 数据可用;
- 浏览器显示安装入口不能保证离线功能完整。
一个 PWA 是否可靠,最终取决于这些条件是否同时成立:
其中任意一项不成立,用户都可能得到“能安装但不能使用”的应用。真正的工程目标不是让浏览器出现安装图标,而是让缓存更新、离线回退、业务数据和版本切换在各种故障路径下仍然具有可解释的行为。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Bundle 分析:Tree Shaking、分包、预加载和性能预算
- 下一篇:React 国际化:消息目录、Locale、日期数字、路由和回退
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论