1. 为什么网页一断网就变白:离线应用要解决的核心问题
你有没有碰到过这种尴尬:地铁上信号忽明忽暗,打开自己做的 H5 页面直接白屏;或者用户明明昨天刚访问过你的活动页,今天断网后再点开,只剩一片空白。问题不一定出在你的代码写法上,而在你从来没有认真思考过 HTML 离线应用与缓存机制这个被大多数人忽略的角落。HTML 不是只能在线渲染的文档,配合浏览器提供的缓存能力,完全可以在断网状态下保持可用。
这篇文章我从实际项目落地的角度出发,把 HTTP 缓存、AppCache 的历史、Service Worker 与 Cache API、IndexedDB 这些关系掰开揉碎地讲清楚,并给出可以直接抄的缓存策略。无论你是用原生 HTML 写落地页,还是用 Vue/React 构建单页应用,只要理解了这条链路,断网白屏、更新不生效、缓存错乱这类问题都能定位到根因。
1.1 网页加载的本质:一切都是网络请求
很多人对“网页”的理解停留在“一个 HTML 文件”上,但实际上,浏览器打开一个页面时,同时要进行几十甚至上百次网络请求:请求 HTML 文档本身、CSS 样式表、JavaScript 脚本、图片、字体、视频,以及异步接口返回的数据。每一类资源都有自己独立的 URL 和生命周期。
一旦断网,这些请求会集体失败。浏览器拿不到 HTML,自然就没有文档结构;拿不到 CSS,即便有 HTML 也会像一份纯文本;拿不到 JS,动态渲染出来的单页应用就会停留在白屏或者 loading 状态。说白了,靠“在线请求”活着的页面,在网络断开的那一刻就丧失了全部能力。
离线应用要解决的核心问题,就是把“每次都要去服务器拿”的依赖,换成“本地已经有副本,网络断开也能直接读”的资源冗余。缓存机制本质上是在做一件很朴素的事:在服务器和页面之间,增加一个可以被控制的存储层。
1.2 离线应用需要解决的三件事
做离线应用,不是简单地把某个文件存下来就行。你在设计缓存方案时,至少要同时回答三个问题。
第一,静态资源怎么存。HTML、CSS、JS、图片这些文件体积大、数量多,如果每次都走网络,慢且浪费流量。这部分适合用 Cache API 做预缓存,按版本管理。
第二,接口数据怎么存。页面要展示的内容往往来自后端接口,断网后如果只缓存了静态资源,页面骨架有了,数据却是空的,用户看到的还是一个空壳。这里需要把 JSON 格式的接口数据本地化,存到 IndexedDB 或者 Cache Storage 里。
第三,版本怎么更新。缓存是把双刃剑:存得越狠,离线能力越强,但线上发了个新版本,用户却还在用旧缓存,你作为开发者会非常难受。所以离线方案必须包含一套明确的更新策略,让“何时用旧版”“何时拉新版”可控。
后面所有内容,都是围绕这三个问题展开的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. AppCache 的前车之鉴:HTML 离线缓存的演进逻辑
聊到 HTML 离线应用,绕不开一个已经进入历史垃圾堆但依然值得了解的东西:Application Cache,也就是 AppCache。它曾经以“HTML5 离线缓存”的身份被写进各种教材里,如今却被浏览器官方标记为废弃,这中间的坑,值得每一个做缓存的人记住。
2.1 Application Cache 曾经是怎么工作的
AppCache 的工作原理很简单:在一个 HTML 页面的 <html> 标签上挂一个 manifest 属性,指向一个 .appcache 后缀的清单文件。比如:
html复制<!doctype html>
<html lang="zh-CN" manifest="app.appcache">
然后在 app.appcache 文件里分区块声明缓存规则:
text复制CACHE MANIFEST
# v1
/index.html
/css/style.css
/js/main.js
NETWORK:
/api/refresh
FALLBACK:
/ /offline.html
CACHE MANIFEST 下面列出的资源会被浏览器本机缓存;NETWORK 下列出的地址保持在线访问,不被缓存;FALLBACK 则定义某个路径不可访问时,回退到哪个本地页面。
这套设计刚推出时确实很惊艳,因为它几乎不需要写 JavaScript,浏览器自己就把清单文件里的资源拉下来存好了。于是很多团队把它用在了移动端 H5 上,希望在弱网环境下能秒开页面。
2.2 它为什么被所有浏览器抛弃
AppCache 看起来省事,实际用起来全是坑。我当年在一个多页面的移动站上用过它,踩过几个印象深刻的雷。
第一个坑是更新不可控。只要清单文件里的内容发生变化,浏览器会自动重新拉取清单里所有资源,这个过程开发者无法精确干预,也没有任何 API 告诉你“更新到哪一步了”。想做个“先提示用户、再下载更新”的流程,完全做不到。
第二个坑是双缓存问题。AppCache 缓存的 HTML 文档,一旦被缓存,它和服务器上新的 HTML 会出现“两个版本打架”的情况。你改了 index.html 并更新了清单版本号,但浏览器可能仍然展示旧的页面,因为 AppCache 对页面本身有特殊处理。当时很多团队被迫在 HTML 里写一段“清除 AppCache”的脚本,体验非常糟糕。
第三个坑是存储空间限制和不可监控性。移动端浏览器对 AppCache 的容量限制通常只有 5MB 左右,超过后直接缓存失败。而且你无法代码里查看当前缓存了哪些资源、占了多少空间、是否能删除某个特定条目。这些问题叠加在一起,让 AppCache 成了一个“上线容易维护难”的定时炸弹。后来主流浏览器相继宣布废弃 AppCache,转而在 Service Worker 基础上重新构建离线能力。
2.3 我们还能从 AppCache 里学到什么
AppCache 失败的核心原因,不是“离线缓存”这个想法错了,而是它把缓存控制权从开发者手里拿走了。浏览器按照一套含糊的启发式规则帮你决定缓存什么、何时更新,你却只能在旁边干瞪眼。
Service Worker 接下来的设计思路完全是相反的:所有缓存行为都由开发者用 JavaScript 显式控制,缓存哪个请求、用哪种策略、什么时候清理、什么时候通知页面更新,全部变成代码。这种“把控制权还给开发者”的思路,正是离线应用走到今天的底层逻辑。所以你要做的第一件事,是放下 AppCache 那套“声明式”思维,拥抱 Service Worker 的“命令式”编程模型。
3. 先把地基打牢:HTTP 缓存与浏览器存储到底存了什么
在进入 Service Worker 实战之前,先花几分钟把地基理清楚。很多人把 Service Worker 缓存和 HTTP 缓存混为一谈,导致排查问题时误区重重。实际上浏览器里存在多套并行的缓存系统,各自解决不同层次的问题。
3.1 浏览器 HTTP 缓存的层级
HTTP 缓存是浏览器最早的一套缓存机制,它覆盖的是“相同 URL 的重复请求”。当服务器在响应头里返回 Cache-Control: max-age=3600 时,浏览器会把这个资源在本地缓存 3600 秒,这段时间内再次请求同一 URL,直接从本地读取,不再访问服务器。类似机制还有基于 Expires 的强缓存,以及基于 ETag / Last-Modified 的协商缓存。
它们的层级大致是:内存缓存(memory cache) → 磁盘缓存(disk cache) → 服务器。内存缓存读取速度最快,但生命周期极短,标签页关闭就没了;磁盘缓存能跨会话保留,是强缓存的主要存储地。
但你需要清醒一点:HTTP 缓存依赖服务器可访问。它是“在同一次在线会话中帮你减少流量”,根本无法解决“服务器彻底连不上”的离线场景。断网之后,即便资源已在磁盘缓存里,浏览器的请求处理流程也会因为网络不可达而进入失败分支。所以 HTTP 缓存是离线应用的基础设施,但离真正离线还差一步。
3.2 localStorage、sessionStorage 与 IndexedDB 的角色
HTML5 还提供了一批浏览器存储 API,它们主要用来存数据,而不是存“文件资源”。
localStorage 是同步 API,容量通常在 5MB 左右,适合存 token、用户偏好、低频率修改的配置项。刷新页面后数据还在,清除浏览器数据才消失。sessionStorage 生命周期只到当前标签页关闭,适合不跨会话的临时状态。
IndexedDB 是一个真正的客户端 NoSQL 数据库,支持索引、事务、异步读写,容量远大于 localStorage。如果你要存接口返回的列表数据、用户操作记录、甚至图片 Blob,IndexedDB 是更合适的选择。
我见过不少项目用 localStorage 存了一整页 JSON 数据,结果结构一大就超过 5MB 限制,出现“写入静默失败”“塞爆站点存储”的问题。正确做法是:小配置用 localStorage,大批量或结构化数据用 IndexedDB。离线应用里,IndexedDB 通常用来缓存业务数据,Service Worker 里的 Cache Storage 负责缓存静态资源和网络响应。
3.3 为什么说 Cache API 才是 Service Worker 的搭档
Service Worker 里能直接调用的缓存工具,是全局的 caches 对象。它对应浏览器里的 Cache Storage,和 HTTP 缓存完全独立,由开发者自己控制。
你可以把 Cache API 理解成一个“以网络请求和响应为键值对的本地仓库”。通过 caches.open('my-cache') 打开一个命名缓存,然后用 cache.put(request, response) 把某个响应放进去,用 cache.match(request) 查询是否存在对应缓存。它接收的参数是 Request / Response 对象,所以天然支持 HTTP 请求的所有维度:URL、method、mode、headers 等。
缓存存储的单位是“响应”,不是“字符串”。这意味着你的代码可以直接把 fetch 拿到的 Response 对象丢进 Cache Storage,下次命中时原样返回,不需要手动序列化。这一点是 localStorage 做不到的,也是 Service Worker 能实现离线页面加载的关键。理解了 Cache API 和 HTTP 缓存的区别,下面写 Service Worker 时就不会一头雾水。
4. Service Worker 离线缓存实战:预缓存、动态缓存与版本更新
前面讲了那么多背景,现在终于到主角上场。Service Worker 是浏览器在页面主线程之外运行的 JavaScript 脚本,它就像你家小区门口的"快递代收点":所有进出页面的网络请求,都可以被它先拦截一遍,决定是放行、改道还是直接从本地仓库拿一份。
4.1 Service Worker 的工作机制
Service Worker 有几个特性决定了它和普通脚本不一样:
第一,它依赖 HTTPS。出于安全考虑,浏览器不允许不安全的网络环境里出现 Service Worker,否则黑客很容易植入恶意拦截脚本。本地开发用 localhost 是例外,可以正常调试。
第二,它有独立生命周期。一个 Service Worker 会经历 install → activating → activated → idle → terminated 这些状态。你可以通过监听 install、activate、fetch 事件来干预不同阶段。
第三,它不是浏览器启动后立刻生效。页面第一次加载时,浏览器会去下载并注册 Service Worker,但这一版 SW 不会接管第一次的请求,必须等页面刷新或者 clients.claim() 之后才会拦截后续请求。很多新手调试时发现"注册了没生效",多半就是没理解这个控制时序。
4.2 第一步:注册 Service Worker
注册代码通常放在 HTML 的脚本里,而且要等页面 load 后再注册,避免和首屏渲染抢资源。最简单的写法:
javascript复制if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then(reg => {
console.log('SW registered:', reg.scope);
})
.catch(err => {
console.error('SW registration failed:', err);
});
});
}
这里最容易踩的坑是 scope 问题。register('/sw.js') 的默认 scope 是 /,也就是整个站点都能被这个 SW 接管。如果你的应用实际部署在 /app/ 下面,但 SW 文件却在 /sw.js,那 scope 依然是最顶层,没问题;可如果 SW 文件放在了 /app/sw.js 而你注册成 '/app/sw.js',默认 scope 是 /app/,这时你希望它拦截的 /api/ 请求就不在控制范围内。
所以注册时要显式声明 scope:navigator.serviceWorker.register('/app/sw.js', { scope: '/app/' })。scope 只能比 SW 文件所在路径更小或相等,不能反向扩大,这是浏览器安全限制,记住就好。
4.3 预缓存静态资源
Service Worker 的 install 事件是预缓存的最佳时机。所谓预缓存,就是页面第一次激活 SW 时,把清单里定义的资源全部下载并放进 Cache Storage。等用户断网时,这些资源已经有了本地副本,页面可以秒开。
javascript复制const CACHE_NAME = 'my-app-v1';
const PRECACHE_URLS = [
'/',
'/index.html',
'/css/app.css',
'/js/app.js',
'/img/logo.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then(cache => cache.addAll(PRECACHE_URLS))
);
// 新 SW 安装完成后,立即激活,不等页面关闭
self.skipWaiting();
});
请注意,cache.addAll() 有一个坑:只要其中一个请求失败,整个 addAll 就会 reject,导致 install 事件失败,SW 不会被激活。如果你缓存列表里有某个资源 404 了,安装过程会直接中断。一个稳妥做法是在 install 里对单个资源做容错,或使用 Promise.allSettled();但也要权衡:容错过多,可能让关键的 HTML 漏缓存。我的习惯是,核心入口资源严格 addAll,可替换的资源单独 cache.put 并 catch 错误。
4.4 动态缓存与 fetch 事件
预缓存只覆盖固定的静态资源。对于图片、接口、字体这类在页面运行中才会出现的请求,我们需要在 fetch 事件里做动态缓存。下面这段代码是"缓存优先,没有再走网络"的朴素实现:
javascript复制self.addEventListener('fetch', event => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(event.request).then(response => {
// 只缓存拿到的合法响应
if (!response || response.status !== 200 || response.type === 'opaque') {
return response;
}
const copy = response.clone();
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, copy);
});
return response;
});
})
);
});
这里有几个关键细节。第一,response.clone() 是必须的,因为 Response 对象只能被消费一次,你要返回给页面的同时再存入缓存,就必须克隆一份。第二,对请求方法做了过滤,POST、PUT 这类带副作用的请求绝不能直接缓存。第三,opaque 类型的响应来自跨域请求,它虽然被缓存了,但你读不到它的状态码和内容体,这类缓存策略要谨慎使用,我一般会跳过。
4.5 版本更新与旧缓存清理
只用固定 CACHE_NAME,资源永远不更新,这显然不行。Service Worker 的更新流程大致是:
- 页面刷新时,浏览器重新请求
sw.js文件。 - 如果
sw.js内容有变化(哪怕是字节级变化),就认为有新版本。 - 新版本 SW 进入
install状态,此时老版本 SW 仍然控制着页面。 - 在
install里调用self.skipWaiting()会让新 SW 立即进入activate状态。 - 在
activate里清理旧缓存,并通过clients.claim()让新 SW 立刻控制所有客户端。
完整代码里,activate 事件通常长这样:
javascript复制self.addEventListener('activate', event => {
event.waitUntil(
caches
.keys()
.then(keys =>
Promise.all(
keys
.filter(key => key !== CACHE_NAME)
.map(key => caches.delete(key))
)
)
);
self.clients.claim();
});
这个阶段最重要的是"版本号变更"的纪律:每次发布前端资源时,都要更新 CACHE_NAME 或 sw.js 文件内容,否则浏览器会认为 SW 没有变化,跳过全部更新逻辑。很多团队把 CACHE_NAME 做成构建产物里的 hash 或时间戳,就是为了保证这一点。
5. 缓存策略怎么选:四种经典方案与组合玩法
有了 Service Worker 之后,并不意味着所有请求都统一"缓存优先"。真实场景里,不同类型的资源对新鲜度和性能的要求完全不同。选错缓存策略,比不缓存更可怕。
5.1 Cache First:缓存优先
Cache First 策略是命中缓存就直接返回,完全不走网络;没有缓存才去请求网络,并把结果写入缓存。
javascript复制async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const copy = response.clone();
const cache = await caches.open(CACHE_NAME);
cache.put(request, copy);
return response;
}
它适合那些文件名带版本 hash 的静态资源。因为资源一旦发布,URL 就变了,旧版本文件不会再被引用,缓存里的旧数据永远不会再被用到。CSS、JS、图片、字体这类资源,用 Cache First 能获得最快的加载速度,同时也不担心更新问题——新版本对应新 URL,自然会被请求到。
5.2 Network First:网络优先
Network First 刚好反过来:先请求网络,请求成功就返回最新数据,并且顺手更新缓存;请求失败(断网、超时)才回落到缓存。实现如下:
javascript复制async function networkFirst(request) {
try {
const response = await fetch(request);
const copy = response.clone();
const cache = await caches.open(CACHE_NAME);
cache.put(request, copy);
return response;
} catch (error) {
const cached = await caches.match(request);
if (cached) return cached;
throw error;
}
}
这个策略最适合页面 HTML 本身和核心接口。HTML 每次进入都应该尽量拿到最新版本,否则用户看到旧页面,而接口数据已经换了一轮,页面就会出现明显错乱。断网时才用旧缓存兜底,这就是"在线优先、离线兜底"的体验。
5.3 Stale-While-Revalidate:返回旧缓存,后台悄悄更新
Stale-While-Revalidate(简称 SWR)是一个很聪明的中间态:先立刻返回本地缓存,让用户马上看到内容;同时在后台发起一次真实网络请求,成功后更新缓存。这样下一次访问时,拿到的是新数据。
javascript复制async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const networkResponsePromise = fetch(request).then(networkResponse => {
const copy = networkResponse.clone();
cache.put(request, copy);
return networkResponse;
});
return cached || networkResponsePromise;
}
它适合图片列表、用户头像、文章列表这类"稍微旧一点也能接受,但希望最终能更新"的内容。性能体验接近 Cache First,数据新鲜度又比 Cache First 高。需要注意,网络请求失败时错误会在后台静默出现,如果不做 catch,控制台会报错但不影响页面。
5.4 混合策略,让每种请求都去该去的地方
实际项目很少只用一个策略,而是按请求类型分发。常见做法是在 fetch 事件里判断请求特征:
javascript复制self.addEventListener('fetch', event => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
// HTML 页面:网络优先
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request));
return;
}
// 同源静态资源(css/js/img):缓存优先
if (request.destination === 'script' || request.destination === 'style' || request.destination === 'image') {
event.respondWith(cacheFirst(request));
return;
}
// API 接口:网络优先 + 实时更新缓存
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// 其余默认:缓存优先
event.respondWith(cacheFirst(request));
});
这里有一个值得展开的思路:页面和 API 的缓存策略要区分对待。页面用 Network First 是为了保证小版本更新及时;API 用 Network First 则是为了保证数据一致性。但并不是所有 API 都应该缓存,比如用户登录态的校验、订单提交、支付状态查询,这些请求不仅不能缓存,还应该在离线时直接提示"当前网络不可用"。不是每个请求都适合被缓存,这是做策略组合时最需要理性的地方。
5.5 策略背后要思考的体验指标
缓存策略不是一个技术选择,而是产品体验选择。你在决定"某个请求用 Cache First 还是 Network First"时,其实是在回答:用户看到旧数据的代价是什么?
图片旧一点无伤大雅;文章标题旧了会被用户骂;下单接口返回的是上一次订单,那就是事故;后台管理系统的用户列表,如果拿到缓存数据,导出报表就全部是错的。所以,核心原则是:越不能接受"旧数据"的请求,越不能缓存;越在意"性能"的场景,越倾向缓存优先。同时还要结合离线需求:如果某一个页面必须离线可用,那么它的数据源必须被缓存,并且缓存更新策略要同时兼顾在线和离线两条路径。
另外别忽略一个现实问题:Service Worker 缓存要占存储空间。Cache Storage 的配额比 localStorage 大不少,但也不是无限。把大视频、大图全部缓存会迅速撑爆配额,导致浏览器自动驱逐最早的数据。设计时尽量只缓存核心资源,非核心资源用运行时动态缓存,并设置容量上限检查。
6. 调试与避坑:离线缓存最容易翻车的 5 个场景
写了这么多年 Service Worker,我几乎每个项目都会踩一遍相似的问题。这些问题单独看都不难,但碰上时非常耗时间。我把它们集中整理在这里,能帮你少走很多弯路。
6.1 更新永远不生效:先检查 sw.js 的 HTTP 缓存头
这是最常见的问题之一。你明明发布了新版本,用户手机上却还是旧的 Service Worker,旧缓存迟迟不更新。原因往往不是 skipWaiting 写错,而是 sw.js 文件本身被 HTTP 缓存给缓存住了。浏览器对 sw.js 有特殊的更新检查机制,但如果服务器对它返回了长期的 Cache-Control: max-age,浏览器检查更新时可能拿到的是旧文件,自然永远不会触发新版本。
解决办法:在服务器里对 sw.js 设置 Cache-Control: no-cache 或 Cache-Control: no-store。比如 Nginx 里这样配置:
nginx复制location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
同时还要注意,受控页面必须在浏览器里刷新至少两次,才能完成"新 SW 下载-激活-接管页面"的完整流程。调试时可以在 DevTools 的 Application → Service Workers 面板点一次 Update,确认新版本状态,再刷新页面观察效果。
6.2 缓存了不该缓存的动态接口
如果你的 fetch 事件没有对请求类型做过滤,很容易把用户信息、购物车、临时 token 这类不该缓存的请求一起放进 Cache Storage。比如 "登录成功后拉取用户信息" 这个接口,缓存下来问题就大了:同一台设备上登录 A 用户后用缓存返回的是 B 用户的数据。
处理原则我给三条:
- 非 GET 请求一律不缓存,直接 return。
- URL 中包含
/user/、/cart/、/auth/等敏感路径的请求,默认跳过缓存。 - 接口响应头若带
Vary: Authorization,说明这个响应和登录状态相关,也不要缓存。
不要嫌过滤条件多,宁可在 fetch 事件里多写几个判断,也不要为了一点性能把业务正确性搭进去。
6.3 跨域资源与 CORS 的坑
Service Worker 可以拦截页面对跨域资源的请求,比如 CDN 上的 JS 文件、外部图片。但把这些资源存入 Cache Storage 时需要小心:如果响应没有正确的 CORS 头,response.type 会是 opaque,存到缓存里没问题,但之后再读取时,你无法判断它是否成功,也无法看到状态码。
更麻烦的是,当你调用 cache.put(request, response) 时,如果响应已经是 opaque,某些浏览器会直接抛异常。所以我在处理跨域请求时比较保守:要么只缓存本域资源,要么在 fetch 时加上 mode: 'cors' 来确保拿到的响应是 cors 类型;对于图片这类透明资源,使用 no-cors 模式读取 opaque 响应也是可行办法,但需要额外判断是否要注入缓存。
6.4 存储配额与缓存过期策略
浏览器对 Cache Storage 和 IndexedDB 的存储限制是动态的,不同浏览器差异很大。当容量占满后,浏览器可能主动清除整个源站的存储数据,包括缓存。这意味着你"离线可用"的承诺并不稳定,尤其对一直停留在旧版本页面的用户来说,他们的缓存可能在某一天被清空。
我的建议是,在 activate 或定期更新时,检查 caches 里的条目数量,超过阈值就删除非核心缓存;对真正重要的离线数据,调用 navigator.storage.persist() 请求持久化存储,但要注意用户可能拒绝授权。在设计时就应该有"缓存数据可能丢失"的兜底方案,比如断网时提示"当前离线状态,部分数据可能不可用"。
6.5 DevTools 离线模拟和真实弱网环境的差异
Chrome DevTools 的 Network 面板里有一个 Offline 勾选项,这是调试离线应用最快捷的方式,但它并不完全等价于真实弱网环境。勾选 Offline 会让所有网络请求立即失败,模拟的是"完全没有网络";而真实弱网场景往往表现为长时间 pending、随机超时、DNS 解析缓慢,这些情况 Service Worker 的 fetch 事件也会捕获,但缓存策略可能会误判为"网络失败",从而回落到旧缓存。
我习惯在调试时用 Network 面板的 Custom 配置,设置成 RTT 很高、带宽很低,观察不同策略下的表现;同时配合 DevTools 的 Application 面板查看 Cache Storage 的实时状态。还有一个容易忽略的小细节:如果你在浏览器里开着开发者工具,默认禁用缓存勾选,会导致 Service Worker 的更新检查行为看起来非常奇怪,记得先把"Disable cache"关掉再调试。
最后再分享一个实战心得:Service Worker 不是写出来就一劳永逸的,它是一套需要随业务一起演进的系统。每次改版更新缓存列表、每次调整接口路径都同步更新 fetch 策略,并且要留一套"一键清缓存"的逃生通道——把清理旧缓存的逻辑暴露为一个可调用的接口或后台按钮,一旦线上出问题,能快速让用户恢复在线状态。缓存机制,本质上就是"让用户更快看到内容"和"让用户看到正确内容"之间的反复平衡,掌握好 Service Worker 这个控制点,你就能把主动权握在自己手里。
