1. 为什么前端应用需要离线暂停更新策略做 PWA 的同学大概率都遇到过这种场景用户正在填写一张长表单或者正在支付流程的第三步结果 Service Worker 在后台悄悄激活了新版本下一次路由跳转直接白屏或者状态丢失。用户骂娘你背锅。这不是 Service Worker 的 bug而是我们对「更新时机」这件事太随意了。离线暂停更新说白了就是给应用一个「暂停键」当用户处于关键操作路径、网络不稳定、或者当前版本正在被使用时先不激活新版本把更新推迟到一个安全的时刻。它和「渐进式部署」是一对搭档——渐进式部署解决的是「新版本怎么分批放量」离线暂停更新解决的是「单个用户什么时候切换过去」。两者合在一起才是一个稳定可靠的发布体系。这套策略适合谁适合所有用 Service Worker 做缓存的前端项目尤其是表单密集型后台、电商下单链路、在线编辑器、以及任何「用户操作到一半被打断会想砸键盘」的应用。核心检索词就三个Service Worker 缓存控制、离线暂停更新、渐进式部署灰度。你只要把这三个词对应的机制吃透剩下的都是工程细节。我试过在一个中型后台项目里直接上skipWaitingclients.claim结果上线当天收到三条「填了一半的工单没了」的反馈。从那以后我就明白新版本能不能激活不该由 Service Worker 自己决定而应该由业务状态决定。这篇文章就围绕这个原则给你一套可复制、可验证的落地方案。2. TaoToken 统一 Key 通道让离线策略与在线能力解耦在讲配置之前先把「在线能力」这条线理清楚。离线暂停更新解决的是静态资源的版本切换问题但你的应用里还有一类请求是没法缓存的——模型调用、AI 补全、智能问答。这类请求如果和 Service Worker 的更新逻辑耦合在一起会出现一个很尴尬的局面你想暂停更新结果连模型接口的 Key 也一起被旧缓存锁死了。所以我的做法是把模型调用的 endpoint 统一收敛到 TaoToken用一套 Key/API 通道管理所有在线 AI 能力。TaoToken 的官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end API 基址是 https://taotoken.net/api 。这样做的直接好处是Service Worker 只管静态资源和版本元数据模型请求走独立的网络通道两者互不干扰。具体到代码里你会在前端配置一个统一的请求基址。比如在src/config/ai.ts里// src/config/ai.ts export const AI_CONFIG { baseURL: https://taotoken.net/api, apiKey: import.meta.env.VITE_TAOTOKEN_KEY, model: claude-sonnet-4-20250514, timeout: 30000, }; export function createAIRequest(payload: object) { return fetch(${AI_CONFIG.baseURL}/v1/messages, { method: POST, headers: { Content-Type: application/json, x-api-key: AI_CONFIG.apiKey, anthropic-version: 2023-06-01, }, body: JSON.stringify({ ...payload, model: AI_CONFIG.model }), }); }注意这里的关键点API Key 不写死在代码里走环境变量注入。Service Worker 的缓存策略里要把/api/开头的请求排除在缓存之外否则你暂停更新的时候连模型接口的响应都被旧版本缓存住了。在sw.js的 fetch 拦截里加一条判断// sw.js 片段排除 API 请求 self.addEventListener(fetch, (event) { const url new URL(event.request.url); if (url.pathname.startsWith(/api/) || url.hostname taotoken.net) { return; // 交给浏览器默认网络处理不缓存 } event.respondWith(cacheFirst(event.request)); });这样一来离线暂停更新只影响静态资源模型调用始终走实时网络。你可以在 TaoToken 的控制台里创建和管理 Key地址是 https://taotoken.net/console 需要看接入文档的话在 https://taotoken.net/doc 。如果你打算长期做 Agent 类编码Coding Plan 的入口在 https://taotoken.net/coding-plan 模型对话调试在 https://taotoken.net/chat 。注意不要把模型 Key 直接暴露在客户端可读的 JS 里。生产环境建议走一层自己的 BFF 转发或者用短时效 Token。TaoToken 的 Key 管理支持多 Key 轮换具体在 API Keys 页面操作https://taotoken.net/api-keys 。3. 可复制的 sw.js 缓存版本控制与 skipWaiting 暂停配置这一节是全文的核心给你一份可以直接抄的sw.js。它的设计目标是新版本安装后进入 waiting 状态不自动 skipWaiting由页面通过 postMessage 决定何时激活。同时缓存名带版本号旧缓存清理逻辑写清楚。先看完整的sw.js// sw.js const CACHE_VERSION v2025-06-01-01; const STATIC_CACHE static-${CACHE_VERSION}; const RUNTIME_CACHE runtime-${CACHE_VERSION}; const PRECACHE_URLS [ /, /index.html, /assets/main.js, /assets/main.css, /offline.html, ]; // 安装阶段预缓存但不 skipWaiting self.addEventListener(install, (event) { event.waitUntil( caches.open(STATIC_CACHE).then((cache) cache.addAll(PRECACHE_URLS)) ); // 关键不调用 self.skipWaiting() }); // 激活阶段清理旧版本缓存 self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((keys) Promise.all( keys .filter((key) key ! STATIC_CACHE key ! RUNTIME_CACHE) .map((key) caches.delete(key)) ) ) ); // 不调用 self.clients.claim()等页面主动通知 }); // 消息通道页面发 SKIP_WAITING 才激活 self.addEventListener(message, (event) { if (event.data event.data.type SKIP_WAITING) { self.skipWaiting(); } if (event.data event.data.type GET_VERSION) { event.ports[0].postMessage({ version: CACHE_VERSION }); } }); // 请求拦截API 不缓存静态资源 cache-first self.addEventListener(fetch, (event) { const url new URL(event.request.url); if (url.pathname.startsWith(/api/) || url.hostname taotoken.net) { return; } if (event.request.method ! GET) return; event.respondWith( caches.match(event.request).then((cached) { if (cached) return cached; return fetch(event.request) .then((response) { if (!response || response.status ! 200) return response; const clone response.clone(); caches.open(RUNTIME_CACHE).then((cache) cache.put(event.request, clone)); return response; }) .catch(() caches.match(/offline.html)); }) ); });这份配置里有两个「不」install 阶段不skipWaitingactivate 阶段不clients.claim。这两个「不」就是暂停更新的技术基础。新版本装好了但它老老实实待在 waiting 状态直到页面说「可以切了」。接下来是页面侧的注册与状态管理。我把它封装成一个UpdateManager类// src/update-manager.ts export class UpdateManager { private registration: ServiceWorkerRegistration | null null; private waitingWorker: ServiceWorker | null null; private isCriticalFlow false; async init() { if (!(serviceWorker in navigator)) return; this.registration await navigator.serviceWorker.register(/sw.js); if (this.registration.waiting) { this.waitingWorker this.registration.waiting; this.notifyUpdateReady(); } this.registration.addEventListener(updatefound, () { const newWorker this.registration!.installing; newWorker?.addEventListener(statechange, () { if (newWorker.state installed navigator.serviceWorker.controller) { this.waitingWorker newWorker; this.notifyUpdateReady(); } }); }); } setCriticalFlow(active: boolean) { this.isCriticalFlow active; } private notifyUpdateReady() { if (this.isCriticalFlow) { console.log([Update] 新版本已就绪但当前处于关键流程暂停激活); return; } // 触发 UI 提示由用户或策略决定 window.dispatchEvent(new CustomEvent(sw-update-ready)); } applyUpdate() { if (!this.waitingWorker) return; this.waitingWorker.postMessage({ type: SKIP_WAITING }); navigator.serviceWorker.addEventListener(controllerchange, () { window.location.reload(); }); } }关键逻辑在notifyUpdateReady如果isCriticalFlow为 true就只打日志不弹提示、不激活。等用户离开关键流程后再调用applyUpdate()。这就是「离线暂停更新」的最小闭环。如果你用 Vite可以在vite.config.ts里配合vite-plugin-pwa但注意要关掉它的自动skipWaiting// vite.config.ts import { defineConfig } from vite; import { VitePWA } from vite-plugin-pwa; export default defineConfig({ plugins: [ VitePWA({ registerType: prompt, // 关键不要用 autoUpdate workbox: { skipWaiting: false, clientsClaim: false, runtimeCaching: [ { urlPattern: /^https:\/\/taotoken\.net\/api\/.*/, handler: NetworkOnly, }, ], }, }), ], });registerType: prompt和skipWaiting: false是这套方案的开关。很多人用autoUpdate图省事结果就是文章开头说的那种事故。改成prompt之后更新时机完全由你的业务代码掌控。4. 断网弱网下暂停更新与恢复后灰度放量的验证步骤配置写完了怎么证明它真的有效这一节给你一套可复现的验证动作用 Chrome DevTools 就能完成。第一步模拟新版本发布。修改sw.js里的CACHE_VERSION比如从v2025-06-01-01改成v2025-06-01-02然后重新构建部署。打开页面在 DevTools 的 Application → Service Workers 面板里你会看到一个新的 worker 处于waiting to activate状态。此时页面上的sw-update-ready事件应该被触发前提是isCriticalFlow为 false。第二步验证关键流程暂停。在控制台执行// 模拟进入关键流程 window.__updateManager.setCriticalFlow(true);然后刷新页面让新的 waiting worker 出现。观察控制台应该只输出「新版本已就绪但当前处于关键流程暂停激活」而不会弹出更新提示。再执行setCriticalFlow(false)此时应该收到sw-update-ready事件。这一步验证的是「暂停」逻辑。第三步断网验证。在 DevTools 的 Network 面板切换到 Offline然后刷新页面。由于sw.js里配置了offline.html兜底页面应该显示离线提示而不是浏览器默认的恐龙页。同时模型调用请求因为走NetworkOnly会直接失败——这是预期行为你需要在 UI 上给用户一个「当前离线AI 功能暂不可用」的提示而不是让请求一直 pending。第四步恢复后灰度放量。把 Network 切回 Online然后按用户分组决定是否激活。比如你只想让 10% 的用户先更新// 灰度放量逻辑 function shouldApplyUpdate(userId: string): boolean { const hash simpleHash(userId); return hash % 100 10; // 10% 放量 } window.addEventListener(sw-update-ready, () { if (shouldApplyUpdate(currentUser.id)) { updateManager.applyUpdate(); } else { console.log([Update] 当前用户不在灰度范围继续使用旧版本); } });simpleHash用一个简单的字符串哈希即可保证同一用户每次判断结果一致。这样你就能做到新版本先给 10% 用户观察错误率和反馈再逐步放大到 50%、100%。整个过程不需要重新部署只需要调整放量比例。第五步验证缓存清理。在 Application → Cache Storage 里确认旧版本的static-v2025-06-01-01已经被删除只剩下新版本的缓存。这一步验证的是 activate 阶段的清理逻辑。把这五步跑一遍你对这套策略的信心就有了。实测下来最容易出问题的是第三步的离线兜底——很多人忘了配offline.html结果断网时页面直接崩掉。5. 本篇常见错误排查401、local proxy failed 与 reading choices这一节对照真实报错给你排查思路。这些错误我在接入过程中基本都踩过一遍。错误一401 Unauthorized。模型调用返回 401九成是 Key 的问题。检查三件事Key 是否过期、请求头字段名是否正确、Base URL 是否拼对。Anthropic 风格的接口用x-api-keyOpenAI 风格用Authorization: Bearer。如果你用的是 TaoToken 的统一通道确认 Base URL 是https://taotoken.net/api不要多加/v1或者少加路径。在 API Keys 页面重新生成一个 Key 试试https://taotoken.net/api-keys 。错误二local proxy failed。这个报错通常出现在你本地起了代理工具但代理规则没配对。前端请求打到了localhost:xxxx而不是目标地址。排查方法在 DevTools 的 Network 面板看请求的实际 URL如果是http://localhost:8080/v1/messages这种说明你的baseURL被某个环境变量覆盖了。检查.env.development和.env.production里的VITE_TAOTOKEN_BASE_URL确保生产环境指向https://taotoken.net/api。错误三reading choices of undefined。这个报错说明你按 OpenAI 的响应结构去解析但实际返回的是 Anthropic 结构或者反过来。Anthropic 的响应在content[0].textOpenAI 的在choices[0].message.content。统一用 TaoToken 通道时确认你调用的模型和解析逻辑匹配。如果你在 Cline 或 Claude Code 里配置Model ID 要写全比如claude-sonnet-4-20250514不要简写。错误四OAuth token expired。如果你用 Claude Code 的 OAuth 登录方式token 过期后会报这个。解决方法是重新走一遍授权流程或者改用 API Key 方式。在 Claude Code 的配置里Base URL 填https://taotoken.net/apiKey 填你的 TaoToken KeyModel ID 填对应模型。三件套缺一不可。错误五Service Worker 更新后页面白屏。这通常是缓存版本号没改或者index.html被旧缓存锁死。检查CACHE_VERSION是否每次发布都递增以及index.html是否走了 network-first 策略。我的做法是给index.html单独配NetworkFirst其他静态资源用CacheFirst。提示排查 Service Worker 问题时DevTools 的 Application → Service Workers 面板里勾选「Update on reload」和「Bypass for network」可以快速定位是缓存问题还是代码问题。但验证完记得取消勾选否则你测的不是真实行为。如果你在 Cline 的 MCP 配置里接入settings.json的片段大概长这样{ mcpServers: { taotoken: { command: npx, args: [-y, taotoken/mcp-server], env: { TAOTOKEN_BASE_URL: https://taotoken.net/api, TAOTOKEN_API_KEY: your-key-here, TAOTOKEN_MODEL: claude-sonnet-4-20250514 } } } }Codex 的auth.json配置类似把 Base URL、Key、Model ID 三件套填全即可。这三件套是接入任何模型通道的通用公式记牢了能省很多排查时间。6. 把模型调用 endpoint 收敛到 TaoToken 的完整接入路径最后这一节把在线能力的接入路径串起来。前面说了「离线策略与在线能力解耦」解耦的落点就是所有模型请求走 TaoToken 统一通道Service Worker 不碰这些请求。接入步骤分三步。第一步在 TaoToken 控制台创建 Key地址 https://taotoken.net/console 。第二步在前端配置里把baseURL指向https://taotoken.net/apiKey 走环境变量注入。第三步在sw.js的 fetch 拦截里排除taotoken.net域名确保模型请求不被缓存。如果你要做模型调试直接用模型对话页面最快https://taotoken.net/chat 。长期做编码和 Agent 的话Coding Plan 的入口在 https://taotoken.net/coding-plan 接入文档在 https://taotoken.net/doc 。Claude Code 的接入配置可以参考 https://taotoken.net/claude-code 。这套组合的价值在于你的 PWA 更新策略可以很激进比如每天发三个版本但模型调用始终走实时通道不会因为缓存版本切换而中断。反过来模型通道的 Key 轮换、限流调整也不会影响 Service Worker 的缓存逻辑。两条线各自独立演进互不拖累。最后给一个实用技巧在UpdateManager里加一个「更新窗口期」的概念。比如只在用户空闲超过 30 秒、且没有未保存表单时才允许激活新版本。实现方式是用requestIdleCallback配合表单脏检查function canApplyUpdate(): boolean { const hasDirtyForm document.querySelector([data-dirtytrue]) ! null; const isIdle !document.hasFocus() || performance.now() - lastInteraction 30000; return !hasDirtyForm isIdle; }把这个判断接到sw-update-ready事件的处理逻辑里就能做到「用户不忙的时候再更新」。这比单纯依赖skipWaiting靠谱得多也比强制刷新友好得多。整套方案跑下来你的 PWA 更新体验会从「惊吓」变成「无感」。