Service Worker 更新后让页面安全切换版本的方案
我在前端发布中最不愿意看到的提示,不是“有新版本”,而是用户点完更新后页面出现一半旧、一半新的状态:HTML 已换了,JavaScript 还在旧缓存里,或者多个标签页同时刷新。Service Worker 的更新流程本身不会替你决定“什么时候让页面切换”,需要把安装、等待、激活、控制权变化和刷新连成一条明确的生命周期。
比较稳妥的方案是:新 worker 先使用新缓存名完成安装,旧 worker 继续服务现有页面;页面检测到 waiting worker 后提示用户,确认后再发送消息调用 skipWaiting();新 worker 激活并通过 clients.claim() 接管页面,页面只在 controllerchange 中刷新一次。这样切换点是可见且可控的。
官方资料:https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration
- 缓存名要随应用资源版本变化,新旧缓存并存到新 worker 激活。
- 不要在发现新 worker 时立即刷新,先让用户确认或由业务策略决定切换时机。
skipWaiting()负责跳过 waiting,clients.claim()负责接管作用域内页面,页面用一次性标记防止重复刷新。
先把更新链路拆成四个状态
Service Worker 更新时,浏览器会把新脚本作为新的 worker 安装。已有页面通常仍由旧的 active worker 控制,新 worker 安装完成后会停在 waiting,直到旧页面不再使用旧 worker,或代码主动要求它提前激活。ServiceWorkerRegistration.updatefound 可以用来观察“有新的 installing worker”这一事实,而不是把它误当成“页面已经切换”。
| 状态 | 页面能做什么 | 切换注意 |
|---|---|---|
| installing | 等待新 worker 完成安装和预缓存 | 旧 worker 仍负责当前页面 |
| waiting | 展示“发现新版本”并等待确认 | 不要立即覆盖旧页面的资源 |
| active | 新 worker 可以处理后续事件 | 是否控制已有页面还要看 claim |
| controllerchange | 页面知道控制者发生改变 | 刷新动作需要幂等保护 |

先预热新缓存,不要直接覆盖旧缓存
缓存名应明确包含应用资源版本,例如 app-static-v2。新 worker 在 install 阶段打开新缓存并写入完整的静态资源列表,旧的 app-static-v1 暂时保留。这样正在运行的旧页面仍能从它自己的资源集合读取,不会因为新版本还没准备好而拿到半套文件。
// sw.js:新版本使用独立缓存,安装完成前不碰旧缓存。
const CACHE_NAME = "app-static-v2";
const APP_SHELL = ["/", "/index.html", "/assets/app-v2.js", "/assets/app-v2.css"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// addAll 失败时让安装失败,避免激活一个不完整的资源集合。
return cache.addAll(APP_SHELL);
}),
);
});
self.addEventListener("message", (event) => {
if (event.data?.type === "ACTIVATE_NEW_VERSION") {
// 只在页面确认后跳过 waiting,避免后台更新突然打断用户操作。
self.skipWaiting();
}
});
这里没有在 install 里无条件调用 skipWaiting()。如果应用正在编辑表单、播放媒体或执行不可中断的任务,立即切换可能让页面与新资源的状态不一致。把消息作为切换闸门,能够让产品决定“现在更新”还是“下次打开更新”。
页面检测到 waiting 后再让用户确认
页面侧需要同时处理两种情况:本次注册过程中出现新的 installing worker,以及页面打开时 registration 已经有 waiting worker。两者都交给同一个绑定函数,避免只监听 updatefound 而漏掉已安装完成的更新。
// app.js:把 waiting worker 交给页面上的更新提示组件。
function showUpdatePrompt(registration) {
const waitingWorker = registration.waiting;
if (!waitingWorker) {
return;
}
// 这里的 UI 可以是 Toast、顶部横幅或设置页按钮。
renderUpdateBanner(() => {
// 用户确认后才发送切换消息,避免后台更新直接打断当前会话。
waitingWorker.postMessage({ type: "ACTIVATE_NEW_VERSION" });
});
}
async function registerAppWorker() {
if (!("serviceWorker" in navigator)) {
return;
}
// 更新检查不读取主 worker 脚本的 HTTP 缓存,减少版本发现延迟。
const registration = await navigator.serviceWorker.register("/sw.js", {
updateViaCache: "none",
});
if (registration.waiting) {
// 页面加载时已经存在 waiting worker,直接显示更新提示。
showUpdatePrompt(registration);
}
registration.addEventListener("updatefound", () => {
const installingWorker = registration.installing;
if (!installingWorker) {
return;
}
installingWorker.addEventListener("statechange", () => {
// installed 且页面已有 controller,说明这是一次可提示的更新。
if (installingWorker.state === "installed" && navigator.serviceWorker.controller) {
showUpdatePrompt(registration);
}
});
});
}
registerAppWorker().catch((error) => {
// 注册失败不应阻塞页面本身,记录后继续走网络加载。
console.error("Service Worker 注册失败", error);
});
updateViaCache: "none" 只影响更新 Service Worker 脚本及其导入脚本时的缓存使用方式,不等于把应用的所有资源都禁用缓存。实际项目仍要根据部署链路设置 HTTP 缓存头,并让构建产物拥有可区分的文件名。
用 controllerchange 完成一次性刷新
用户确认后,新 worker 会从 waiting 进入 activating,再成为 active。若在 activate 阶段调用 clients.claim(),作用域内的页面会收到 controllerchange。页面可以把这个事件作为“资源控制权已经交接”的信号,但必须设置刷新锁,否则多个事件、多个标签页或重复注册都可能造成刷新循环。
// app.js:只有原本已经被 worker 控制的页面才因更新自动刷新。
const hadControllerBeforeRegistration = Boolean(navigator.serviceWorker.controller);
let isReloadingForUpdate = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (!hadControllerBeforeRegistration || isReloadingForUpdate) {
// 首次安装或已经触发过刷新时,不再重复导航。
return;
}
isReloadingForUpdate = true;
// 新 controller 已接管请求,刷新后页面会读取同一版本的资源集合。
window.location.reload();
});
首次安装时页面原本没有 controller,是否刷新应由产品体验决定;上面的 hadControllerBeforeRegistration 用来把“首次接管”和“已有版本更新”区分开。如果应用需要首次安装也刷新,可以把这个条件改成持久化的版本确认逻辑,而不是简单删除保护。
activate 阶段再清理旧缓存
旧缓存的删除要放在新 worker 激活阶段,并通过 event.waitUntil() 等待清理完成。关键点是只保留当前版本,其余缓存统一删除;不要在安装阶段删除旧缓存,因为此时旧页面可能仍在使用它。
// sw.js:新 worker 激活后再清理旧版本,并接管当前作用域页面。
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
// 只删除旧版本缓存,不触碰当前正在使用的 app-static-v2。
return caches.delete(name);
}),
);
// 让作用域内的现有页面获得新的 controller。
await clients.claim();
})(),
);
});

如果旧页面仍在执行长任务,强制切换仍可能造成业务中断,所以“确认后 skipWaiting”只是技术能力,不是所有产品都必须采用的默认策略。对编辑器、支付流程或长表单,可以只提示用户并把切换推迟到下一次自然导航。
这几类现象通常对应什么边界
| 现象 | 优先检查 | 处理方向 |
|---|---|---|
| 一直显示 waiting | 页面是否发送了激活消息、worker 是否完成 install | 检查消息类型和 addAll 失败原因 |
| 更新后仍拿到旧 JS | 缓存名、构建文件名和 fetch 策略 | 让资源版本可区分,激活后再删旧缓存 |
| 页面连续刷新 | controllerchange 是否有锁 | 增加一次性标记,并区分首次安装 |
| 新 worker 接管但页面不更新 | 是否监听了 controllerchange,是否调用了 claim | 将接管和刷新动作连接起来 |
上线前把切换策略固定成清单
- 为每次构建生成新的静态资源文件名或缓存版本名,避免新旧文件在同一缓存键上竞争。
- 安装阶段只创建和填充新缓存,资源缺失时让安装失败,不激活半成品。
- 页面同时处理已有
registration.waiting和updatefound产生的 waiting worker。 - 把
skipWaiting放在用户确认或明确的业务切换点之后。 - 在
activate中用event.waitUntil完成旧缓存清理,再用clients.claim交接控制权。 - 用
controllerchange触发一次性刷新,并为多标签页、表单和长任务保留延迟切换方案。
这套流程的核心不是“让 Service Worker 立即更新”,而是把版本准备、切换许可和页面刷新拆开。只要旧页面仍能获得完整的旧资源,新页面只在新 worker 已经接管后读取新资源,发布时最棘手的新旧版本混用问题就有了明确的边界。
相关问题
为什么不直接在 install 中调用 skipWaiting?
可以这样做,但它会让新 worker 尽快进入激活流程,可能打断正在编辑、播放或提交中的页面。需要可控发布时,把调用放在页面确认消息之后更稳妥。
clients.claim 和 skipWaiting 是一回事吗?
不是。skipWaiting 处理 worker 从 waiting 到 active 的推进;clients.claim 让已经位于作用域中的页面获得新的 controller,二者解决的是不同阶段。
缓存版本名变了就一定不会混用吗?
不能单独保证。还要保证资源清单完整、旧缓存只在 activate 后清理,并让页面在 controllerchange 后再刷新;构建产物也应使用可区分的文件名。
CSS color-scheme 怎样让原生表单控件适配深色模式
- 上一篇
- CSS color-scheme 怎样让原生表单控件适配深色模式
- 下一篇
- IndexedDB versionchange 处理旧连接阻塞升级的方法
-
- 文章 · 前端 | 17分钟前 | 前端开发 · CSS 深色模式 prefers-color-scheme color-scheme 原生表单控件 前端主题
- CSS color-scheme 怎样让原生表单控件适配深色模式
- 204浏览 收藏
-
- 文章 · 前端 | 19分钟前 | imports ES modules import maps scopes 浏览器模块
- ES modules import maps 管理浏览器依赖映射的配置
- 140浏览 收藏
-
- 文章 · 前端 | 19分钟前 | 前端工程 · 环境变量 vite mode 前端构建 import.meta.env envPrefix
- Vite import.meta.env 控制客户端变量暴露范围的方法
- 181浏览 收藏
-
- 文章 · 前端 | 22分钟前 | 前端 · css · CSS 卡片布局 margin margin-trim
- CSS margin-trim 怎样统一卡片容器的首尾外边距
- 380浏览 收藏
-
- 文章 · 前端 | 26分钟前 | 前端开发 · CSS 锚点定位 前端布局 anchor-size() 浮动面板 尺寸约束
- CSS anchor-size() 按锚点尺寸控制浮动面板大小的写法
- 363浏览 收藏
-
- 文章 · 前端 | 27分钟前 | 前端 · Fetch AbortController 请求取消 AbortSignal.any
- Fetch AbortSignal.any 合并多个取消来源的处理
- 255浏览 收藏
-
- 文章 · 前端 | 1天前 |
- View Transitions API 怎样让多页导航保留页面连续感
- 315浏览 收藏
-
- 文章 · 前端 | 1天前 |
- Vite 环境变量为什么需要 VITE_ 前缀
- 111浏览 收藏
-
- 文章 · 前端 | 1天前 |
- WebSocket 关闭码怎样区分正常断开和异常
- 172浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 1015次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 1019次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 1144次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 1119次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 960次使用
-
- Adobe Express怎么安装到电脑?网页版、PWA入口与更新检查
- 2026-08-15 324浏览
-
- Go html/template 怎么高亮当前导航:传入 CurrentPath 的最小写法
- 2026-07-17 409浏览
-
- EXPIRE 怎样避免刷新 TTL
- 2026-10-25 217浏览
-
- 流式 AI 回复如何拼接完整答案:delta、done 与断线续传
- 2026-08-30 323浏览
-
- Chrome 2026 年 9 月改为两周一版:前端团队要调整哪些验证节奏
- 2026-08-24 365浏览

