ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Service Worker离线缓存实战:从注册到五种缓存策略

Service Worker离线缓存实战:从注册到五种缓存策略 最近接手了一个给车间设备维护用的工具页面功能本身不复杂——输入设备编号显示维护记录和操作指引。但使用环境很特殊车间里信号差断网是家常便饭。第一次现场演示的时候页面加载到一半网络断了白屏卡在那里场面非常尴尬。后来我把精力放到了离线Web应用上用Service Worker把整个页面的缓存策略手搓了一遍问题才算彻底解决。这篇博文就是这轮折腾的完整记录。简单来说Service Worker是浏览器在后台独立运行的一层“请求代理”所有网络请求先经过它再由它决定用缓存还是走网络因此即使完全断网页面也能从缓存里完整加载出来。这篇文章会从Service Worker的注册方式、Cache API的使用、五种常见缓存策略的原理和代码写法一直讲到调试方法、版本更新机制和坑点排查完整走一遍。适合已经能熟练写HTML/CSS/JS、想让自己的网站具备离线能力的朋友参考。1. 为什么要做离线应用先搞懂Service Worker的价值1.1 Service Worker到底是什么和传统缓存有什么区别先厘清一个概念浏览器里的HTTP缓存比如响应头里的Cache-Control、Expires其实也能减少重复请求但它有一个本质局限——决策完全由服务器返回的响应头决定浏览器不能根据“当前是否在线”自主判断。而且HTTP缓存的清空策略受浏览器配额和用户操作影响根本没法保证离线可用。Service Worker则是另一种思路。它是一个独立于网页的JavaScript运行环境由浏览器在后台启动并常驻。它把请求处理逻辑完全交给开发者你可以在Service Worker内部自己决定哪个请求走缓存、哪个请求走网络、缓存多久、缓存哪些内容。这就相当于在浏览器和服务器中间加了一个完全由你控制的“代理层”。打个比方HTTP缓存像是商场的自动门开不开由总部统一设定Service Worker则是你自己雇的保安进门要不要登记、哪些人放行、哪些人要带路全由你现场决定。这意味着离线能力不再依赖服务器是否配合而是前端自己就能做主。1.2 离线应用的适用场景不是所有网站都需要Service Worker不是万金油接入之前先做需求判断。我个人的判断标准是你的用户是否会在网络不稳定的场景下依赖你的页面完成核心操作。典型场景包括车间和工地里的移动工具页、企业内部系统、机场列车上的信息查询页、在线阅读器、会议签到页、文档编辑工具等。反过来如果一个页面完全依赖服务端动态生成或后端API必须有强鉴权且实时性要求极高离线意义就不大甚至可能出现“缓存了旧的敏感数据反而更糟”的情况。所以不要一上来就想着给所有页面加离线先想清楚到底哪些资源必须离线可用哪些数据绝对不能缓存。这个前置判断做得越细后面写缓存逻辑就越省心。1.3 前置条件HTTPS和浏览器兼容性Service Worker的注册要求HTTPS环境原因很简单——它处在请求拦截的核心位置如果HTTP页面也能随意注册中间人攻击就能通过篡改脚本劫持全部流量。本地开发时例外localhost包括127.0.0.1被浏览器当作安全上下文对待可以直接调试。浏览器兼容性方面现代浏览器都已经支持Service Worker和Cache API但需要注意Safari对Cache API的实现在细节上和其他浏览器存在差异比如某些旧版本对Cache.prototype.addAll的支持、对后台更新策略的处理不太一样。不过单论离线缓存和加载问题不大。特老版本浏览器可以走渐进增强——支持SW的启用离线逻辑不支持的正常走网络不影响使用。这也是Service Worker设计里比较友好的一点。2. 从零注册Service Worker迈出离线应用第一步2.1 注册脚本为什么不能放在页面底部就结束注册Service Worker其实只涉及一个APInavigator.serviceWorker.register()。但很多教程把它简写成孤零零一行新手照着做却总是“注册失败”或“刷新后不生效”问题大多出在生命周期和路径细节上。先给出一个比较稳妥的注册模板// 在页面主HTML中引入 if (serviceWorker in navigator) { window.addEventListener(load, function() { navigator.serviceWorker.register(/sw.js) .then(function(reg) { console.log(Service Worker注册成功作用域, reg.scope); }) .catch(function(err) { console.log(Service Worker注册失败, err); }); }); }有两个细节必须强调。第一放在window的load事件之后再注册而不是在脚本加载时立刻执行。这个顺序能让浏览器先完成页面主体资源的加载避免Service Worker的安装过程与页面初次渲染争抢网络带宽。弱网环境下这个差异非常明显我实测发现先注册SW会让页面首屏渲染慢几百毫秒不值得。第二register()返回的是Promise一定要接错误处理。HTTPS失效、路径写错、权限问题都会导致注册失败不接catch的话浏览器只会在控制台角落默默报错你根本不知道发生了什么。还有一个常见认知误区Service Worker脚本在注册完成后不会立即接管页面。首次注册的SW要等页面刷新两次——第一次完成激活第二次正式接管——才真正生效。如果你是第一次调试注册成功后刷新个两三次再下结论免得误以为代码有问题。2.2 目录路径和scope的坑路径问题绝对是Service Worker入门第一大坑。register方法中脚本路径决定了默认作用域scope作用域决定它能拦截哪些页面请求。举个例子就清楚了navigator.serviceWorker.register(/sw.js); // 作用域是 / 能控制整个站点 navigator.serviceWorker.register(/js/sw.js); // 作用域是 /js/ 只能控制 /js/ 目录下的页面或请求如果你把sw.js放在深层目录又想控制整个站点必须显式指定scopenavigator.serviceWorker.register(/js/sw.js, { scope: / });注意这里有个硬限制scope参数不能比脚本所在目录更宽松否则会直接抛SecurityError。比如脚本在/js/下想注册scope为/admin/可以更窄但想注册scope为/就会报错。我的经验是sw.js固定放在站点根目录路径写成绝对路径/sw.js这是避坑最省心的方式。别问为什么踩过一次就懂了。2.3 怎么确认注册成功注册之后怎么确认打开DevTools的Application面板左侧菜单找到Service Workers能看到当前生效的SW脚本、状态installing/activated/redundant和作用域。Chrome这个面板很直观但不少人第一次找不到入口我习惯按F12后按CtrlShiftP输入“sw”快速跳转。如果是本地开发Service Workers面板里有个Update on reload选项建议一直勾上。勾选后每次刷新页面都会强制更新SW脚本并重新走生命周期改代码的反馈速度快很多。不勾选的话浏览器默认每天检查一次更新你会陷入“我改了代码但页面怎么都不更新”的困惑白白浪费时间。提示开发调试时尽量用无痕窗口或独立用户配置避免多个窗口的Service Worker状态互相串扰。3. 核心缓存策略拆解五种方式怎么选在Service Worker内部缓存策略的本质是你在fetch事件里对“缓存”和“网络”两个来源做取舍。不同策略的核心区别只有三点优先读谁、要不要更新缓存、失败时怎么兜底。下面把五种常见策略逐一拆开讲。3.1 Cache First缓存优先Cache First的思路是有缓存直接从缓存返回完全没有缓存时才请求网络拿到响应后存入缓存。这是响应速度最快、离线能力最强的策略。适用场景文件名带哈希指纹的静态资源比如build/app.a1b2c3.jslogo、图标、字体这类几乎不变的内容纯展示型页面。这些资源内容基本稳定命中率极高离线时也能正常显示。核心实现是这样self.addEventListener(fetch, function(event) { event.respondWith( caches.match(event.request).then(function(cached) { if (cached) { return cached; } return fetch(event.request).then(function(response) { // 只缓存有效响应 if (response.ok || response.type opaque) { var clone response.clone(); caches.open(my-cache-v1).then(function(cache) { cache.put(event.request, clone); }); } return response; }); }) ); });注意clone()这一步不能省。因为响应是一个流只能被消费一次你要一边返回给页面用一边存进缓存就必须先克隆一份。很多新手在这里用原响应直接存缓存结果页面渲染异常找半天找不到原因。3.2 Network First网络优先Network First与Cache First相反优先走网络网络成功就用网络响应并顺手更新缓存网络失败断网、超时再回退到缓存。它牺牲了一点速度但数据实时性有保障。适用场景新闻列表接口、用户信息接口、需要较新数据的页面导航请求。在断网的一瞬间用户会先看到旧数据但页面不会白屏。等网络恢复后数据又能立即更新到最新。self.addEventListener(fetch, function(event) { event.respondWith( fetch(event.request).then(function(response) { if (response response.status 200) { var clone response.clone(); caches.open(my-cache-v1).then(function(cache) { cache.put(event.request, clone); }); } return response; }).catch(function() { return caches.match(event.request); }) ); });这种策略有个隐藏问题如果网络一直很慢但不至于断开用户会一直等网络响应体验反而比缓存优先更差。后面我会讲到用超时机制兜底这里先记住这个痛点。3.3 Stale While Revalidate后台更新这个策略是个人最常用的一个强烈推荐。它的逻辑是命中缓存先立刻返回缓存保证速度同时悄悄向网络发起请求拿到新响应后更新缓存。下次请求同一资源时用户才能看到新内容。适用场景新闻列表、博客文章、产品详情页等阅读性的页面可以容忍“下一次刷新才更新”的内容。self.addEventListener(fetch, function(event) { if (event.request.method ! GET) return; event.respondWith( caches.match(event.request).then(function(cached) { var networkFetch fetch(event.request).then(function(response) { if (response.ok || response.type opaque) { var clone response.clone(); caches.open(my-cache-v1).then(function(cache) { cache.put(event.request, clone); }); } return response; }).catch(function() { return cached; }); return cached || networkFetch; }) ); });里面return cached || networkFetch这行是关键命中缓存就立即返回后台的networkFetch继续跑跑完更新缓存。潜在问题在于后台请求的Promise理论上可能因SW被终止而中断不过现代浏览器的FetchEvent生命周期会等事件处理产生的Promise全部完成所以这个写法在主流浏览器里是安全的。如果刻意追求稳妥可以把networkFetch放进event.waitUntil里。3.4 其他策略速览Cache Only和Network OnlyCache Only只读缓存没有缓存就直接失败一般用于预先置入的离线包内容、固定文档或者配合更上层的策略做最后兜底。Network Only是纯走网络相当于完全绕过缓存层适合登录、支付、验证码这类必须实时校验的请求。这两种策略实现极短不单独贴代码后面的综合示例里会用到。3.5 怎么选一张选型表策略响应速度数据实时性离线能力推荐场景Cache First最快低最强带哈希版本号的静态资源、logo、图标Network First慢高有兜底接口数据、活动页、页面导航请求Stale While Revalidate快中强新闻列表、详情页、博客等读多写少页面Cache Only快无仅限已缓存内容预置离线包、固定文档Network Only看网络最高无登录、支付、验证码接口4. 手写缓存逻辑从install到fetch的完整实现有了策略的认知接下来可以自己把整个Service Worker从install到fetch完整写一遍。4.1 install阶段预缓存静态资源install事件是Service Worker的“开工准备”阶段。这里最常用的API是event.waitUntil它告诉浏览器“这个Promise完成之前别急着结束install阶段”。预缓存清单precache list一般包括页面入口HTML、CSS、JS、字体、基础图片。拿我那个车间工具页举例预缓存清单长这样var CACHE_VERSION toolbox-v1.0.0; var PRECACHE_URLS [ /, /index.html, /css/main.css, /js/app.js, /js/vender.js, /images/logo.svg, /fonts/iconfont.woff2 ]; self.addEventListener(install, function(event) { event.waitUntil( caches.open(CACHE_VERSION).then(function(cache) { return cache.addAll(PRECACHE_URLS); }).then(function() { return self.skipWaiting(); }) ); });这里有几个容易踩的坑需要集中说明。其一addAll有“全有或全无”的特性清单里只要有一个资源请求失败整个安装流程都会失败SW直接进入redundant状态。所以清单每一项都必须确认存在不要想当然写路径。我遇到过把/images写成/images/加个多余空格导致安装失败的情况控制台报错根本不会提示是空格问题。其二如果确实担心某项资源失败导致整体崩掉可以改成逐个add自己catch每项错误牺牲一点效率换稳定性。其三skipWaiting让等待中的SW立即激活省掉“等两次刷新才接管”的尴尬生产环境建议使用但它会让新版SW立刻上场你得配合activate阶段的旧缓存清理逻辑不然新旧缓存会混在一起。4.2 activate阶段清理旧缓存activate阶段的核心任务是清理旧版本缓存。如果没有清理逻辑每次发布新版本都会留下一套旧缓存长时间累积会占用大量磁盘配额而且可能让页面加载到已废弃的资源。var CACHE_VERSION toolbox-v1.0.0; self.addEventListener(activate, function(event) { event.waitUntil( caches.keys().then(function(keyList) { return Promise.all(keyList.map(function(key) { if (key ! CACHE_VERSION) { return caches.delete(key); } })); }).then(function() { return self.clients.claim(); }) ); });关键点在于给缓存命名时加入版本号比如toolbox-v1.0.0每次发布新版就改版本号activate时把不等于当前版本的缓存全删掉。我曾偷懒不写清理逻辑结果开发机Chrome的Cache Storage里躺着六个版本的缓存白白占了几十MB存储。磁盘配额这东西桌面浏览器看着无所谓在移动端会直接影响其他站点和应用正常运行。再解释clients.claim()它的作用是让新激活的SW立刻接管所有客户端页面。默认情况下浏览器要等页面刷新一次后SW才接管加了claim可以省掉这一步。但注意它不等同于“强制所有页面立即用新缓存内容”因为页面可能已经用旧SW拉取过旧缓存了。要做更细的版本控制需要配合Clients API和postMessage实现不过一般业务场景用不到。4.3 fetch阶段实现缓存策略fetch事件是整个Service Worker的核心战场。每个请求进来时根据请求特征、当前网络状态选择不同策略分发处理。self.addEventListener(fetch, function(event) { var request event.request; // 只处理GET请求 if (request.method ! GET) { return; } var url new URL(request.url); // 跳过非同源请求和非HTTP(S)协议 if (url.origin ! location.origin) { return; } // 页面导航请求使用Network First if (request.mode navigate) { event.respondWith(networkFirst(request)); return; } // 静态资源走Cache First if (url.pathname.startsWith(/css/) || url.pathname.startsWith(/js/) || url.pathname.startsWith(/images/) || url.pathname.startsWith(/fonts/)) { event.respondWith(cacheFirst(request)); return; } // 其余走Stale While Revalidate event.respondWith(staleWhileRevalidate(request)); });三种策略的具体实现函数如下function cacheFirst(request) { return caches.match(request).then(function(cached) { if (cached) return cached; return fetch(request).then(function(response) { if (!response || response.status ! 200) { return response; } var clone response.clone(); caches.open(CACHE_VERSION).then(function(cache) { cache.put(request, clone); }); return response; }); }); } function networkFirst(request) { return fetch(request).then(function(response) { if (response response.status 200) { var clone response.clone(); caches.open(CACHE_VERSION).then(function(cache) { cache.put(request, clone); }); } return response; }).catch(function() { return caches.match(request); }); } function staleWhileRevalidate(request) { var cachedCopy caches.match(request); var fetchPromise fetch(request).then(function(response) { if (response response.status 200) { var clone response.clone(); caches.open(CACHE_VERSION).then(function(cache) { cache.put(request, clone); }); } return response; }); return cachedCopy.then(function(cached) { return cached || fetchPromise; }); }看到这里可能有读者会问为什么fetch事件要小心翼翼分流因为在现实项目里把所有请求一概而论用同一种策略反而会变成灾难。比如把API请求强行Cache First用户看到的数据永远滞后把带哈希的静态资源改成Network First每次都要跟服务器确认一次等于放弃了离线能力。先分流再各自匹配最优策略就是Service Worker缓存设计的基本法则。4.4 完整示例代码把上面三段逻辑整合成一个sw.js整个文件大致长这样var CACHE_VERSION toolbox-v1.0.0; var PRECACHE_URLS [/, /index.html, /css/main.css, /js/app.js, /images/logo.svg]; self.addEventListener(install, function(event) { event.waitUntil( caches.open(CACHE_VERSION) .then(function(cache) { return cache.addAll(PRECACHE_URLS); }) .then(function() { return self.skipWaiting(); }) ); }); self.addEventListener(activate, function(event) { event.waitUntil( caches.keys() .then(function(keyList) { return Promise.all(keyList.map(function(key) { if (key ! CACHE_VERSION) return caches.delete(key); })); }) .then(function() { return self.clients.claim(); }) ); }); function cacheFirst(request) { /* 见上文 */ } function networkFirst(request) { /* 见上文 */ } function staleWhileRevalidate(request) { /* 见上文 */ } self.addEventListener(fetch, function(event) { var request event.request; if (request.method ! GET) return; var url new URL(request.url); if (url.origin ! location.origin) return; if (request.mode navigate) { event.respondWith(networkFirst(request)); return; } if (url.pathname.startsWith(/css/) || url.pathname.startsWith(/js/) || url.pathname.startsWith(/images/)) { event.respondWith(cacheFirst(request)); return; } event.respondWith(staleWhileRevalidate(request)); });发布时把这个文件放到根目录页面里注册上一节提到的register代码一个具备离线能力的页面就完成了。5. 调试与常见问题排查实录这部分是实战里最值钱的把我在调试Service Worker时遇到的典型问题和排查思路整理成速查表供你直接抄。5.1 DevTools的Application面板怎么看调试Service Worker强烈建议用Chrome DevTools的Application面板。左边栏能看到几个关键区域Service Workers、Storage、Cache Storage、Storage Usage。在Service Workers区域里你能看到当前作用域下注册的所有SW。如果某个SW显示stopped属于正常现象浏览器为了省电会随时暂停空闲SW。看到红色errors表示有异常日志。点开service worker文件链接可以直接跳到Sources面板打断点调试这点比console.log定位问题高效得多。Cache Storage区域则可以查看每个缓存仓库里存了哪些请求和响应。我常遇到一种情况明明写了缓存逻辑、页面也正常加载了但Cache Storage里就是没有数据。这时候先检查请求本身是否满足缓存条件——比如status不是200、响应类型是error或者请求是在HTTP缓存里而不是通过SW。逐项排除一般都能找到原因。5.2 刷新后还是旧数据头号问题这是所有玩Service Worker的人都会遇到的头号问题代码改完了刷新页面结果还是旧页面。原因多半不在fetch逻辑而在更新机制。常见原因有三个。第一SW脚本本身被HTTP缓存了。浏览器默认对SW脚本的缓存周期是24小时但某些服务器配置了更强的Cache-Control头导致浏览器拿到的还是旧版sw.js。解决办法是在服务器返回sw.js时设置响应头Cache-Control: no-cache, no-store, must-revalidate。第二页面在SW的更新流程里检测到新版本后需要激活如果页面没有调用registration.update()或监听controllerchange事件用户要等下一次刷新或浏览器后台自动检测才会更新。第三开发调试时漏勾了Update on reload浏览器还停留在旧SW状态。我排查这类问题的标准动作是先在Application面板里点Unregister注销SW再清空Cache Storage最后硬刷新CtrlShiftR。快速清场重头再来效率极高。5.3 版本更新的坑怎么让用户用上新版本关于版本更新很多教程教你拦导航请求然后提示用户刷新实际操作中更稳妥的做法是页面在启动时监听controllerchange事件发现SW控制权变更后提示用户刷新页面以获取新版本。var refreshing false; navigator.serviceWorker.addEventListener(controllerchange, function() { if (refreshing) return; refreshing true; // 这里可以弹提示发现新版本点击刷新获取更新 window.location.reload(); });注意别直接不加防抖就在controllerchange里立即reload因为安装/激活可能不止一次触发事件直接reload可能导致用户还没看完页面就被多次刷新打断。更稳妥是提示用户手动刷新或者在业务空闲时再reload。还有一种常见陷阱很多团队给静态资源加哈希版本号app.a1b2c3.js但HTML文件名不变。如果对HTML也用了Cache First用户会一直看到旧HTML里引用的旧资源永远拿不到新版。我的经验是HTML导航请求一律用Network First静态资源用Cache First并配合哈希文件名整个更新链路就顺了。5.4 404、跨域请求和opaque响应最后几个细节坑每个都值得单独说。第一个是404响应不能缓存。很多人在写缓存逻辑时不判断response.ok直接把404响应体存进缓存结果下次离线时用户看到的是缓存的404页面这是非常隐蔽的bug。上一节代码里我统一判断status200就是为了堵住这个口子。第二个是跨域请求。Service Worker能监听同源页面发出的所有请求包括跨域请求。跨域请求如果走CORS模式响应可以被Cache API正常存取但如果请求是写在script标签或img标签里发起的响应类型是opaque这种响应无法读取状态码也无法判断是否成功。Cache API虽然可以缓存opaque响应但Chrome等浏览器对opaque响应的配额占用按实际大小的一倍计算——也就是说一张1MB的图片存进缓存会占2MB配额。我的建议是跨域静态资源尽量不缓存除非你明确知道它可信且稳定否则离线时可能拿到错误的opaque响应页面渲染又多一个难排查的白屏。第三个是混合内容问题。HTTPS页面请求一个HTTP资源会被浏览器直接拦截SW层面也绕不过去。遇到这种请求优先把资源地址改成HTTPS千万别指望在Service Worker里做手脚。5.5 常见问题速查表现象大概率原因处理方法刷新后旧内容不变SW脚本被HTTP缓存/无强制更新服务器对sw.js加Cache-Control: no-cache勾选Update on reloadSW注册后不生效路径/scope错误或需要二次刷新检查sw.js路径使用根路径开发时勾选Update on reloadCache Storage没有数据请求状态不是200/未满足缓存条件打印fetch响应状态确保只缓存成功响应断网后页面白屏HTML未缓存或缓存策略错误对navigate请求改用Network First并确保install阶段预缓存HTML新版本页面一直不加载HTML用了Cache First导航请求改用Network First静态资源带哈希并Cache First缓存占用空间异常增长没有清理旧版本缓存activate里按版本号删除旧缓存避免缓存过多opaque响应我最想提醒的还是那句话Service Worker不是锦上添花的炫技它是一个需要认真对待的运行环境。缓存策略选错了轻则用户看到旧数据重则把404页面缓存了、把用户的更新卡死。我在车间那个项目上线后有一次发现工人手机上的页面版本始终不更新排查到最后才发现是sw.js被服务器默认的缓存头坑了当初要是早一点意识到“SW脚本自身也是HTTP资源”这个细节能少折腾半个下午。最后再分享一个小技巧如果你只是想给现有页面快速加上离线能力不一定要全部手写可以考察一下workbox这个工具库它把install/activate/fetch的整套逻辑封装成了几个策略方法写起来省事很多。但如果你像我一样喜欢掌控每一行逻辑或者页面规模不大不想引入额外依赖我上面手写的这套模板已经足够应付日常项目。先把install、activate、fetch三件事想清楚你的离线页面基本就成了。
返回列表