ARTICLE DETAIL

资讯详情

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

手搓Service Worker:原生JS实现轻量级HTML离线应用缓存策略

手搓Service Worker:原生JS实现轻量级HTML离线应用缓存策略 最近接了个小需求要把一个纯HTML的在线工具改造成离线可用的版本。一开始我也想走Workbox那套成熟路子后来觉得为一个小工具引一堆依赖实在不值当索性把Service Worker底层机制翻出来手搓了一遍。做完之后我确认了一件事原生Service Worker加上几个缓存策略完全能支撑起一个轻量离线应用而且整个工程就一个HTML文件加一个JS文件不需要任何构建工具。这篇文章就围绕“手搓HTML离线应用”这条线把Service Worker怎么注册、怎么预缓存、怎么拦截请求、缓存策略怎么选、常见坑有哪些全部拆开讲。代码可以直接抄路径改成你自己的项目就行。适合想给静态页面做离线能力、又不想引入框架和构建工具的前端开发者也适合刚接触PWA但想真正搞懂底层原理的人。1. 整体思路为什么离线能力选Service Worker1.1 浏览器缓存那么多为什么要用Service Worker开发里大家接触到的“缓存”通常指HTTP Cache就是服务器响应头里带Cache-Control、Expires那种浏览器自动判断要不要用本地副本。这套机制离线时确实能帮点忙但问题是它完全不受你控制浏览器说了算开发者拿不到决定权。还有一个老古董叫Application CacheAppCacheHTML5时代的离线方案后来被浏览器废弃了因为它的缓存更新机制实在一言难尽用户容易被卡在旧版本里出不来。localStorage容量只有几MB左右同步API只能存字符串适合存简单数据不适合存HTML、CSS、图片这种资源IndexedDB容量大一些但那是给结构化数据用的拿来缓存页面资源总是绕。真正为离线资源缓存而生的是Service Worker。Service Worker是浏览器在后台独立于页面运行的一个JS线程本质上是一个可编程的网络代理。页面发出去的请求都会先经过它你可以自己决定是走网络、走缓存、还是两者组合。只要这个代理能把自己需要的资源提前存下来页面离线运行就是顺理成章的事。更关键的是它不挑框架不挑构建工具一个原生HTML页面就能用这也是“手搓”能成立的核心原因。1.2 核心设计思路整个离线应用的设计拆成三个环节注册环节页面加载后调用navigator.serviceWorker.register()把sw.js注册上去。安装与激活环节SW脚本首次安装时触发install事件把要预缓存的核心资源HTML、CSS、JS、图标放进去安装完成后进入activate阶段清理旧版本残留的缓存。请求拦截环节之后页面每次发请求fetch事件都会触发。我们在事件里按缓存策略判断命中缓存就直接返回缓存没命中就走网络拿到响应后顺手塞进缓存。三个环节串起来就是一套完整的离线缓存生命周期。实际动手时会发现代码量没有想象中那么大但“拦截请求”这个机制非常灵活策略选错了直接影响页面表现所以第3节我会把每种策略的适用场景拆开讲。1.3 这套方案适合什么项目不是所有项目都适合用Service Worker。按我的经验它最适合三类场景纯静态工具型页面比如计算器、二维码生成器、JSON格式化工具逻辑都在前端资源和接口都是静态的离线后体验和在线几乎一致。文档型站点比如API手册、产品说明页、个人博客内容变化不频繁缓存优先策略能明显提升打开速度。演示和技术攻关项目比如你写了个小页面要发给别人看网络不稳定时离线兜底很加分。反过来如果你的页面需要登录认证、大量个性化接口、实时推送Service Worker要处理的情况就复杂得多。不是不能做而是策略设计成本会大幅上升。这种场景我建议只缓存静态壳子动态接口走网络优先千万别一股脑全缓存。2. Service Worker生命周期和核心机制2.1 生命周期五个阶段每个阶段能干什么从注册到正式接管请求一个Service Worker要经过这几个阶段downloading、installing、installed、activating、activated如果出错或被新版本替换还会进入redundant。对开发者来说真正需要写代码的其实是install、activate和fetch三个事件。installSW脚本下载完成后触发适合做预缓存。activate安装完成进入激活阶段触发适合清理旧版本缓存。fetchSW被激活并控制页面后页面发请求触发这是缓存策略的主战场。新手最容易犯的错是以为install里预缓存完就完事了结果页面一刷新请求根本没走SW。原因多半是activate没执行完或者旧SW还控制着页面。第一次注册SW后它不会接管当前已打开的页面要等下次刷新才生效。如果你希望当前页面在激活后立即被接管需要在activate事件里调用clients.claim()让新SW立刻获得对整个作用域内所有客户端的控制权。2.2 为什么需要skipWaiting和clients.claim两个容易忽略的API实际调试时帮了大忙。场景是这样的你发布了一个新版本sw.js里面改了缓存版本号。用户下次访问浏览器后台下载新sw.js发现和当前不一致新SW就会进入waiting状态等旧SW控制的所有页面关闭后才激活。如果用户一直挂着页面新版本要等很久才生效。skipWaiting()的作用是强制新SW跳过等待在install阶段直接进入activating。配合clients.claim()能做到“一刷新就上新版”。但要注意skipWaiting之后如果页面还在用旧缓存资源可能出现版本错乱。所以稳妥做法是先做好缓存版本号管理再决定要不要skipWaiting。我自己项目里的取舍是测试阶段加skipWaiting让改动能快速生效生产环境不加或只在特定条件下加避免资源版本和页面状态错位。这个属于经验选择没有绝对标准。2.3 缓存存储的结构和版本管理Service Worker缓存存放在Cache Storage里caches.open(name)可以打开或创建一个命名缓存。同一个名字对应一个缓存对象里面可以放任意多的请求/响应对。一个关键点是代码一旦改了旧缓存里的资源还在。如果不主动清理老资源会一直占着空间而且fetch逻辑如果还在用旧缓存名新页面可能拿到旧内容。所以我强烈建议在sw.js顶部维护一个版本号常量const CACHE_NAME my-offline-app-v1;每次发布新版把版本号改成v2、v3然后在activate事件里遍历所有缓存名把不以当前CACHE_NAME开头的全删掉。self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((keys) { return Promise.all( keys.filter((key) key ! CACHE_NAME).map((key) caches.delete(key)) ); }) ); });这段代码看着简单作用却很大。如果忘了清理用户设备上会堆积一堆无效缓存对缓存配额比较严格的浏览器来说可能触发存储配额问题。3. 缓存策略选型四选一还是组合拳3.1 四种主流策略的优缺点对比缓存策略本质上是在回答一个问题请求来了先查本地缓存还是先问网络四种主流策略各占一个角度Cache First缓存优先先查缓存命中直接返回没命中再走网络并把响应写进缓存。Network First网络优先先请求网络网络成功就用网络响应并更新缓存网络失败就落回缓存。Stale-While-RevalidateSWR立即返回缓存内容同时在后台发起网络请求成功后更新缓存。Cache Only / Network Only两个极端策略适用于纯离线素材或者登录、支付这类必须实时的接口。用表格来看更直观策略优势劣势典型场景Cache First速度快完全离线可用内容更新不及时版本号固定的CSS/JS、图片、字体Network First内容最新网络差时兜底网络慢时会等待首页HTML、文章列表SWR首屏快后台更新实现稍复杂头像、用户信息、报表数据Cache Only完全不联网资源必须提前缓存安装型离线素材Network Only实时性强断网即不可用登录、支付、实时行情为什么没有一种策略能通吃所有请求因为不同类型的资源对“新鲜度”的容忍度完全不一样。CSS文件版本号变了就是变了用缓存优先不会有问题但首页HTML每次访问可能都不同用户刷新希望看到最新内容这时候就要让网络优先。3.2 组合策略按请求类型分流大多数实际项目不会只用一种策略而是按请求类型分流。我的做法是写一个公共的fetch分发逻辑再对不同URL做不同处理。self.addEventListener(fetch, (event) { const requestURL new URL(event.request.url); if (event.request.method ! GET) return; if (requestURL.origin ! location.origin) return; // 页面导航请求网络优先 if (event.request.mode navigate) { event.respondWith(networkFirst(event.request)); return; } // 静态资源缓存优先 if (requestURL.pathname.startsWith(/assets/)) { event.respondWith(cacheFirst(event.request)); return; } // 其余资源SWR event.respondWith(staleWhileRevalidate(event.request)); });分流的好处是每个请求拿到适合它的策略。图片放缓存优先接口放网络优先页面导航放网络优先。如果你只有一个简单页面不分流全部用同一种策略也能跑但项目一旦超过几个文件分流就很有必要。3.3 三种策略的核心代码实现下面是三种常用策略的实现代码可以直接抄进sw.js。Cache Firstasync function cacheFirst(request) { const cachedResponse await caches.match(request); if (cachedResponse) { return cachedResponse; } const networkResponse await fetch(request); const cache await caches.open(CACHE_NAME); cache.put(request, networkResponse.clone()); return networkResponse; }注意networkResponse.clone()非常关键。Response对象本质是一根“流”一旦被读取就不能二次读取。要同时存进缓存并返回给页面必须先clone一份。Network Firstasync function networkFirst(request) { try { const networkResponse await fetch(request); const cache await caches.open(CACHE_NAME); cache.put(request, networkResponse.clone()); return networkResponse; } catch (error) { const cachedResponse await caches.match(request); if (cachedResponse) { return cachedResponse; } return new Response(网络请求失败且没有缓存。, { status: 503, statusText: Service Unavailable, }); } }Network First的坑是如果网络很慢但没断fetch会一直挂起用户等得很焦虑。可以给fetch加超时5秒后主动降级到缓存技巧在5.3节细说。SWRasync function staleWhileRevalidate(request) { const cache await caches.open(CACHE_NAME); const cachedResponse await cache.match(request); const networkPromise fetch(request) .then((networkResponse) { cache.put(request, networkResponse.clone()); return networkResponse; }) .catch(() {}); return cachedResponse || networkPromise; }SWR的特点是即使网络请求失败页面也感知不到因为首次加载直接返回了缓存等网络恢复后再慢慢更新缓存。对用户体验来说这是最平滑的更新方式。3.4 导航请求的特殊处理离线时fallback到页面壳做离线应用最容易忽略的就是导航请求。在线时用户访问example.com/about浏览器正常请求服务器离线时这个请求直接失败除非你显式告诉浏览器“没网时用主页顶上”。处理导航请求的标准写法是async function navigationFallback(request) { try { const networkResponse await fetch(request); const cache await caches.open(CACHE_NAME); cache.put(request, networkResponse.clone()); return networkResponse; } catch (error) { const cachedResponse await caches.match(request); if (cachedResponse) { return cachedResponse; } // 用缓存的首页顶替 const fallbackResponse await caches.match(/index.html); if (fallbackResponse) { return fallbackResponse; } return new Response(离线模式不可用, { status: 503 }); } }如果你做的是单页应用这个兜底尤其重要用户离线刷新任意路由都应该落到index.html再靠前端路由渲染对应页面。如果是多页静态站则需要为每个页面准备对应的离线缓存否则fallback时上下文会错乱。3.5 预缓存清单把要离线用的资源写在明面上install事件里可以一次性把必须离线可用的资源放进缓存。这个清单要谨慎设计。放太少离线打开时一堆资源加载失败放太多首次安装很慢流量消耗也大。常规做法是预缓存HTML、CSS、JS、图标和字体这些基础运行资源图片等体积大的资源交给Cache First策略去按需缓存。按需缓存意味着用户离线前至少访问过该资源所以如果你的核心页面里有一张大图把它也加进预缓存清单比较稳妥。const PRECACHE_ASSETS [ ./, ./index.html, ./style.css, ./app.js, ./icon.png, ]; self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_NAME).then((cache) cache.addAll(PRECACHE_ASSETS)) ); self.skipWaiting(); });注意addAll有一个很深的坑如果清单里任何一个资源请求失败整个install就会失败SW根本不会安装成功。开发阶段一个路径写错所有预缓存都白干。排查技巧是先在浏览器地址栏逐个访问清单里的路径确认都是200再跑SW。4. 实操流程从零搭一个离线HTML应用4.1 项目结构和初始化按“手搓”的精神这个演示项目不用任何脚手架四个文件就够offline-app/ ├── index.html ├── style.css ├── app.js └── sw.jsindex.html里写一个简单页面引上style.css和app.js即可。为了避免DevTools缓存干扰开发时资源可以带版本参数比如style.css?v1正式发布时再统一处理版本号。还有一点要提前说清楚Service Worker只能运行在HTTPS或localhost环境下普通http协议下浏览器会直接拒绝注册。测试环境最好用localhost开一个静态服务器而不是双击HTML文件。file://协议打开时连SW都没法注册这会劝退一票第一次上手的人。4.2 注册Service Worker社区公认的最小代码在index.html里加入注册逻辑script 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); }); }); } /script这里的if判断很关键不支持SW的浏览器会走静默分支页面保持正常网络加载。注册路径决定了SW的作用域如果sw.js放在根目录SW默认控制整个站点如果放在子目录它只能控制子目录下的页面。这个规则由scriptURL的路径决定初学时容易在子目录项目上反复踩坑。4.3 完整sw.js实现一份可直接改的样板把前面几节代码串到一起形成一份完整样板。复制粘贴后只要改成自己的CACHE_NAME和PRECACHE_ASSETS就能用。const CACHE_NAME offline-app-v1; const PRECACHE_ASSETS [ ./, ./index.html, ./style.css, ./app.js, ]; // 安装预缓存核心资源 self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_NAME).then((cache) cache.addAll(PRECACHE_ASSETS)) ); self.skipWaiting(); }); // 激活清理旧版本缓存 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(); }); // 请求拦截按策略分流 self.addEventListener(fetch, (event) { if (event.request.method ! GET) return; const url new URL(event.request.url); if (url.origin ! location.origin) return; if (event.request.mode navigate) { event.respondWith(networkFirst(event.request)); } else { event.respondWith(cacheFirst(event.request)); } }); async function cacheFirst(request) { const cached await caches.match(request); if (cached) return cached; const response await fetch(request); const cache await caches.open(CACHE_NAME); cache.put(request, response.clone()); return response; } async function networkFirst(request) { try { const response await fetch(request); const cache await caches.open(CACHE_NAME); cache.put(request, response.clone()); return response; } catch (error) { const cached await caches.match(request); if (cached) return cached; const fallback await caches.match(./index.html); if (fallback) return fallback; return new Response(Offline, { status: 503 }); } }这份样板把导航请求走Network First静态资源走Cache First放在小项目里已经够用。开发时你可能会发现改了CSS刷新没效果这就是Cache First把所有静态资源都从缓存取了。解决办法是改版本号或者在DevTools里勾选Update on reload让浏览器每次刷新都更新SW和缓存。4.4 本地起服务与三分钟验证流程本地快速起一个HTTP服务比如用Pythonpython3 -m http.server 8080然后浏览器打开http://localhost:8080进入DevTools的Application面板可以看到Service Workers区域显示激活状态。第一次访问后打开Network面板把离线模式打开再刷新页面页面应该仍然正常显示说明SW已经接管了页面请求。验证缓存内容时切到Cache Storage能看到刚才创建的缓存名以及存放的资源列表。如果列表不全多半是预缓存路径写错了回去检查路径大小写和相对路径关系。整个验证流程三分钟就能走完这也是手搓方案最爽的地方没有构建层所见即所得。5. 常见问题与排查技巧实录5.1 改完代码但页面不更新堪称头号坑我调试时浪费最多时间的坑就是“改了SW代码页面却不更新”。原因通常是浏览器对sw.js本身有HTTP缓存或者旧SW还在控制页面。解决办法有几招在DevTools Application面板勾选Update on reload让每次刷新都重新检查SW脚本。在install事件里主动调用self.skipWaiting()让新版本快速进入激活。给sw.js的请求头设置Cache-Control: no-cache让浏览器每次都向服务器校验脚本。改CACHE_NAME版本号让activate逻辑知道这是一次全新部署。如果页面始终显示旧内容最笨但最有效的方法是开无痕窗口重新访问或者在DevTools里手动Unregister再刷新。调试阶段我经常两三个版本叠在一起分不清哪个SW在控制页面Unregister是终极解药。5.2 注册失败不是所有环境都能跑SWService Worker只能在HTTPS或localhost下运行这是一个硬约束。如果你在普通http、IP直连或证书过期的环境测试浏览器会直接拒绝注册。生产环境通常没问题但本地测试时用localhost最省事别想着绕过浏览器的安全限制那样既不可靠也不安全。如果看到类似“An SSL certificate error occurred”的报错先确认当前页面协议是不是https再确认证书是否有效。本地localhost不需要证书所以开发调试最稳妥的方式就是本地起一个静态服务用localhost访问。5.3 Network First卡顿给fetch加超时Network First在网络正常时表现很好网络断开时也能快速落到缓存。但问题出在“网络很慢但没断”的中间态fetch请求会一直等用户看到白屏很久。我实测中遇到过请求卡了十几秒才失败的情况这对离线体验是灾难。给fetch加超时用AbortController就能实现function fetchWithTimeout(request, timeout 5000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); return fetch(request, { signal: controller.signal }).finally(() clearTimeout(timer)); }在Network First里用fetchWithTimeout替代fetch超时后走catch分支自然落到缓存兜底。Safari老版本对AbortController支持不太好但新版本基本没问题如果实在不放心也可以用Promise.race模拟超时。5.4 跨域请求和opaque响应一个容易翻车的地方如果你页面引用了CDN上的资源SW的fetch事件也能拦截到。但caches.put跨域请求响应时响应体是opaque不透明的也就是你能存进去但看不到状态码读不出内容大小。命中缓存时虽然能正常返回可远程资源一旦更新opaque响应的缓存判断会非常保守容易造成CDN资源长时间不更新。我的建议是核心资源尽量走同源让SW完全可控CDN上的第三方库如果必须用请用带版本号的URL并采用Network First或SWR策略避免永久缓存旧版本。另外跨域请求默认不带Cookie认证相关接口不要指望SW缓存响应。5.5 缓存配额问题频繁更新把存储塞满Service Worker缓存的容量不是无限的。如果每次发布都生成新缓存名又不清理旧缓存用户存储开销会逐渐变大最终可能出现QuotaExceededError导致caches.open和put全部失败。所以版本清理不能只在activate阶段做一次一劳永逸的处理最好在每次put资源前也检查缓存条目数或总大小。我自己做项目时维护了一个最大条目数常量超出后按最早缓存时间删除一批保证缓存体积稳定在合理范围内。对个人小项目来说只要老老实实改CACHE_NAME并清理旧缓存基本不会遇到配额问题。5.6 DevTools排查技巧速查排查离线应用时最常用的DevTools入口有这几个Application - Service Workers查看SW状态、最近更新时间可执行Stop、Update、Unregister。Application - Cache Storage逐个缓存查看请求与响应。Network - Offline一键模拟断网。Network - Service Workers查看被SW处理的请求能看出请求是从缓存还是网络返回。Application - Clear storage一键清空所有站点数据包括SW和所有缓存。遇到“在线正常离线白屏”的问题标准排查顺序是先在Network面板模拟离线看具体是哪个请求挂了再去Cache Storage确认该资源到底有没有缓存最后查SW fetch事件里是否拦截了该请求以及分流策略是否命中。沿着这个顺序基本能定位90%的问题。最后分享一个我实际测试时的心得不要一上来就设计复杂的策略组合先用“导航走Network First、静态走Cache First”这组最朴素的组合跑通流程等到你确信能分清每个请求的归属再引入SWR、超时、预缓存清单这些进阶能力。Service Worker代码本身不难理解真正难的是判断哪个请求该用哪种策略离线时页面要呈现什么状态。这个判断力只能靠一次次断网刷新熬出来没有捷径。希望你用上面的样板跑通之后也能体会到这种“手搓”的成就感。
返回列表