第 12 章 PWA:把网页装进桌面和手机
PWA(Progressive Web App,渐进式 Web 应用)要解决的问题很朴素:网页很好发版,但断网就白屏;原生 App 体验好,但要过审、要装包、要用户专门去下载。PWA 想在中间取一个平衡——一个网址,既能像网页一样随手打开,又能被"安装"到桌面、断网时还能用。它不是某个具体框架,而是三样东西的组合:Manifest(身份)+ Service Worker(离线能力)+ HTTPS(前提)。
论PWA 的三根支柱,缺一根就不成立
① Web App Manifest(应用名片):一个 JSON 文件,告诉系统"我叫什么名字、用什么图标、打开时要不要隐藏地址栏"。没有它,装不了。
② Service Worker(可编程的代理服务器):跑在浏览器后台的独立线程,能拦截本页面的所有网络请求,决定"走缓存还是走网络"。它是离线能力和推送能力的基础。它跑在独立线程,所以不阻塞页面;也因为独立,它拿不到 DOM。
③ HTTPS:Service Worker 权限太大(能伪造任意响应),所以浏览器只允许在 https 或 localhost 下注册。这是硬门槛,不是建议。
换句话说:Manifest 管"能不能装",Service Worker 管"能不能离线",HTTPS 管"能不能用"。
| 场景 | 判断 |
|---|---|
| 用户会重复访问的工具型产品(后台、笔记、看板、记账) | 值得做。能装到桌面、有离线兜底,体验接近原生,用户留存明显更好。 |
| 网络不稳的场景(外勤、展会、地铁、工厂) | 强烈值得。离线读写 + 回到有网时同步,是 PWA 最能打的地方。 |
| 纯内容站 / 一次性落地页 | 意义不大。做"缓存静态资源 + 离线兜底页"就够了,不用费劲做安装引导。 |
| 必须用系统能力(蓝牙外设、深度相机、后台常驻定位、App Store 分发) | 别硬上 PWA,老老实实做原生或 Tauri/RN,Web 在这些能力上有硬边界。 |
Web App Manifest:先让浏览器认识你
Manifest 是一个 JSON 文件,在 HTML 的 <head> 里用 <link rel="manifest"> 引入。名称、图标、启动地址、显示模式这四项缺失任意一个,浏览器就不会认为它"可安装"。
| 字段 | 作用与注意点 |
|---|---|
name / short_name | 全名(安装弹窗、启动画面用)和短名(桌面图标下显示,建议 12 字符内,中文 4~6 字)。 |
start_url | 点图标启动后打开的地址。带查询参数时要注意它会影响这个 PWA 的"独立身份"。 |
display | standalone(隐藏地址栏,最常用)/ minimal-ui / fullscreen / browser。想"像 App"就写 standalone。 |
icons | 至少要 192×192 和 512×512 两个 PNG。再加一张 purpose: "maskable" 的图标,否则在 Android 上会被裁成奇怪形状。 |
theme_color / background_color | 前者影响系统状态栏颜色,后者是启动画面的底色(尽量和首屏背景一致,避免闪白)。 |
id | 应用唯一标识。不写时浏览器用 start_url 推断,改了 start_url 会让用户"变成装了两个 App",建议显式写。 |
shortcuts / screenshots | 长按图标弹出的快捷入口;以及安装弹窗里的预览截图(部分平台会展示)。 |
Service Worker:生命周期一定要先搞懂
Service Worker(下称 SW)是新手中最容易"改了没生效、以为代码写错了"的地方。原因几乎都是没搞懂它的生命周期——它是一个有自己生命周期的独立线程,不是页面的一部分。
论SW 的一生:注册 → 安装 → 等待 → 激活 → 拦截
① 注册:页面 JS 里 navigator.serviceWorker.register("/sw.js")。作用域默认是 sw.js 所在目录,所以想管全站就把 sw.js 放根目录。
② 安装(install):首次注册或 sw.js 内容有变化时触发。这里通常做预缓存(把静态资源一次性下载进 Cache Storage)。安装失败(比如某个资源 404)SW 直接作废。
③ 等待(waiting):这是最容易踩的坑。如果页面已经有一个旧 SW 在控制,新 SW 装好后会停在 waiting 状态不接管,直到所有受控页面都关闭。你改了代码、刷新页面,看到的还是旧逻辑,就是这个原因。
④ 激活(activate):接管控制权。这里适合清理旧版本缓存(否则缓存只会越堆越多)。
⑤ 拦截(fetch):之后页面的每一次请求(同源 + 被允许的跨源)都会先经过 SW 的 fetch 事件,由你决定返回缓存还是走网络。
sw.js:预缓存 + 按请求类型分策略(用 Workbox,别手搓)
| 策略 | 行为 | 用在什么资源上 |
|---|---|---|
| Cache First | 先查缓存,有就直接返回,没有才走网络。 | 带内容哈希的静态资源(JS/CSS/图片/字体)——它们永远不会变,是最安全的用法。 |
| Network First | 先走网络,成功就更新缓存;失败(超时/断网)用缓存兜底。 | 接口数据、用户内容、HTML 导航。要"尽量新鲜,但不能白屏"。 |
| Stale-While-Revalidate | 立刻返回缓存(快),同时在后台发请求更新缓存(下次是新的)。 | 更新不紧急但想要快的东西:头像、公共配置、排行榜。 |
| Network Only | 一律走网络,不缓存。 | 支付、下单、登录等写操作——绝对不能返回缓存结果。 |
Vite 项目里怎么落地:vite-plugin-pwa
手写 SW + 手工维护缓存清单很容易出错(漏文件、忘了清旧缓存)。Vite 项目直接用 vite-plugin-pwa:它会自动生成 manifest、把构建产物注入预缓存清单、生成 SW 文件。
坑一:开发环境就注册 SW,改了代码永远看不到效果。SW 缓存了旧资源,你刷一百遍还是旧的。规矩是:只在生产构建里注册(import.meta.env.PROD);调试时用 DevTools 的 Service Workers 面板勾上 "Update on reload"、或直接点 "Unregister"。
坑二:把接口响应也预缓存进 precache。预缓存是安装时一次性写入、之后靠版本更新才换,把接口塞进去等于让用户永远看到第一天的数据。接口只能走运行时缓存(NetworkFirst / SWR)。
坑三:写了 SW 却不清理旧缓存。每次发版缓存名都换(Workbox 会自动带 hash),不做清理的话用户磁盘里会堆几百 MB。用 cleanupOutdatedCaches() 或自己在 activate 里删旧 cache。
坑四:把写操作也做成"离线可用"。用户断网时点了"提交订单",你以为成功了(其实进了缓存),其实没到服务器——这是会造成真实损失的 bug。写操作必须 Network Only,或者用 Background Sync 明确设计成"排队重试 + 明确提示",绝不能静默假成功。
坑五:改域名/路径后白屏。SW 的作用域、Cache Storage、Cache key 都和 URL 绑定,换域名、从根路径挪到子路径、改 base,都可能让旧 SW 去找不存在的资源。发版时留意:路径变更要引导用户"卸载旧版本"或调整 scope。
安装引导与消息推送
"可安装"是 PWA 最大的差异化能力,但浏览器不希望你随便弹安装提示(滥弹会被惩罚)。正确做法是:拦截浏览器的默认时机,自己挑"用户确实用得爽"的时候再引导。
- iOS 没有
beforeinstallprompt。它只支持用户手动"分享 → 添加到主屏幕"。所以 iOS 上要做的是一条图文引导(截图告诉用户点哪里),而不是等事件。 - 推送(Push)需要用户授权 + 后端 VAPID 密钥,而且推送权限请求要有明确理由,否则用户一律拒绝且很难挽回(拒绝了就只能去系统设置里改)。iOS 16.4 起 Safari 才支持 Web Push,且要求 PWA 先被添加到主屏幕。
怎么验证做对了
| 检查项 | 怎么做 |
|---|---|
| Manifest 是否合法 | DevTools → Application → Manifest:看有无报错、图标是否加载、名称/start_url 是否正确。注意新版 Lighthouse 已取消单独的 PWA 分类(安装性检查主要看 Application 面板),没看到那个分数不用慌。 |
| 可安装性 | Application → Manifest 里会出现 "Installability" 区块;地址栏右侧应出现安装图标。缺 manifest / 缺 192、512 图标 / 不是 HTTPS 都会导致不可安装。 |
| 离线是否真的可用 | DevTools → Network 面板勾 Offline,然后刷新页面。如果白屏,说明缓存策略没覆盖到关键资源或导航回退没配。 |
| SW 是否在跑 | Application → Service Workers:能看到状态(activated / waiting)、可手动 Update / Unregister,也有 "Bypass for network" 方便调试。 |
| 缓存内容是否合理 | Application → Cache Storage:确认清单里没有接口响应和用户隐私数据,体积没有异常膨胀。 |
① PWA 三根支柱:Manifest(可安装)+ Service Worker(可离线)+ HTTPS(前提),缺一根就不成立。
② Manifest 五个必需项:name/short_name、start_url、display、icons(192/512)、theme_color + background_color。
③ SW 生命周期:注册 → install(预缓存)→ waiting(最容易踩坑)→ activate(清旧缓存)→ fetch(拦截请求)。
④ 缓存策略按内容选:静态资源 Cache First、接口 Network First、头像配置 SWR、写操作 Network Only。
⑤ 落地用 vite-plugin-pwa + Workbox,只在生产注册,改路径/域名要留意旧 SW,写操作绝不做"静默假成功"。
1. 我改了 sw.js 并刷新页面,为什么新逻辑没生效?
看答案
因为新 SW 装好后停在 waiting 状态,必须等所有受控页面都关闭才会激活接管。三种解法:① 关闭所有该站点标签页再打开;② 在 sw.js 里 self.skipWaiting() 并在页面侧监听 controllerchange 后 reload;③ 调试期直接在 DevTools 的 Service Workers 面板点 Unregister 或勾 "Update on reload"。
2. 为什么 Service Worker 必须在 HTTPS 下才能用?localhost 为什么例外?
看答案
SW 能拦截并伪造该源下所有请求的响应,权限极高。如果允许在 HTTP 下使用,任何中间人(公共 WiFi、运营商劫持)都能注入一个 SW 长期接管页面,形成难以清除的持久化攻击。localhost 例外是因为流量不出本机、没有中间人,同时也方便开发调试。
3. 为什么静态资源用 Cache First,接口却要用 Network First?
看答案
因为两者的"变化频率"完全不同。带内容哈希的静态资源(app.a1b2c3.js)内容永不改变,新的内容必然是新文件名,所以缓存命中永远安全、也最快。接口返回的是业务数据,随时在变,Cache First 会让用户一直看到旧数据;用 Network First 保证"有网就新鲜、断网有兜底"。
4. 用户反馈"断网时点提交订单,界面显示成功,但后台没收到"。问题出在哪?
看答案
典型的"把写操作也做了离线缓存"。写请求命中了缓存策略(比如 SWR 或 Cache First),SW 直接返回了一个伪造的成功响应。正确做法是:写操作(POST/PUT/DELETE)一律 Network Only;如果确实要做离线提交,必须用 Background Sync 明确定义"排队 → 联网后重试 → 成功后通知用户",并且在离线时明确告诉用户"当前离线,操作已排队"。
5. 为什么 iOS 上 PWA 的安装引导要单独做?
看答案
因为 iOS Safari 不支持 beforeinstallprompt 事件,你没法用代码唤起原生安装弹窗,用户只能通过"分享 → 添加到主屏幕"手动完成。所以 iOS 上需要一条图文引导说明操作路径。同时 iOS 对 manifest 的支持历史上较晚,apple-touch-icon、apple-mobile-web-app-capable 这些 meta 仍然要保留。