# skipWaiting() 与更新流程

> 浏览器如何检测字节不同的 worker 脚本、新 worker 为何停在 waiting，以及 skipWaiting()、clients.claim()、update() 与 updateViaCache 各改变什么。

import Figure from '@components/Figure.astro';
import updateStrategiesDiagram from '@assets/diagrams/update-strategies.svg';

浏览器抓取到的 service worker 脚本与已安装版本哪怕只差一个字节，就会在旧 worker 旁边安装新 worker，并把它保持在 *waiting* 状态，直到旧 worker 控制的所有页面都已关闭；`self.skipWaiting()` 解除这个等待，`clients.claim()` 则让已激活的 worker 接管它尚未控制的页面。这一流程的四个部分（`skipWaiting()`、`clients.claim()`、`registration.update()` 与 `updateViaCache` 选项）自 Chrome 41、Firefox 44、Safari 11.1、Edge 17 起可用（BCD `api.ServiceWorkerGlobalScope.skipWaiting`），只有 `updateViaCache` 较晚，自 Chrome 68、Firefox 57、Safari 11.1 起可用。

<Figure src={updateStrategiesDiagram} alt="Service worker 更新策略流程图：更新检查比较脚本字节，把新 worker 安装到 waiting 状态，随后三选一。A：等所有标签页关闭后再 activate；B：调用 self.skipWaiting() 与 clients.claim()，并在 controllerchange 时重新加载；C：监听 updatefound，显示“有可用更新”控件，发送 SKIP_WAITING 消息并在 controllerchange 时重新加载。" caption="处理 waiting 状态 service worker 的三种方式：等待（默认）、立即接管，或询问用户。" />

## 语法

```js
// service worker 中
self.skipWaiting();                    // Promise<undefined>
self.clients.claim();                  // Promise<undefined>

// 页面中
const registration = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
await registration.update();           // 以该注册兑现
registration.waiting;                  // 没有等待中的 worker 时为 null
registration.addEventListener('updatefound', handler);
navigator.serviceWorker.addEventListener('controllerchange', handler);
```

浏览器会在 scope 内发生导航、`push` 或 `sync` 事件触发，以及功能性事件距上次检查超过 24 小时到达时自动运行更新检查；`registration.update()` 按需触发同一检查。`install` 处理器被拒绝的 worker 不会进入 waiting，而是直接变为 *redundant*。

## 参数

四个成员中两个不接受参数，另外两个分别接受一个注册选项和零个参数。

| 成员 | 位置 | 参数 | 作用 |
|---|---|---|---|
| `self.skipWaiting()` | worker | 无 | 标记该 worker 在当前 active worker 正在运行的事件结束后立即激活，而不是等它的所有客户端关闭。通常在 `install` 中调用。 |
| `self.clients.claim()` | worker | 无 | 让这个 active worker 成为 scope 内所有无控制器或由更旧 worker 控制的客户端的控制器；在这些页面中触发 `controllerchange`。只有 worker 处于 active 状态时才有效，因此应放在 `activate` 中。 |
| `registration.update()` | 页面 | 无 | 重新抓取脚本（遵守 `updateViaCache`）并逐字节比较；无论是否发现新 worker 都以该注册兑现。 |
| `updateViaCache` | `register()` 选项 | `'imports'`（默认）、`'all'` 或 `'none'` | 更新检查期间允许 HTTP 缓存提供哪些脚本：只有 `importScripts()` 的依赖、全部，或都不允许。与此选项无关，浏览器一律忽略超过 24 小时的缓存副本。 |

## 异常

`skipWaiting()` 从不拒绝：规范规定它在 worker 激活后、或已处于 active 状态时以 `undefined` 兑现。其他成员可能拒绝。

- `clients.claim()` 在该 worker 不是注册的 active worker 时以 `InvalidStateError` 拒绝，例如在脚本顶层调用而 worker 仍在安装中。
- `registration.update()` 在抓取的脚本加载失败或 MIME 类型不是 JavaScript 类型时以 `TypeError` 拒绝，在注册已被注销或其最新 worker 为 `null` 时以 `InvalidStateError` 拒绝。
- `register()` 在 `updateViaCache` 不是三个允许值之一时以 `TypeError` 拒绝。

调用 `skipWaiting()` 却不重新加载受控页面，会让这些页面继续运行上一次部署的 HTML，而 worker 的预缓存里已是新部署的内容；页面稍后懒加载某个已不存在的带哈希分块时会得到 `404`。示例中的 `controllerchange` 重新加载正是为了关上这个窗口。

:::observed
在 Chrome DevTools（英文界面）中，Application › Service workers 会把已安装但尚未激活的 worker 列为状态文字 `#<n> waiting to activate`，旁边带一个 `skipWaiting` 链接；点击该链接即对该 worker 调用 `skipWaiting()`，状态随即变为 `#<n> activated and is running`。`waiting to activate`、`activated and is running`、`trying to install`、`is redundant` 这几个字符串就是 DevTools 前端 `ServiceWorkersView.ts` 中的 `UIStrings`（chromium.googlesource.com）。同一窗格里的 **Update on reload** 复选框会在每次导航时强制重新安装，这也是"在 DevTools 里能用"的改动仍可能在用户那里停在 waiting 的原因。
:::

## 示例

三个示例分别对应图中的三条路径：立即接管、询问用户后更新、按需检查。

### 用 skipWaiting()、claim() 与带保护的重新加载立即接管

worker 在 `install` 中跳过等待、在 `activate` 中接管客户端；页面在 `controllerchange` 时重新加载一次。`refreshing` 标志防止 `claim()` 第二次触发该事件时陷入重载循环。

```js
// sw.js
self.addEventListener('install', (event) => {
  event.waitUntil(caches.open('app-v2').then((cache) => cache.addAll(['/', '/app.js'])));
  self.skipWaiting();
});
self.addEventListener('activate', (event) => {
  event.waitUntil(self.clients.claim());
});

// page.js
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (refreshing) return;
  refreshing = true;
  location.reload();
});
```

这条路径的代价是每个打开的标签页都会在会话中途重新加载，包括用户正在填写表单的那个；它适合页面生命周期短的应用。

### 激活前先询问用户

页面监听 `updatefound`，等新 worker 到达 `installed` 后显示控件；用户点击后才向 worker 发消息，由 worker 调用 `skipWaiting()`。

```js
// page.js
const registration = await navigator.serviceWorker.register('/sw.js');
registration.addEventListener('updatefound', () => {
  const worker = registration.installing;
  worker.addEventListener('statechange', () => {
    if (worker.state === 'installed' && navigator.serviceWorker.controller) {
      showUpdateButton(() => worker.postMessage({ type: 'SKIP_WAITING' }));
    }
  });
});
navigator.serviceWorker.addEventListener('controllerchange', () => location.reload());

// sw.js
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
```

`navigator.serviceWorker.controller` 的检查跳过了首次安装，因为那时没有旧版本可替换。如果页面同时在第二个标签页中打开，那个标签页也会在 `controllerchange` 时重新加载。

### 按需检查更新并检测 API

一个从不导航的长寿命单页应用，否则只会每 24 小时检查一次更新。下面的代码每小时检查一次，并在 `navigator.serviceWorker` 缺失时降级为什么都不做；在所有实现该 API 的浏览器中，非安全源（非 localhost 的纯 HTTP）都是这种情况。

```js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.ready.then((registration) => {
    setInterval(() => registration.update().catch(() => {}), 60 * 60 * 1000);
  });
} else {
  // 没有 service worker：页面总是运行已部署的版本，无需检查更新。
}
```

离线时 `update()` 被拒绝是预期行为；在这里吞掉它是正确的，因为下一个周期会重试。

## 另请参阅

- [Service Workers specification: Update algorithm](https://www.w3.org/TR/service-workers/#update-algorithm)（w3.org）
- [Service Workers specification: skipWaiting() method](https://w3c.github.io/ServiceWorker/#dom-serviceworkerglobalscope-skipwaiting)（w3c.github.io）
- [The service worker lifecycle](https://web.dev/articles/service-worker-lifecycle)（web.dev）
- [Service worker 生命周期](/zh/reference/service-worker/lifecycle/)
- [Service worker 注册与 scope](/zh/reference/service-worker/registration-scope/)
- [Service worker 调试](/zh/reference/service-worker/debugging/)
- [Workbox](/zh/reference/service-worker/workbox/)