跳到主要内容

Service Worker

Service Worker 是浏览器按事件启动的后台脚本,可以参与受控页面的请求处理,并配合 Cache Storage 提供离线资源和网络失败后的替代响应。它不能直接操作 DOM,也不是一直运行的后台线程。

理解它的关键是生命周期:脚本注册成功,不代表新版本已经激活;已经激活,也不代表当前页面正在由它控制。缓存和更新方案只有把这些状态区分清楚,才能解释为什么首次访问未离线可用、旧页面仍在使用旧资源,或者刷新后才出现新版本。

1. 面试核心​

先记住这几条,再读正文
  • Service Worker 通过作用域和控制关系参与页面请求,不是所有页面请求的全局代理。它适合离线资源、请求策略等需求,不适合保证持续运行的长计算。卡 · 事件驱动的执行环境
  • 注册、安装、等待、激活和控制页面是不同状态。初次安装后,现有页面通常还未被控制;ready 得到注册也不等于当前 controller 非空。卡 · ready 与 controller 的区别
  • install 的 waitUntil 可以让必要资源准备成为安装条件。安装失败的新版本不会替换仍在服务的旧版本,但应用产生的所有副作用并不会自动回滚。卡 · 安装要等待必要资源
  • respondWith 要在 fetch 事件分发时同步调用,可以传入异步产生 Response 的 Promise。它决定响应;waitUntil 则延长事件相关异步工作的生命周期。卡 · 请求处理的边界
  • Cache Storage 与 HTTP 缓存是两套机制。显式写入的 Cache 条目不会自动按 Cache-Control 过期,需要自己规定缓存对象、版本和清理规则。卡 · Cache Storage 与 HTTP 缓存
  • skipWaiting 推进新版本激活,clients.claim 争取控制符合条件的页面,二者不是同一动作。提前切换要处理旧页面与新资源混用、未保存内容和其他标签页。卡 · 等待新版本与提前激活
  • 离线策略应按资源用途选择。静态版本资源可以优先缓存,导航可以网络失败后返回离线页,用户私有数据不能被一个宽泛的缓存规则顺手保存。卡 · 按资源选择策略
  • PWA 不是 Service Worker 的别名。Manifest 描述应用元信息,安装体验与平台有关,Service Worker 主要提供请求与离线等能力。卡 · PWA 与排查思路

2. 运行模型与作用域​

2.1 事件驱动的执行环境​

Service Worker 没有页面 DOM。浏览器可以在请求、消息或其他受支持事件到来时启动它,也可以在空闲后停止它。全局变量不会因为曾经赋过值,就可靠地保留到下一次事件。

需要持久化的状态应放到合适的存储中。Cache Storage 保存请求和响应;结构化业务记录可使用 IndexedDB。两者的用途见 浏览器存储。定时器和一个持续 pending 的 Promise 都不是无限续命机制。

普通 Web Worker 主要为创建方处理独立计算;Service Worker 有注册、控制范围和更新生命周期。不要因为二者都不能直接操作 DOM,就用 Service Worker 承诺持续执行大型后台计算。计算任务的边界见 Web Worker。

2.2 安全上下文与作用域​

Service Worker 需要安全上下文,生产站点通常通过 HTTPS 提供;本地开发的 localhost、回环地址等受信任环境有相应例外。脚本和注册页面需要满足同源要求,并使用正确的 JavaScript MIME 类型。

作用域(scope)限定一个注册可以控制的页面 URL 范围。默认范围通常是脚本所在目录。例如 /demo/sw.js 默认覆盖 /demo/ 下的页面,不是整个站点。显式扩大到父目录还需要服务器的 Service-Worker-Allowed 等条件,不能只改前端参数就任意接管。

作用域判断的是客户端页面,不是简单地给所有资源 URL 加一个前缀过滤器。一个受控页面发出的子资源请求也可以经过它的 Service Worker;实际代码应继续判断请求方法、来源和用途,决定哪些请求由自己处理。

下面是在页面模块中注册 /demo/ 的示例:

if (!('serviceWorker' in navigator)) throw new Error('当前环境不支持 Service Worker');
const registration = await navigator.serviceWorker.register('/demo/sw.js', {
scope: '/demo/',
updateViaCache: 'none',
});
console.log(registration.scope);
console.log(Boolean(navigator.serviceWorker.controller));

updateViaCache: 'none' 用于控制更新检查时是否使用 HTTP 缓存读取 Service Worker 脚本及其导入脚本,不会关闭页面资源的 HTTP 缓存,也不会清除 Cache Storage。

同一 scope 再次调用注册通常会复用并检查相应注册,不应该每次页面打开都换一个随机 scope。稳定的脚本 URL 和清晰的资源版本更便于理解更新。

3. 安装、激活与控制关系​

3.1 生命周期的几个阶段​

阶段主要动作容易误判的地方
注册与下载建立或更新注册,获取并执行脚本注册 Promise 兑现不等于资源准备完成
installing执行 install 事件及其必要异步工作安装失败的新版本不能成为有效替代版本
installed / waiting新版本已准备好,但可能等旧版本退出新脚本下载成功不代表页面已经使用它
activating执行 activate,处理版本相关准备不应依赖旧页面一定已经刷新
activated可以处理符合条件的事件当前页面仍可能没有 controller
redundant被替代或失败等原因不再有效不能继续把它当作当前服务版本

旧版本仍控制页面时,新版本默认可以进入等待状态,以减少页面执行到一半突然切换脚本和资源版本。关闭使用旧版本的页面通常有助于新版本接管;只刷新一个标签页,不应被当成所有情况下都能结束等待的办法。

3.2 ready 与 controller 的区别​

navigator.serviceWorker.ready 等待当前页面关联的注册具有非空的 active Worker。它返回时 Worker 仍可能处于 activating,不应把它当作所有激活工作已经完成的保证。它也不表示当前页面已经受控,更不是会因一次安装失败就自动拒绝的通用错误 Promise。

判断当前页面由谁控制,应查看 navigator.serviceWorker.controller;控制关系变化时可以监听 controllerchange。初次访问时,页面本身可能已经加载完才安装 Service Worker。没有主动接管时,后续导航到作用域内的页面才会被控制。

clients.claim() 可以让活动 Worker 争取控制其作用域内符合条件的页面,因此有机会让首次加载的页面也受控。是否这么做,应取决于页面和资源是否兼容,不能仅为使第一次打印的 controller 非空而添加它。

4. 公共资源与离线页示例​

这个示例只负责 /demo/ 的公共静态演示页,不缓存登录后的 HTML 或 API 数据。准备三个确实存在的公开资源:/demo/app.v1.js、/demo/style.v1.css、/demo/offline.html。离线页本身应能在网络不可用时显示必要说明。

/demo/sw.js:

const CACHE_PREFIX = 'campus-sw-demo-';
const CACHE_NAME = CACHE_PREFIX + 'v1';
const PRECACHE = [
'/demo/app.v1.js',
'/demo/style.v1.css',
'/demo/offline.html',
];

self.addEventListener('install', event => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE_NAME);
await cache.addAll(PRECACHE.map(url => new Request(url, { cache: 'reload' })));
})());
});

self.addEventListener('activate', event => {
event.waitUntil((async () => {
const names = await caches.keys();
await Promise.all(names
.filter(name => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
.map(name => caches.delete(name)));
})());
});

self.addEventListener('fetch', event => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== 'GET' || url.origin !== self.location.origin) return;

if (request.mode === 'navigate') {
event.respondWith((async () => {
try {
return await fetch(request);
} catch {
const cache = await caches.open(CACHE_NAME);
return await cache.match('/demo/offline.html') || new Response('当前离线', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
})());
return;
}

if (PRECACHE.includes(url.pathname) && url.search === '') {
event.respondWith((async () => {
const cache = await caches.open(CACHE_NAME);
return await cache.match(request) || fetch(request);
})());
}
});

4.1 安装要等待必要资源​

install 把 cache.addAll 的完成交给 waitUntil。必要资源请求失败或返回不符合 addAll 要求的响应时,新 Worker 的安装失败,而不是带着缺失的离线页继续成为新版本。

这不代表所有副作用自动回滚:例如提前 caches.open 创建的缓存名称可能仍存在。版本命名和后续清理仍由应用负责。发布新版本时,应使用新的缓存版本,并保证必要资源在安装时已经可用。

示例通过明确的资源列表排除普通 API。服务器也必须保证这些 URL 真的是公共静态内容;如果错误地把登录页以 HTTP 200 返回,浏览器不会自动知道它不该作为某个脚本或离线页保存。

4.2 请求处理的边界​

respondWith 在 fetch 监听函数执行期间直接调用,但传入的 Promise 可以异步生成响应。不能先任意 await 一段工作,等事件分发结束后再决定调用它。

不调用 respondWith 的请求继续走浏览器的默认处理。示例不接管跨源请求和非 GET 请求;静态资源只有精确匹配预缓存路径且没有查询参数时才优先取缓存。

导航采用“先网络,网络失败再离线页”。HTTP 404、500 是已经收到的响应,fetch() 不会只因这些状态进入 catch,所以会原样返回。若产品希望部分服务端错误也回退,需要明确写出状态规则,不能假定 catch 自动覆盖它们。

这里没有给网络等待设置短超时。弱网下“仍在等待”与“已经失败”不同;需要限制等待时,应另行设计取消、超时与回退边界。

4.3 响应消费与缓存写入​

Cache 存储的是 Request 与 Response 的对应关系。cache.add、addAll 会主动获取资源;cache.put 可以保存已有响应。一个 Response 的正文通常只能消费一次,若既要返回给页面又要写入缓存,需要在消费前建立克隆分支。

对于额外的后台缓存更新工作,可以通过 event.waitUntil(...) 告诉浏览器事件还关联着异步任务;respondWith 则负责本次请求的响应。两者职责不同,不能把延长工作寿命理解为已经向页面返回内容。

示例只在安装时预缓存。若某条缓存之后被删除,静态请求会尝试网络,但不会自动补写回来。这是明确的简单策略,不是具备所有缓存修复与淘汰功能的框架。

5. 缓存策略与失效​

5.1 Cache Storage 与 HTTP 缓存​

Cache Storage 是脚本显式管理的响应存储,HTTP 缓存则按 HTTP 规则选择和验证响应。一个请求可能涉及两者,但它们不是同一个开关。

Cache API 不会仅根据响应的 Cache-Control 自动删除或过期条目。即使浏览器的 HTTP 缓存不应保存某个响应,也不能假定应用显式 cache.put 后它一定不在 Cache Storage 中。敏感响应应从应用缓存策略中明确排除。

缓存还会受到存储配额、浏览器清理和用户清理影响。离线能力要有缺失回退,重要用户数据应有持久化与恢复方案。HTTP 缓存规则见 HTTP 缓存。

5.2 按资源选择策略​

策略执行方式适用条件与代价
缓存优先命中直接返回,未命中再请求网络适合 URL 带内容版本的静态资源,需要失效与容量管理
网络优先先请求网络,失败后使用已知替代结果适合更看重新鲜度的内容,需明确等待期限和错误范围
先旧后新返回已有缓存,同时后台请求更新更快展示但可能短暂过时,界面与业务必须接受这种语义
仅网络按正常网络语义访问,不保存应用缓存适合不该被离线复用的私有或强时效数据

“先旧后新”通常称为 stale-while-revalidate 策略。Service Worker 中自己实现的同名策略,与 HTTP 响应头中的相关指令仍属于不同层次,不能把二者的有效期与处理过程混为一谈。

私有数据不仅要判断 URL,还要考虑用户身份、权限和退出登录后的清理。缓存键遗漏身份,会造成不同用户看到同一份旧数据。一个简单的 fetch 监听器不应默认缓存所有成功响应。

6. 更新与版本一致性​

6.1 等待新版本与提前激活​

浏览器发现 Worker 脚本或相关更新内容变化后,可以安装新版本。安装成功后,如果旧版本仍在控制页面,新版本通常等待。registration.update() 可以请求执行更新检查,不等于强制新版本立即接管。

skipWaiting() 让安装完成的新 Worker 不再继续等待旧版本退出,推进激活;clients.claim() 则由活动 Worker 控制符合条件的客户端。前者不是清缓存,后者不是下载新版本,也不意味着之前所有加载过的资源会重新请求。

提前激活会影响仍打开的页面,不只是点击更新按钮的那个标签页。旧 JavaScript 可能随后请求新资源;若立即删除所有旧资源,也可能让旧页面运行失败。生产应用需要版本兼容、保留必要旧资源或协调页面刷新,不能把两句 API 调用当作完整的无感更新方案。

6.2 用户确认后切换的最小流程​

对于没有未保存编辑状态的演示页,可以发现等待版本后显示按钮,再让用户明确触发激活。页面增加:

<button id="apply-update" hidden>更新并重新加载</button>

以下代码接在前面的注册代码后,仍在同一个页面模块里使用 registration:

const updateButton = document.querySelector('#apply-update');
let acceptedUpdate = false;
let reloading = false;

function refreshUpdateButton() {
updateButton.hidden = !registration.waiting;
}
function watchInstalling() {
const installing = registration.installing;
installing?.addEventListener('statechange', refreshUpdateButton);
}
registration.addEventListener('updatefound', watchInstalling);
watchInstalling();
refreshUpdateButton();

navigator.serviceWorker.addEventListener('controllerchange', () => {
if (acceptedUpdate && !reloading) {
reloading = true;
location.reload();
}
});
updateButton.addEventListener('click', () => {
const waiting = registration.waiting;
if (!waiting) return;
acceptedUpdate = true;
waiting.postMessage({ type: 'ACTIVATE_UPDATE' });
});

Worker 文件末尾增加消息处理:

self.addEventListener('message', event => {
if (event.data?.type === 'ACTIVATE_UPDATE') {
event.waitUntil(self.skipWaiting());
}
});

这个演示只在已知更新请求之后重新加载当前页面,避免每次控制关系变化都无条件刷新。它没有调用 clients.claim,也没有处理复杂多标签页编辑协调;这些是产品需要额外确定的更新协议。

有表单草稿、长任务或多页面协作时,先保存状态、协调其他客户端,再决定何时切换。还应保证用户点击的等待版本尚未被其他更新替代,并对激活失败或等待过久提供反馈。

6.3 注销与清理不是同一步​

unregister() 注销注册,不会自动删除 Cache Storage 中的所有数据,也不等于当前已受控页面立即失去控制。故障恢复或移除离线能力时,要分别管理注册、已打开客户端和应用缓存。

下面只清理这个演示自己的注册与缓存,不影响同源的其他应用:

async function removeDemoOfflineSupport() {
const scope = new URL('/demo/', location.origin).href;
const registrations = await navigator.serviceWorker.getRegistrations();
for (const item of registrations) {
if (item.scope === scope) await item.unregister();
}
const names = await caches.keys();
await Promise.all(names
.filter(name => name.startsWith('campus-sw-demo-'))
.map(name => caches.delete(name)));
}

清理后还要让旧客户端结束或重新导航,并确保页面代码不再立刻注册回来。不要为了排查一个应用就删除同源所有注册和缓存。

7. PWA 与排查思路​

PWA(Progressive Web App)是一类借助 Web 平台能力提供更接近应用体验的网站。Manifest 描述名称、图标、启动 URL 和显示方式等元信息;Service Worker 可以支撑离线和请求策略。安装条件与入口会随浏览器和平台变化,不能用“有 Service Worker 就一定能安装”作为定义。

Service Worker 也不天然让整个应用离线可用。静态资源、页面导航、数据来源、登录状态和写操作都需要自己的离线方案。安装了 Worker 但没有缓存目标资源,离线后仍然可能失败。

调试时先沿着状态和请求路径检查:

  1. 注册是否成功,脚本 URL、MIME、作用域和安全上下文是否符合要求。
  2. 新版本处于 installing、waiting 还是 active,install 的异步准备是否失败。
  3. 当前页面的 controller 是谁,是否只是有注册但没有受控。
  4. fetch 处理器是否匹配当前方法、URL 和请求类型,实际返回网络还是 Cache Storage。
  5. 缓存是否缺失或过期,更新是否混用了页面和资源版本。

开发工具中的绕过网络处理、强制更新或注销选项会改变测试条件。验证离线时,应使用正常注册和控制关系再断开网络,不能只看开发工具里有一条 active 记录就宣称离线功能完成。

8 · 面试题卡片​

本页题目与参考回答

8.1 项目为什么使用 Service Worker,它适合解决什么问题?​

考察重点:请求处理、离线能力、受控页面与事件生命周期。

相关内容:事件驱动的执行环境、公共资源与离线页示例。

参考回答

它适合为受控页面提供明确的请求策略,例如缓存公共静态资源、网络失败时返回离线页。 应说明具体缓存了什么、失败如何回退以及新版本怎样生效,而不只是说“项目用了 PWA”。

它不能直接操作 DOM,也不保证一直运行。数据持久化和长期计算要分别选择合适机制,不能依赖全局变量或无限定时器。

8.2 注册成功和 ready 已完成,为什么当前页面还没有 controller?​

考察重点:注册、活动 Worker 与页面控制关系。

相关内容:ready 与 controller 的区别。

参考回答

因为有活动注册不等于当前页面已经由它控制。 初次访问可以先加载页面,随后安装激活 Worker;没有主动接管时,后续进入作用域的导航才会受控。

当前控制关系看 navigator.serviceWorker.controller,变化看 controllerchange。clients.claim 可以争取控制符合条件的页面,但是否采用应取决于兼容与更新设计。

8.3 新版本预缓存失败,会把旧版本一起替换掉吗?​

考察重点:install、waitUntil、旧版本服务与副作用清理。

相关内容:安装要等待必要资源。

参考回答

不会因新安装失败就把仍在服务的旧版本换成失败版本。 将必要资源准备交给 install 的 waitUntil,可以让失败阻止新 Worker 安装成功。

但缓存名称创建等副作用不一定自动回滚。仍要采用清晰版本命名与清理规则,并保证安装依赖资源先发布到位。

8.4 respondWith 和 waitUntil 分别做什么?​

考察重点:响应选择与事件异步工作寿命。

相关内容:请求处理的边界、响应消费与缓存写入。

参考回答

respondWith 决定本次 fetch 事件的响应,waitUntil 把异步工作关联到可延长的事件生命周期。 respondWith 要在事件分发期间同步调用,但可以传入一个稍后产生 Response 的 Promise。

安装资源准备和激活清理适合使用 waitUntil;返回页面的响应和后台缓存更新可能分别需要这两种机制。它们都不是让 Worker 永久运行的接口。

8.5 Cache Storage 会按照 Cache-Control 自动过期吗?​

考察重点:应用缓存与 HTTP 缓存、失效与敏感数据边界。

相关内容:Cache Storage 与 HTTP 缓存。

参考回答

不会仅依靠 Cache-Control 自动管理 Cache API 显式保存的条目,两者是不同机制。 应用需要决定缓存哪些响应、如何命名版本、何时失效和清理。

私有或敏感响应应在应用规则中明确排除;存储也可能受配额或清理影响,离线请求仍需处理缓存缺失。

8.6 skipWaiting 和 clients.claim 有什么区别,为什么不总是一起调用?​

考察重点:新版本激活、页面接管与版本一致性。

相关内容:等待新版本与提前激活。

参考回答

skipWaiting 推进已安装新 Worker 的激活,clients.claim 由活动 Worker 控制符合条件的客户端。 它们不负责重新下载页面,也不自动协调页面状态。

提前切换可能让旧页面代码遇到新资源,删除旧缓存还可能影响其他标签页。需要先确定兼容、状态保存和刷新协议,不能无条件把两句调用视为完整更新方案。

8.7 静态资源、页面导航和私有接口应该使用同一种缓存策略吗?​

考察重点:新鲜度、可恢复性、身份隔离和错误范围。

相关内容:按资源选择策略。

参考回答

不应该,策略要由资源是否可复用、能否接受过时和是否含私有数据决定。 带版本的静态资源可以缓存优先,导航可以在网络失败时返回离线页,私有接口则应采用明确的身份和失效规则,或不进入应用缓存。

HTTP 500 不会自动成为 Fetch 的 catch,网络优先还要定义等待和状态回退规则。离线能力也必须处理缓存被清理的情况。

8.8 注销 Service Worker 后,缓存和当前页面会立刻恢复原状吗?​

考察重点:注册、受控客户端与持久化缓存的独立生命周期。

相关内容:注销与清理不是同一步。

参考回答

不会,注销注册、结束已有控制关系和删除应用缓存是不同操作。 unregister 不自动清掉 Cache Storage,旧页面也不一定立即失去控制。

应只删除自己的注册和缓存命名空间,让旧客户端结束或重新导航,并避免页面代码马上注册回来。

8.9 PWA 和 Service Worker 是什么关系?​

考察重点:应用元信息、安装体验、请求策略与离线范围。

相关内容:PWA 与排查思路。

参考回答

PWA 是应用体验的整体方向,Service Worker 是其中可用于请求处理和离线能力的技术。 Manifest 描述名称、图标、启动地址等信息;安装体验取决于浏览器和平台,不能简单等同于注册了 Worker。

是否离线可用还取决于资源、导航、数据和写操作方案。只安装一个不处理目标资源的 Worker,不会让整个应用自动离线运行。

9. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明