PWA实战:用Service Worker和Manifest把网页变成可安装、可离线的应用

发布时间:2026/9/16 20:06:17
PWA实战:用Service Worker和Manifest把网页变成可安装、可离线的应用 这几年我在团队里接过不少“把一个普通网页变得像个 App”的活儿。大部分站点功能都不缺缺的是用户打开之后的那种“应用感”——没有图标入口、断网就白屏、每次启动都像第一次见面。渐进式 Web 应用PWA解决的就是这堆细碎问题它不会逼你重写一套原生客户端而是用 Web 技术逐步给网页叠加离线能力、安装能力和原生体验。这篇文章记录我最近一个完整改造实例目标是把一个信息查询类的轻量工具站从“能用的网页”升级成“可安装、可离线、启动快”的渐进式 Web 应用。全程实测下来的踩坑和取舍都有适合正在评估 PWA 路线、或者想把手头网页逐步改造的开发者参考。整个改造分两个阶段。第一阶段是打地基完成 Web App Manifest、Service Worker 注册、静态资源和页面的缓存策略设计让站点具备最基本的可安装与可离线能力。第二阶段再考虑消息推送、数据同步和更深度的性能优化。这篇文章先把第一阶段讲透项目结构和配置代码我都会贴出来并解释每个字段和每条策略背后的逻辑。这样你看完可以直接在项目里复现不用走我踩过的弯路。1. 立项背景与技术选型为什么用 PWA 重构一个普通网页1.1 源项目的痛点先交代一下这个项目的真实处境。这是一个偏内部使用的资料速查工具前端是 Vue 3 Vite后端只提供一个 JSON 数据接口。用户在活动现场或移动网络环境下频繁打开网络质量时好时坏经常出现“页面框架加载出来了数据接口超时整页白屏”的情况。而且用户没有把页面加入收藏或桌面的习惯每次都要通过聊天记录里的链接再点进来。这就是典型的 PWA 适用场景内容不复杂、依赖网络却不稳定、需要像 App 一样常驻桌面。如果用原生 App 或小程序解决当然可以但团队没有移动端人力前端只有两个人。小程序和原生都意味着重新开发一套界面还要处理审核、发版、跨端差异。PWA 最大的优势是复用现有 Vue 代码改造范围集中在 manifest、Service Worker 和构建配置三个层面。即便将来团队要转小程序离线缓存和页面结构的设计思路也能迁移过去。1.2 技术栈与资源约束下的选择技术选型上我延续了项目原有的 Vue 3 Vite没有额外引入 Workbox而是直接手写 Service Worker。原因有二。第一这个项目只有三类缓存需求静态资源、页面导航、API 数据手写 SW 不到两百行就能覆盖引入 Workbox 反而多一层抽象。第二团队成员对 SW 生命周期不熟手写能逼着我们把 install、activate、fetch 这些时机彻底搞明白后面排查问题不会抓瞎。如果你负责的是大型项目、静态资源动辄上百个文件那我建议用vite-plugin-pwa底层是 Workbox它能自动生成预缓存清单避免手写维护。但小型项目手写 SW 是完全可控的还能加深理解。1.3 目录结构与构建配置目录上我只动了两个位置public/manifest.webmanifest放在站点根目录确保site/manifest.webmanifest可以直连访问public/sw.js放在根目录因为 Service Worker 的默认 scope 是由脚本路径决定的放根目录才能控制全站页面。这两个文件放public目录下Vite 构建时会原样复制到dist根目录不参与打包和 hash 命名。这里有个容易忽略的细节Vite 打包后的 JS/CSS 文件名默认带内容 hash如果你把这类文件名写死在 SW 的预缓存列表里每次发版都要同步改非常容易漏。我采用的做法是预缓存只写入口 HTMLindex.html静态资源通过运行时缓存来动态缓存首次访问后第二次进入就能命中缓存。这样发版时 SW 脚本不用跟着变只要缓存名称版本递增一次就行。# 项目根目录 ├── public │ ├── manifest.webmanifest │ ├── sw.js │ └── icons │ ├── icon-192.png │ ├── icon-512.png │ ├── maskable-512.png │ └── apple-touch-icon.png ├── src │ └── main.ts └── vite.config.ts2. Web App Manifest决定“安装到桌面”的体验下限2.1 完整的 manifest 配置与逐项说明Manifest 是 PWA 的身份证。浏览器通过它判断这个站点能否被安装、安装后叫什么名字、使用什么图标。我的第一版配置长这样{ id: /, name: 活动资料速查, short_name: 资料速查, description: 面向活动现场的轻量级资料与日程查询工具, start_url: /?sourcepwa, scope: /, display: standalone, background_color: #ffffff, theme_color: #2563eb, lang: zh-CN, orientation: portrait-primary, icons: [ { src: /icons/icon-192.png, sizes: 192x192, type: image/png, purpose: any }, { src: /icons/icon-512.png, sizes: 512x512, type: image/png, purpose: any }, { src: /icons/maskable-512.png, sizes: 512x512, type: image/png, purpose: maskable } ] }逐项说几个关键的。id字段我建议显式写出来它用于标识应用的唯一性Chrome 用这个字段判断是否已经安装了同一个应用。如果不写浏览器会以start_url作为兜底一旦以后改start_url可能被判定成两个不同的应用导致用户重复安装。start_url我加了?sourcepwa参数方便后续在站点统计里识别 PWA 安装入口带来的流量。这是个很常用的小技巧不占多少成本但数据价值高。scope控制哪些路径属于这个应用。默认情况下 scope 是 manifest 文件的目录我的 manifest 在根目录所以不写也默认全站。但建议显式声明/避免以后把 manifest 挪到子目录时行为变掉。display: standalone让应用以自己的窗口打开没有浏览器地址栏这是“像 App”的心理基础。background_color管的是启动瞬间的白屏背景色它必须是页面背景同色系避免从点击图标到页面渲染这段空档出现刺眼的颜色跳变。图标这块要重点说。我提供了 192 和 512 两种常规尺寸外加一个maskable类型的图标。普通图标在 Android 上会被裁成圆形、圆角矩形等任意形状边缘容易被切掉所以purpose为maskable的图标要预留出被裁切的安全区域——内容居中四边留白不少于整体尺寸的 20%。很多站点不做这个安装后图标被切得很难看。2.2 自定义安装按钮与 beforeinstallprompt配置好 manifest 之后Chrome 会自动在某些条件下弹出安装提示但这个时机不可控。更好的做法是捕获beforeinstallprompt事件自己决定在什么时候引导用户安装。事件触发说明浏览器已经认为站点满足安装条件但它不会自动弹出把event.prompt()留到你想要的交互时机再调用。let deferredPrompt null; window.addEventListener(beforeinstallprompt, (event) { event.preventDefault(); deferredPrompt event; document.querySelector(#install-btn).style.display block; }); document.querySelector(#install-btn).addEventListener(click, async () { if (!deferredPrompt) return; deferredPrompt.prompt(); const choice await deferredPrompt.userChoice; if (choice.outcome accepted) { console.log(用户接受安装); } deferredPrompt null; });这里我的经验是不要在一进页面就弹安装按钮最好在用户完成某个核心操作之后再亮出来。比如我们这个工具站用户第一次成功查询到资料后再弹出“安装到桌面”的引导转化率明显更高。事件触发的条件里包含用户之前是否已经安装过、是否频繁使用等指标如果用户已经安装这个事件不会再触发所以不用担心干扰老用户。2.3 不可忽视的 iOS 差异化配置Manifest 在 iOS 上的支持一直不如 Android 完整必须配合 meta 标签和 link 标签才能达到近似效果。我在index.html里加了这样一段meta nameapple-mobile-web-app-capable contentyes / meta nameapple-mobile-web-app-status-bar-style contentdefault / meta nameapple-mobile-web-app-title content资料速查 / meta nametheme-color content#2563eb / link relapple-touch-icon href/icons/apple-touch-icon.png /apple-touch-icon是 iOS 添加到主屏幕时使用的图标而且它不接受192x192这类小尺寸建议单独准备一张 180x180 的图。apple-mobile-web-app-status-bar-style控制独立窗口模式下状态栏的样式default表示白色状态栏深色文字如果你希望状态栏和主题色一致需要选black-translucent同时页面背景色要能延伸到状态栏区域否则会出现色差。iOS 16.4 之后的 Safari 才支持 Web Push所以推送还是留给第二阶段再考虑。3. Service Worker离线能力的关键基础设施3.1 生命周期是理解一切的总钥匙Service WorkerSW是一个独立于页面线程运行的 JavaScript 文件它像一层代理所有页面请求都会经过它。很多初学者卡住是因为没理解它的生命周期安装install、激活activate、闲置idle、更新更新后重新走 install。install阶段适合做预缓存。页面首次加载时浏览器下载 SW 脚本执行 install通常在这里把核心静态资源放进 CacheStorage。但要注意install 里cache.addAll一旦有一个请求失败整个安装就失败SW 会被丢弃下次访问再重试。所以预缓存列表要小且可靠这也是前面建议不要把 hash 资源写死的原因。activate阶段适合做旧缓存清理。注意 install 完不等于 SW 立刻生效要等所有由旧 SW 控制的页面关闭后新的 SW 才会 activate。用self.skipWaiting()可以跳过等待用self.clients.claim()可以让新 SW 立即接管不受控的页面。这两个 API 在开发调试时搭配使用非常顺手但生产环境要谨慎后面第五部分会展开说。fetch阶段是核心的请求拦截逻辑。在这个事件里判断请求类型、决定命中缓存还是走网络、以及要不要把网络响应写入缓存。这个项目里我写了三类策略见下一节。3.2 三类缓存策略与资源分类手写 SW 最容易走偏的地方是对所有请求一视同仁。我把请求分成三类页面导航请求、同源静态资源请求、API 接口请求每一类用不同的缓存策略。页面导航请求request.mode navigate采用 Network First优先走网络拿到最新 HTML 返回给页面同时顺手把响应复制一份放入缓存网络挂了就从缓存里取上一次成功的 HTML。这个策略对内容型站点最合适因为 HTML 通常不大但内容变化频繁用户希望每次打开能看到最新版本网络可用时不要主动用旧缓存。我实测手机浏览器上这个策略只在请求超时时才会有明显等待体感可达标。if (request.mode navigate) { event.respondWith( fetch(request) .then((response) { const copy response.clone(); caches.open(PAGE_CACHE).then((cache) cache.put(request, copy)); return response; }) .catch(() caches.match(request).then((cached) cached || caches.match(/index.html)) ) ); return; }同源静态资源JS、CSS、图片采用 Cache First命中缓存就直接返回不发起网络请求没命中才请求网络并写入缓存。这些资源文件名带 hash内容固定不变缓存命中率极高。注意response.clone()是必须的——响应体只能被读取一次你要返回给页面就必须复制一份再存进缓存。另一类同源资源如图片可以沿用同一逻辑但缓存容量要做好上限控制我在 activate 里会把超出 50 个条目的图片缓存整体清理一次。API 请求采用 Stale-While-Revalidate优先返回缓存数据让页面立刻渲染同时后台发起网络请求成功后更新缓存。这个策略能显著改善弱网环境下的首屏速度页面不至于一直转圈。风险是数据不是绝对实时对一致性要求高的场景要慎用。我们这个工具站的数据变更频率低完全够用。下面是整合后的完整 SW 代码const CACHE_VERSION pwa-demo-v1; const STATIC_CACHE ${CACHE_VERSION}-static; const PAGE_CACHE ${CACHE_VERSION}-pages; const API_CACHE ${CACHE_VERSION}-api; const STATIC_ASSETS [/, /index.html]; self.addEventListener(install, (event) { event.waitUntil( caches .open(STATIC_CACHE) .then((cache) cache.addAll(STATIC_ASSETS)) .then(() self.skipWaiting()) ); }); self.addEventListener(activate, (event) { event.waitUntil( caches .keys() .then((keys) Promise.all( keys.filter((key) !key.startsWith(CACHE_VERSION)).map((key) caches.delete(key)) ) ) .then(() self.clients.claim()) ); }); self.addEventListener(fetch, (event) { const request event.request; const url new URL(request.url); if (request.method ! GET) return; if (request.mode navigate) { event.respondWith( fetch(request) .then((response) { const copy response.clone(); caches.open(PAGE_CACHE).then((cache) cache.put(request, copy)); return response; }) .catch(() caches .match(request) .then((cached) cached || caches.match(/index.html)) ) ); return; } if (url.origin location.origin STATIC_ASSETS.includes(url.pathname)) { event.respondWith(caches.match(request).then((cached) cached || fetch(request))); return; } if (url.pathname.startsWith(/api/)) { event.respondWith( caches.open(API_CACHE).then(async (cache) { const cached await cache.match(request); const fetchPromise fetch(request) .then((response) { if (response.ok) { cache.put(request, response.clone()); } return response; }) .catch(() cached); return cached || fetchPromise; }) ); } });3.3 SW 脚本的版本管理与旧缓存清理SW 脚本本身的更新机制容易踩坑。浏览器检查 SW 更新是在导航请求时触发的但如果 SW 文件内容没变浏览器不会执行新的 install。所以我们约定每次发版涉及页面资源变更时必须手动改 SW 文件顶部的CACHE_VERSION字符串从pwa-demo-v1改成pwa-demo-v2从而触发 install再通过 activate 里的清理逻辑把 v1 的旧缓存删掉。这个清理逻辑有一个小设计key.startsWith(CACHE_VERSION)保留当前版本前缀的所有缓存其余全部删除。意味着 v2 发布后v1 的 static、pages、api 三个缓存都会被清理干净不会越积越大。如果你在调试期间发现缓存没有按预期清理优先检查 activate 是否执行了、以及clients.claim()是否被调用。我调试时会在 DevTools 的 Application 面板里点一下 “Unregister” 再重新加载确保从干净状态开始验证。4. Lighthouse 审计与真机验证从达标到可用的距离4.1 仔细看审计项别只盯着分数看到页面能离线打开了先别急着庆祝用 Lighthouse 做一次系统审计。现在的 Chrome DevTools 里Lighthouse 提供的是分类指标PWA 相关的能力分布在 “Installable可安装” 和 “Progressive Web App” 两类中。跑分只能作为参考关键是看明细——是否有可安装的 manifest、SW 是否注册成功、页面是否在 HTTPS 下、启动画面和主题色是否配置、图标是否遮罩安全。我第一次跑完Installable 类全部通过但这个项目的 icon 可访问性项标黄了。点开详情才发现是 512 图标在 Lighthouse 模拟的窗口宽度下面资源加载超时被判定为 “加载失败”。真实情况是我本地起了两个服务图标在另一个端口上同源检查没过。修好后重新跑就全部绿了。这个排查过程提醒我Lighthouse 的分数更像体检报告每一条审计项的说明文字里都写清楚了判定标准和修复建议花点时间读完比单纯看分数有用得多。4.2 安卓与 iOS 的真机差距跑分全绿只是第一步真机测试才能暴露真实差异。我测试设备一台 AndroidChrome一台 iPhoneSafari两个平台的表现差距非常明显。Android 端的体验基本符合预期安装按钮正常出现独立窗口启动、启动画面背景色正确、状态栏颜色为主题色。iOS 端则是“能用但糙”手动添加到主屏幕后图标正常但第一次启动会出现一瞬白屏状态栏颜色怎么调都和安卓不完全一致离线刷新页面时Safari 偶尔会先展示系统错误页再加载缓存内容。这些差异不是配置错误而是 WebKit 渲染机制决定的。我的建议是iOS 上把测试重点放在“核心流程能否在离线条件下完成”细节样式尽力即可不要追求两端像素级一致。4.3 离线与弱网测试的完整方法聊一套可复制的离线测试流程。用 Chrome DevTools 的 Network 面板切到 Offline 模拟断网刷新页面验证 HTML、CSS、JS 都能从缓存加载再把 API 接口在 Network 面板里右键 Block request URL模拟接口单独挂掉的场景确认页面走 Stale-While-Revalidate 逻辑时还能展示上一次的数据。这两种场景必须分开测因为网络请求的失败时机不同SW 的catch分支是否能正确兜底只有真正测了才知道。弱网模拟我用的是 DevTools 里的 Fast 3G / Slow 3G 预设。你会发现 Slow 3G 下尽管接口请求要等很久页面框架却能秒开因为静态资源都命中缓存了。这正是 SW 的实际收益所在也是给用户最直接的体感提升——断网时起码能看到内容和提示而不是白屏加转圈。5. 实战中踩过的坑从现象到根因的排查记录5.1 坑一安装了新 SW页面却一直是旧缓存一个让我印象很深的坑。发版后我把缓存版本改成了 v2自测一切正常但线上用户反馈仍然是旧版内容。排查链路是这样的先确认用户能收到新 HTML——Network 面板里请求确实返回了 200但内容还是旧的。然后看 SW 状态发现新版 SW 处于 “waiting” 状态没有 activate。原因是有旧页面还开着旧 SW 仍在控制它们按照生命周期规则新 SW 必须等旧页面全部关闭才能接管。我在install里加了self.skipWaiting()在activate里加了self.clients.claim()问题就解决了。但这里要说清楚如果你在生产环境对所有用户都无条件跳过 waiting可能造成“用户正在填写表单时页面突然被新版本接管”的情况。我之所以敢用是因为这个工具站页面无状态没有需要保留的页面数据。如果你的站点有复杂表单状态建议保留默认等待机制在页面代码里监听controllerchange事件引导用户刷新。5.2 坑二manifest 同域部署图标却 404这个坑也很典型。开发时我用的相对路径src: icons/icon-192.png本地一切正常。打包部署后页面能正常访问 manifest但 DevTools 提示 icon 请求 404。查了请求 URL 才反应过来manifest 在根目录时相对路径解析没问题但如果部署环境加了 CI 前缀比如把应用挂在site/abc/下相对路径就解析成了site/icons/icon-192.png而不是site/abc/icons/icon-192.png直接 404。排查之后我把所有图标路径改成了绝对路径/icons/icon-192.png再部署一次就好了。这里想提醒的是manifest 和图标文件一定要跟着 base 路径走如果你用了 Vite 的base配置更要注意路径统一。5.3 坑三CDN 文件被缓存后出现跨域报错我在优化阶段给页面引入了公共字体和第三方统计脚本并把它们也加进缓存策略。结果发现SW 拦截这些跨域请求后页面出现跨域报错。根因是请求模式不同——跨域脚本走的是no-cors模式返回的是opaque响应这种响应可以放入 CacheStorage但从中读取时再往页面里用会被浏览器判定为不透明而拒绝。另外有些 CDN 响应虽然能正常读取但缺少Access-Control-Allow-Origin头也会在缓存重放时报错。解决方案是调整策略跨域资源我改成 Network First并且只在response.type basic或response.type cors时写入缓存opaque响应一律不缓存。这样绕开了跨域限制虽然离线时这类资源会失效但至少不会伤害页面主流程。5.4 坑四iOS 上的状态栏和图标行为与安卓不一致iOS 上如果只配了 manifest不配 meta 和 link 标签几乎等于白做。我最初只加了 manifestAndroid 一切正常iPhone 桌面图标是个页面截图缩略图状态栏也乌黑一片。后来补上了apple-touch-icon和apple-mobile-web-app-status-bar-style才正常显示。还有个细节iOS 对start_url里的参数识别也很怪某些版本会在独立窗口打开时把查询参数去掉导致页面统计丢失。我是在页面代码里对window.location.search做了兜底解析而不是完全依赖start_url传参。5.5 坑五HTTP 与 localhost 环境下的雷区Service Worker 只能在 HTTPS 或localhost下注册这是硬性安全限制。我在调试阶段的测试环境挂在局域网 IP 上用 HTTP 访问DevTools 报错 “An SSL certificate error occurred”SW 注册失败PWA 能力全部失效。解决办法是开发时坚持用localhost或者给内网测试服务器配置自签名证书并信任它。如果你只是临时联调也可以直接用 Vite 的--host加本地 HTTPS 代理总之别在不安全上下文里指望 SW 能工作。6. 阶段性复盘这一期做到了什么下一期做什么6.1 第一期成果的验证清单第一阶段到此就收尾了我列一份可复现的成果验证清单方便你改造完自查在 Chrome 地址栏打开站点DevTools Application 面板的 Manifest 分栏没有红色报错图标能正常预览。将 Network 面板设为 Offline刷新页面HTML、CSS、JS 均能从缓存加载页面正常渲染。将浏览器切到移动端模拟确认地址栏右侧出现安装图标点击后能安装到桌面。从桌面图标启动应用窗口独立打开没有浏览器地址栏启动画面背景色和主题色正确。修改页面内容后重新构建部署并将 SW 的缓存版本CACHE_VERSION加 1确认老用户刷新后能看到最新版本同时旧缓存被清理。用 Lighthouse 审计Installable / PWA 相关分类没有阻断级问题。6.2 下一期的技术方向第二期我还有几件明确想做的事。一是消息推送目标平台先集中在 Android 和桌面 ChromeiOS 16.4 的 Web Push 后面再验证。二是把静态资源预缓存改为自动清单生成考虑引入 Workbox 的generateSW模式解放手写维护的人力。三是接入应用安装后的活跃统计通过window.matchMedia((display-mode: standalone))在独立窗口模式下上报埋点量化 PWA 改造的实际收益。这三个方向都会在后续文章里继续分享感兴趣的话可以先把手头项目的这期改造跑起来有问题欢迎在评论区交流。最后说一点个人感受。做 PWA 最容易陷入的误区是追求所有能力一蹴而就但它的核心价值恰恰在“渐进”二字——先把安装入口和离线兜底做好让用户在任何网络条件下都能打开你的页面这已经能解决很大一部分体验问题。我在实际项目中体会最深的是Service Worker 的生命周期和缓存策略设计远比想象中重要它决定了整个系统的可维护性。每次改缓存逻辑时多问自己一句“缓存版本变了吗、旧数据清理了吗”能帮你避开多数坑。这套改造从开始到上线总共花了三个工作日工作日里还包括真机测试的时间性价比相当高建议你直接动手试一把。