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 至少涉及四个部分:

  1. React 应用:渲染页面、显示加载和离线状态、处理安装入口。
  2. Service Worker:安装、激活、拦截请求、读写 Cache Storage。
  3. Web App Manifest:定义应用名称、图标、启动 URL、显示模式等。
  4. 部署环境: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;页面需要监听 controllerchangemessage 等事件自行更新界面。

Service Worker 的执行环境独立于页面:

// 下面的代码在 Service Worker 中不可用
document.querySelector("#root");
window.localStorage;
React.useState();

Service Worker 可以使用 fetchcachesselfclients 等 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. waitingskipWaiting 和更新时机

新 Worker 安装完成后,默认会进入 waiting,直到没有旧客户端使用旧 Worker。调用:

self.skipWaiting();

可以让它跳过等待并尽快激活,但这不总是安全。

假设用户正在使用旧版本 React 应用:

  1. 旧 HTML 加载旧 main.abc.js
  2. 新 Worker 下载完成;
  3. 新 Worker 执行 skipWaiting() 并接管页面;
  4. 页面中旧代码发起新的请求;
  5. 新 Worker 按新版本规则处理旧页面请求。

此时同一个页面可能由旧代码和新缓存策略共同组成。若新旧版本的数据协议、路由或资源命名不兼容,就可能出现难以复现的问题。

更稳妥的交互是:

  1. 新 Worker 安装完成并进入 waiting;
  2. 页面提示“发现新版本”;
  3. 用户点击“立即更新”;
  4. 页面向 waiting Worker 发送消息;
  5. Worker 执行 skipWaiting()
  6. 页面监听 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() 不能省略。响应体通常是一次性可读流;一份响应返回给页面,另一份响应写入缓存,因此需要克隆出两个可消费的响应对象。


四、缓存策略不是越激进越好

缓存策略决定了“新鲜度”和“离线能力”的取舍。可以把一次请求抽象为:

R={C缓存命中N网络成功F网络失败且存在回退R = \begin{cases} C & \text{缓存命中} \\ N & \text{网络成功} \\ F & \text{网络失败且存在回退} \end{cases}

其中:

  • CC 表示 Cache Storage 中的响应;
  • NN 表示网络响应;
  • FF 表示离线回退响应。

不同资源需要不同的正确性条件。

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 或认证头返回的敏感内容。

如果确实需要离线业务数据,应分别设计:

  1. IndexedDB 中的数据模型;
  2. 数据版本和过期时间;
  3. 离线写入队列;
  4. 恢复联网后的同步;
  5. 冲突解决规则;
  6. 用户可见的同步状态。

Service Worker 的缓存命中不等于业务数据可用。缓存了一个列表页面,只能说明页面资源可读取,不能说明用户可以离线提交订单。


五、离线体验的完整条件

“支持离线”至少要拆成三层:

1. 应用外壳离线

应用外壳通常包括:

  • HTML;
  • React JavaScript;
  • CSS;
  • 字体;
  • 路由所需的资源;
  • 离线页面或错误页面;
  • 必需图标。

这些资源构成一个近似闭包:

Shell={HTML,JS,CSS,Font,Icon,RuntimeDependencies}Shell = \{HTML, JS, CSS, Font, Icon, RuntimeDependencies\}

如果 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,更新流程应满足:

  1. 新 HTML 引用新哈希资源;
  2. 新 Worker 能获得新 HTML;
  3. 新哈希资源可以从网络下载或被预缓存;
  4. 旧页面短时间内仍能获得旧哈希资源;
  5. 新旧资源不会因为过早清理而互相破坏。

因此,生产部署通常需要“发布原子性”:

  • 先上传新的哈希资源;
  • 再发布新的 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 缓存。

诊断缓存更新时,需要同时查看:

  1. DevTools 的 Application → Service Workers;
  2. Cache Storage 中的缓存名称和条目;
  3. Network 面板的 from ServiceWorker、状态码和响应内容;
  4. 页面当前的 navigator.serviceWorker.controller;
  5. 是否存在 waiting Worker;
  6. 服务器是否仍保留旧哈希资源。

八、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 中显示离线提示时,可以把 onlineoffline 事件用于改善体验,但不要据此决定所有业务请求是否发送:

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,此时不存在 windownavigatordocument

错误写法:

// 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,但没有缓存完整应用外壳。依次检查:

  1. 离线请求 / 是否返回 HTML;
  2. HTML 引用的 JS 和 CSS 是否存在于 Cache Storage;
  3. 静态资源 URL 是否使用了不同的 base path;
  4. 字体、动态导入 chunk 是否缺失;
  5. 是否在开发服务器而非生产构建上测试;
  6. 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 是否可靠,最终取决于这些条件是否同时成立:

Reliable Offline=Shell CompletenessRoute FallbackCache Policy CorrectnessDeployment CompatibilityBusiness Data SemanticsReliable\ Offline = Shell\ Completeness \land Route\ Fallback \land Cache\ Policy\ Correctness \land Deployment\ Compatibility \land Business\ Data\ Semantics

其中任意一项不成立,用户都可能得到“能安装但不能使用”的应用。真正的工程目标不是让浏览器出现安装图标,而是让缓存更新、离线回退、业务数据和版本切换在各种故障路径下仍然具有可解释的行为。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。