从零搭建可安装可离线的PWA:详解manifest.json配置与ServiceWorker生命周期及precache、stale-while-revalidate缓存策略。
PWA 离线应用实战:Service Worker 缓存策略与 manifest 配置
PWA 离线应用实战:Service Worker 缓存策略与 manifest 配置
当你在地铁里掏出手机想看一眼昨天没读完的网页,信号却只剩一格时,传统网页会毫不犹豫地给你一个大大的"无法连接到网络"。但有些网站不一样——它们即使断网也能打开,能像原生 App 一样被"添加到主屏幕",甚至还有自己的启动图标和闪屏。这类网站背后的技术,就是 PWA(Progressive Web App,渐进式 Web 应用)。本文不堆砌概念,而是带你从零搭出一个真正能离线打开、能被安装的 Web 应用,把 manifest 配置与 Service Worker 缓存策略这两块硬骨头啃下来。
很多人以为 PWA 是什么新框架、新语言,其实它既不是框架也不是库,而是一套"体验标准":只要你的网页满足可安装、可离线、可被发现这几个条件,它就是一个 PWA。PWA 的厉害之处在于,它跑在浏览器里,却拥有接近原生应用的体验;它不需要去应用商店审核上架,用户扫码或访问一次就能"安装"。理解 PWA 的核心,对前端工程师来说已经从"加分项"变成了"基本功"。
一、重新认识 PWA:它能解决什么真实痛点
在动手写代码之前,先想清楚 PWA 到底解决了什么问题。传统的网页应用有三个绕不开的短板。
第一个短板是"弱网即瘫痪"。只要网络一断,页面就白屏,用户什么都做不了。即使用户只是想看一眼昨天已经打开过的文章,也得重新联网。第二个短板是"没有入口"。用户看完就关,下次再来要么靠收藏夹,要么靠搜索引擎,网站很难像 App 一样驻留在用户的主屏幕上。第三个短板是"体验割裂"。浏览器地址栏、前进后退按钮、不安全提示,时刻在提醒用户"你用的是网页",而不是一个完整的产品。
PWA 正是针对这三个短板逐一开刀。它通过 Service Worker 在浏览器与网络之间插入一层"缓存代理",让断网也能返回内容;通过 manifest 文件声明应用的名称、图标与显示方式,让浏览器愿意把它"安装"到主屏;通过 HTTPS 与一套渐进增强的设计哲学,保证老浏览器访问时只是少一点能力,而不会整体崩溃。
这里必须强调一个前提:PWA 的几乎所有能力都建立在 HTTPS 之上。Service Worker 只能注册在 HTTPS(或本地的 http://localhost)环境下,这是浏览器出于安全考虑做的硬性限制。换句话说,如果你的站点还在用HTTP,第一步不是写代码,而是先把证书配上。下面所有示例都默认你已经跑在安全的上下文里。
二、PWA 的两大支柱:manifest 与 Service Worker
如果把一个 PWA 拆开看,真正起核心作用的是两块:一块是 manifest.json,负责"可安装";另一块是 Service Worker,负责"可离线"。前者是一份声明文件,告诉浏览器"我是一个应用,请用应用的样子对待我";后者是一段运行在独立线程里的脚本,负责拦截请求、读写缓存。
为了让页面同时具备这两块能力,我们通常在入口 HTML 里做两件事:用 <link rel="manifest"> 关联清单文件,用 JavaScript 注册 Service Worker。下面这段就是最小可用的"引导代码":
html<!-- 在 HTML 的 <head> 中声明 manifest,浏览器据此判断是否可安装 --><link rel="manifest" href="/manifest.json"><!-- 在页面底部注册 Service Worker,注意路径决定其控制范围(scope) --><script> // 注册前先确认浏览器支持,避免在不兼容环境报错 if ('serviceWorker' in navigator) { // 页面加载完成后再注册,避免阻塞首屏渲染 window.addEventListener('load', () => { // sw.js 放在根目录,意味着它能拦截全站请求 navigator.serviceWorker.register('/sw.js') .then(reg => console.log('Service Worker 注册成功,作用域:', reg.scope)) .catch(err => console.log('Service Worker 注册失败:', err)); }); }</script>
<
上面这几行代码虽然短,却勾勒出了 PWA 的整个骨架。为了让你更直观地理解"装了 PWA 之后,请求到底走了什么不同的路",请看下面这张在线与离线架构对比图:在普通网页里,每一次资源请求都必须穿过网络直连服务器;而在 PWA 里,Service Worker 像一个守在浏览器门口的"中间人",能决定哪些请求直接走缓存、哪些请求去网络拿、哪些请求两者结合。正是这个差异,让"断网也能打开"成为现实。
理解了这张图,接下来的内容就顺理成章了:我们先把"可安装"的身份证(manifest)配好,再深入"可离线"的引擎(Service Worker)内部。
三、manifest.json 详解:让网站拥有"可安装"的身份
manifest 本质上是一个 JSON 文件,放在站点根目录,用来描述这个 Web 应用"作为 App 时应该长什么样"。当浏览器检测到一个页面正确关联了 manifest,并且该页面已被 Service Worker 控制、且通过 HTTPS 访问过一定次数后,就会在地址栏或页面里弹出"安装"提示。
下面是一份比较完整的 manifest 配置,每一项都加了中文注释,方便你对照自己的项目修改:
json{"name":"前端哥 PWA 示例", // 安装后主屏显示的应用全名"short_name":"前端哥", // 空间不足时显示的简称"description":"一个演示离线能力的渐进式 Web 应用","start_url":"/index.html", // 从主屏启动时打开的入口页面"display":"standalone", // 全屏无浏览器地址栏,像原生 App"background_color":"#ffffff", // 启动闪屏的背景色"theme_color":"#3b82f6", // 系统状态栏与地址栏的主题色"orientation":"portrait", // 锁定竖屏,可选 landscape 等"scope":"/", // PWA 能控制的范围,超出则回退浏览器"icons": [ {"src":"/icons/icon-192.png", // 图标路径"sizes":"192x192", // 适配不同分辨率的设备"type":"image/png","purpose":"any maskable" // maskable 适配异形屏的安全区 }, {"src":"/icons/icon-512.png", // 更高分辨率的图标"sizes":"512x512","type":"image/png","purpose":"any maskable" } ]}
我们逐个把关键字段讲透。
name与short_name是一对"全名"和"小名"。系统在空间充裕时(比如桌面端安装后的快捷方式)显示name,在主屏图标下方空间紧张时显示short_name。建议两者都填,且short_name控制在四个字以内,主屏显示最友好。
start_url决定用户从主屏点开应用时,第一个落地的页面。常见的坑是写成相对路径或者带了多余查询参数,导致每次打开都像是一次"全新访问",之前的登录态、路由都丢了。稳妥的做法是指向一个干净的入口,比如/index.html或/。
display有四个取值:fullscreen(全屏,无系统 UI)、standalone(独立窗口,无浏览器地址栏,最像原生 App)、minimal-ui(带极简的浏览器控件)、browser(就是普通网页)。做 PWA 多半选 standalone,让用户体验最贴近原生。
background_color和theme_color经常被人混淆。background_color出现在应用"冷启动"那一瞬间的闪屏(splash)背景上,而theme_color 出现在系统状态栏、任务栏、地址栏等系统级 UI 上。换句话说,用户点开图标到页面渲染完成之间的那几百毫秒,看到的是 background_color;页面跑起来之后,顶栏的颜色由theme_color决定。关于主题色如何与页面样式联动,可以参考CSS 变量(Custom Properties)实战:一行 var() 搞定全局主题切换,把 manifest 的 theme_color和页面里的CSS 变量统一起来管理,换肤时一处改动处处生效。
orientation用于锁定方向,适合游戏、工具类应用;内容型站点一般不强求,留空即可自适应。
scope 是新手最容易忽略的字段,它规定了 PWA"管得着"的页面范围。比如 scope设为/app/,那么用户在/app/ 内的导航都由 PWA 接管;一旦跳到 /blog/,就回到普通浏览器模式。把scope和start_url放在同一层级,是最不容易出错的搭配。
最后是icons,它是"启动图/闪屏"能否正常显示的关键。很多新手只放一张图,结果在某些设备上安装按钮就是不出现——因为系统要求至少提供 192×192 和 512×512 两个尺寸,并且建议加上 purpose:"maskable",这样图标才能在异形屏的安全区内完整显示,不会被系统自动裁掉边角。所谓"启动图",其实并非一张单独的图片,而是由background_color+theme_color + 合适的 icons在启动时由系统自动合成的闪屏。
四、Service Worker 注册:把离线引擎装进页面
manifest 解决的是"可安装",真正让离线成为可能的,是 Service Worker(以下简称 SW)。SW 是一段运行在浏览器后台独立线程里的脚本,它有几个与普通脚本截然不同的特征:第一,它不依附于任何页面,即使页面关闭也能在后台存活一段时间;第二,它没有 DOM 访问权限,不能操作 document,但能拦截它作用域内的所有网络请求;第三,它是一个事件驱动的"中间人",通过监听install、activate、fetch等事件来工作。
前面第二章已经展示了最基础的注册代码,这里补一个更健壮的版本,把"支持性检测"和"更新时机"讲清楚:
js// 更健壮的注册写法:区分首次注册与后续更新if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') .then(registration => { console.log('注册完成,作用域:', registration.scope); // 如果 sw.js 内容变了,新 SW 会进入 waiting 状态 // 这里可以提示用户"有新版本,是否刷新" registration.onupdatefound = () => { const newWorker = registration.installing; newWorker.onstatechange = () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { console.log('发现新版本,可提示用户刷新以应用更新'); } }; }; }) .catch(err => console.error('注册失败:', err)); });}
这里引出一个关键概念:SW 的更新是"先备后用"的。当你修改了 sw.js,浏览器会安装新的 SW,但旧的 SW 依然控制着当前打开的页面,新的 SW 会进入 waiting状态,直到所有旧页面都被关闭、或你主动调用skipWaiting/clients.claim 强制接管。这是 SW 一个非常有意思、也容易踩坑的设计——它保证了正在运行的页面不会因为脚本更新而突然"断根"。
注册时还有两个细节要留意。其一是路径即作用域:把sw.js放在根目录/sw.js,它的scope默认就是整个站点;如果放在/app/sw.js,它只能控制/app/ 下的请求。其二是 HTTPS:再次强调,线上必须 HTTPS,本地开发用 http://localhost 即可,不要试图在普通 HTTP 域名下调试 SW,那是死路一条。
五、生命周期三阶段:install、activate、fetch
SW 不是注册完就立刻干活的,它有自己严格的生命周期,分为三个核心阶段:install(安装)、activate(激活)、以及反复触发的fetch(拦截请求)。理解这三个阶段,是写出正确缓存逻辑的前提。
1. install 阶段:预缓存"应用壳"
install 在 SW 第一次被安装时触发一次。这个阶段最适合做"预缓存"(precache)——把构成应用骨架(App Shell)的那一小撮文件提前塞进缓存,比如 HTML 入口、核心 CSS、核心 JS、图标。这样即使用户还没访问过具体页面,这些"壳子"文件也已经在本地了,断网时至少能渲染出框架。
js// Service Worker 安装阶段:预缓存"应用壳"(App Shell)const APP_SHELL = [ '/', // 首页(注意有些服务器 '/' 与 '/index.html' 不等价,最好都列上) '/index.html', '/styles/main.css', '/scripts/app.js', '/icons/icon-192.png'];self.addEventListener('install', event => { // waitUntil 让安装阶段等到缓存写完才结束,避免壳子缺斤少两 event.waitUntil( caches.open('app-shell-v1').then(cache => { console.log('开始预缓存应用壳'); return cache.addAll(APP_SHELL); // 全部成功才算成功,任一失败则整个安装失败 }) );});
注意cache.addAll 是"全有或全无"的:列表里只要有一个资源 404 或网络出错,整个安装就会失败。所以预缓存清单应当只包含那些你百分之百能拿到的核心文件,不要把一大堆可能变化的接口或图片塞进去。
2. activate 阶段:清理旧缓存
当新 SW 激活时,activate阶段触发。它的主要任务是"打扫卫生"——把上一版遗留的旧缓存删掉,避免缓存无限膨胀、还顺带清掉那些已经没人用的旧版本数据。
js// Service Worker 激活阶段:清理过期的旧缓存,释放存储空间const CACHE_WHITELIST = ['app-shell-v1', 'runtime-v1'];self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(keys => Promise.all( keys.map(key => { // 不在白名单里的缓存(旧版本)统统删除 if (!CACHE_WHITELIST.includes(key)) { console.log('删除旧缓存:', key); return caches.delete(key); } }) ) ).then(() => self.clients.claim()) // 立即接管当前已打开的页面 );});
这里用了一个"白名单"技巧:只保留指定版本号的缓存名,其余一律删除。配合下面的"缓存版本号"机制,每发一版代码就升一次版本(比如app-shell-v1升到app-shell-v2),旧版本会在激活时被自动清理,既保证用户拿到新资源,又不会让硬盘被旧缓存塞满。
3. fetch 阶段:真正拦截请求
fetch 是 SW 的"主战场",每当作用域内有网络请求发出,它都会被触发。我们可以在这里决定:这个请求是走缓存、走网络,还是两者结合。下一节我们就把最常用的几种缓存策略写清楚。
六、缓存策略实战:precache 与 stale-while-revalidate
缓存策略是 PWA 离线能力的灵魂。不同资源适合不同策略,盲目全缓存或全不缓存都达不到好效果。业界总结出几个经典模式,我们挑最实用的两个讲透:预缓存(precache)和运行时缓存中的 stale-while-revalidate(简称 SWR,中文叫"先旧后新")。
预缓存我们已经在install 阶段见过了,它解决的是"壳子在不在"。而运行时缓存(runtime caching)解决的是"内容新不新"——用户在浏览过程中请求到的图片、文章、接口,在请求的同时被顺手存进缓存,下次再请求时就能直接命中。
SWR 是最能兼顾"快"和"新"的策略,特别适合图片、字体这类"稍微旧一点也没关系,但最好不要每次都等网络"的资源。它的逻辑是:收到请求时,立刻把缓存里的旧版本返回给用户(如果有的话),与此同时在后台悄悄发起网络请求去拿最新版本,并把最新版本写回缓存,供下一次使用。用户几乎零等待看到内容,而缓存又在后台悄悄更新。
js// 运行时缓存策略:stale-while-revalidate(先返回缓存,再后台更新)function staleWhileRevalidate(request) { // 从 Cache Storage 中打开我们命名的运行时缓存空间 return caches.open('runtime-v1').then(cache => { // 先尝试匹配缓存,这一步立即返回一个 Promise(可能命中也可能落空) const cachedResponse = cache.match(request); // 同时发起网络请求,去取最新版本 const fetchedResponse = fetch(request).then(networkRes => { // 网络拿到后,悄悄写入缓存,供下次使用(注意要 clone,响应体只能读一次) cache.put(request, networkRes.clone()); return networkRes; }); // 谁先到用谁:缓存先到就先用,网络后到则更新缓存 return cachedResponse.then(cached => cached || fetchedResponse); });}
需要特别留意networkRes.clone()这一行。一个Response对象的响应体只能被读取一次,如果你既想把它写进缓存、又想把它返回给页面,就必须先clone() 一份,否则会遇到"响应已被消费"的报错。这是写 SW 缓存逻辑时最高频的坑之一。
除了 SWR,还有两种策略值得了解。cache-first(缓存优先)适合那些几乎不变的资源,比如打包后的带哈希文件名app.8f3a.js,命中缓存就直接返回,连网络都不用问;network-first(网络优先)适合内容必须最新的场景,比如实时数据接口,先打网络,网络失败才回退缓存。把策略按资源类型"分而治之",才是成熟 PWA 的做法。
讲到图片缓存,就不得不提懒加载。图片往往是一个页面里体积最大的资源,如果一股脑全请求、全缓存,既浪费流量也拖慢首屏。更优雅的做法是用Intersection Observer API 实战:高性能滚动触发动画与懒加载 里介绍的方案,让图片进入视口时才加载,同时在 SW 里用 SWR 把它们缓存下来——这样用户第二次滑到同一位置,图片连网络都不用走,离线也能秒出。懒加载负责"少请求",运行时缓存负责"请求过的别再请求",两者配合才是离线图片体验的完整答案。
七、组合拳:一个最小可运行的完整 sw.js
前面把各个零件拆开讲了,现在把它们拼成一个能直接用的sw.js。下面这段代码同时覆盖了预缓存、activate清理、以及按请求类型分流的fetch策略,拿去稍作修改就能跑:
js// 一个最小可运行的 Service Worker:预缓存 + 运行时分流策略const APP_SHELL = ['/', '/index.html', '/styles/main.css', '/scripts/app.js'];const SHELL_CACHE = 'app-shell-v1';const RUNTIME_CACHE = 'runtime-v1';// ① 安装:预缓存应用壳self.addEventListener('install', event => { event.waitUntil(caches.open(SHELL_CACHE).then(cache => cache.addAll(APP_SHELL))); self.skipWaiting(); // 安装完立即激活,跳过 waiting(视产品策略而定)});// ② 激活:清理旧缓存并接管页面self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(keys => Promise.all(keys.map(k => (k !== SHELL_CACHE && k !== RUNTIME_CACHE) ? caches.delete(k) : null)) ).then(() => self.clients.claim()) );});// ③ 拦截请求:按类型选择策略self.addEventListener('fetch', event => { const url = new URL(event.request.url); // 非 GET(如提交表单)或跨域请求,直接放行走网络,不拦截 if (event.request.method !== 'GET') return; if (url.origin !== self.location.origin) return; // 页面导航(HTML 文档):网络优先,失败回退到缓存的首页(离线兜底) if (event.request.mode === 'navigate') { event.respondWith( fetch(event.request).catch(() => caches.match('/index.html')) ); return; } // 其余静态资源(JS/CSS/图片等):stale-while-revalidate event.respondWith( caches.open(RUNTIME_CACHE).then(cache => { const cached = cache.match(event.request); const fetched = fetch(event.request).then(res => { cache.put(event.request, res.clone()); return res; }); return cached.then(c => c || fetched); }) );});
这段代码里有一处很关键的兜底:当请求是一个页面导航(mode === 'navigate')时,我们"网络优先,失败回退首页"。这意味着即便用户断网,至少能打开预缓存好的首页骨架,而不是一片空白——这就是"离线可用"最朴素也最实在的体现。而其余的 JS、CSS、图片则走 SWR,兼顾速度与新鲜度。
如果你打算把应用壳进一步组件化,让 UI 在离线时也能保持结构稳定,可以借鉴 Web Components 实战:用原生 API 封装可复用自定义元素(Shadow DOM 与插槽全解析) 的思路,把头部、侧边栏、卡片等封装成自定义元素。这样即便网络层断了,由 Shadow DOM 隔离出来的组件结构依然完整,配合 SW 的壳子缓存,离线体验会非常接近原生 App。
八、调试与常见坑:在 DevTools 里看清 SW 的一举一动
写 PWA 最容易"以为写对了,实际没生效",所以必须学会用 Chrome DevTools 的 Application 面板来排查。打开 DevTools → Application → Service Workers,你能看到当前页面注册的 SW 状态、作用域、以及"Update on reload"(每次刷新都重新安装)等开关。调试时强烈建议勾选 "Update on reload" 和 "Bypass for network",前者保证每次刷新都拉最新 SW,后者允许你在需要时跳过 SW 直连网络,避免被旧缓存误导。
Cache Storage 子面板则能让你直接看到每个命名缓存里究竟存了哪些请求和响应,是验证"预缓存到底成没成功"的最快方式。如果清单里的文件没出现,多半是 cache.addAll 时某个资源 404 导致整个安装失败。
下面把最常见的几个坑集中说一下。
第一坑:作用域(scope)不对。SW 放在子目录却想控制全站,结果是只有那个子目录下的请求被拦截。解决方法是把 sw.js放到根目录,或在注册时用register('/sw.js', { scope: '/' })显式声明。
第二坑:缓存没更新。你改了sw.js 但用户看到的还是旧逻辑,因为新 SW 卡在 waiting。开发阶段用 "Update on reload";生产环境可以用 skipWaiting主动激活,但要权衡"正在运行的页面被突然替换"的体验风险。
第三坑:响应体被消费两次。忘记clone(),又想既写缓存又返回响应,结果页面拿到空响应。记住:凡是"既缓存又返回"的地方,先clone()。
第四坑:整页 HTML 被 SWR 缓存导致永远拿不到新页面。HTML 文档应当用"网络优先 + 回退首页"的策略,而不是直接 SWR,否则用户会被永远锁在旧版本里。
第五坑:MIME 类型错误。服务器把 sw.js以错误的Content-Type(比如text/html)返回,浏览器会拒绝注册。确保sw.js的响应头是text/javascript或application/javascript。
最后,安装引导页和"添加到主屏"的入口 UI 本身也是页面的一部分,它需要良好的响应式布局来适配手机和桌面。布局技巧上可以复用 CSS Grid 网格布局完全指南:从网格线到 auto-fit 响应式里的auto-fit方案,让引导按钮和说明文字在不同屏幕宽度下都能优雅排布,不会因为设备差异而错位。
九、总结:PWA 不难,难在把细节做对
回顾一下,我们从一个"断网就白屏"的普通网页出发,一步步把它改造成了一个能安装、能离线的 PWA。核心动作只有两件:用 manifest.json 给网站发一张"可安装"的身份证,用 Service Worker 在浏览器与网络之间架起一层缓存代理。
manifest 负责声明名称、图标、启动图(由 background_color+theme_color+icons 合成)、显示方式与作用域;Service Worker 则通过 install预缓存应用壳、activate清理旧缓存、fetch 按策略拦截请求,把离线能力真正落地。缓存策略上,预缓存保证壳子在,运行时缓存里的 stale-while-revalidate 兼顾速度与新鲜,而页面导航请求用"网络优先 + 回退首页"做离线兜底。
PWA 之所以叫"渐进式",是因为它不强求一步到位:老浏览器不支持 SW,页面照样能看,只是少了离线能力;新浏览器支持,用户就获得接近原生的体验。这种"能升级就升级,不能升级也不崩"的设计哲学,正是它能在各种环境下稳健落地的根本原因。把本文的清单对着你的项目逐条核对,一个真正可用的离线 Web 应用,其实并没有想象中遥远。

发表评论发布评论后审核成功,即可显示在列表中...