到了周五下午,业务方又来了:这个预约页用户每天都得打开看进度,能不能像 App 一样加到桌面上?我脑子里第一反应是又要接打包流程或者单独维护一个混合应用。但冷静下来想了想,这个场景根本不用上架应用商店,渐进式 Web 应用(PWA)就能解决大部分问题。
所谓 PWA,核心就一句话:让普通网页获得安装、离线、独立窗口这些类 App 能力。它不需要你推翻现有前端技术栈,也不需要重新学一套移动端框架,只要补上 Web App Manifest 和 Service Worker 这两个文件,配合正确的 HTTPS 环境,浏览器就会把这个页面当成一个可安装的应用来对待。
这篇文章我是拿自己一个真实场景来做复盘:一个内容型的预约进度页,流量入口分散、用户重复打开、网络环境不稳定,最后用 PWA 做了改造。文章会把从零配置 manifest、注册 Service Worker、设计离线缓存策略,到调试安装入口的完整流程全部拆开讲,重点写那些文档里看不到的坑。适合正在做 H5、活动页、门户站点的前端开发者,也适合想评估“到底要不要给项目做 PWA”的产品和技术负责人。读完之后,你至少能回答一个问题:这个网页适不适合做 PWA,如果适合,第一步该干什么。
1. 项目背景:为什么网页需要“可安装”
1.1 需求来源:一个预约页的尴尬
手里这个项目是一个活动预约进度页,一开始就是个普通 H5:用户从短信、聊天记录、浏览器收藏夹点进来,输入预约编号,查看当前排到哪了。业务方发现一个很现实的问题,用户不是只来一次,而是每天甚至每小时都会来看一眼进度,但入口太浅,每次都要翻聊天记录,或者重新搜索。结果就是重复访问率低,用户嫌麻烦,运营数据也不好看。
业务方提需求的时候,第一版方案是做个 App。可这个页面本身技术含量不高,为了一个查询功能拉一个原生应用,要接入打包、签名、审核、版本更新,成本完全不成比例。第二个方案是在现有 App 里做个内嵌页,但当时这个业务根本不在主 App 里,用户比例也不高,为了一个小功能让用户下载一个几十兆的 App,转化率不用想都知道会很难看。
后来我重新梳理了用户的行为模型:高频、轻量、单页面、对实时性要求中等,这不就是 PWA 最舒服的区间吗?PWA 不用经过应用商店,用户只需要在浏览器里点一次“安装”,就能在桌面或者手机主屏上生成一个图标入口;下次打开就是独立窗口,不再是一堆浏览器工具栏围着的内容卡片。对于这种功能单一、重复访问的页面,PWA 的性价比几乎是无敌的。
1.2 PWA解决的真实问题
PWA 不是哪一家公司提出的全新黑科技,它是由一系列 Web 能力组合出来的技术方案。在这套方案里,每个能力解决一个具体问题:
- 安装问题。普通网页的入口是收藏夹或地址栏,用户没有“安装”的概念。PWA 通过 manifest 文件向浏览器声明自己的应用身份,满足条件后浏览器会让你把它放到桌面,变成一个独立的图标。这个图标启动的是你的页面,但体验上更接近原生应用。
- 离线问题。网络抖动、电梯里信号弱、地铁里没有网,这些场景下原生 App 还能打开骨架,普通网页直接给你白屏。Service Worker 可以把页面结构、样式、脚本甚至部分数据预先缓存,下次访问时即使断网也能展示内容。
- 入口和沉浸感问题。普通网页打开后浏览器地址栏、标签栏、底部导航都还在,用户一眼就感觉“这是个网页”。PWA 支持 standalone 模式,启动后浏览器外壳会被隐藏,页面占据整个屏幕,视觉上和一个独立应用没有区别。
我自己的体会是,对很多业务来说,PWA 最有价值的不是“离线”,而是“安装”。能装到桌面上,就相当于在你和用户之间多留了一个低成本常驻入口。很多团队看着离线缓存两眼放光,但实际上真正拯救留存的是那个图标。
1.3 哪些业务适合,哪些不适合
任何技术都有边界,PWA 不是万能解药。我给自己定了一个判断框架,接到需求先过一遍:
适合做 PWA 的业务,通常有几个特征:功能相对聚焦,不是动辄二三十个模块的大型应用;用户重复访问率高,比如签到、预约、查物流、看文档、记账、拼团;对网络环境不信任,经常在弱网或离线状态下使用;不需要太多系统级能力,或者这些能力用 Web API 已经能覆盖。
不适合的情况也很明显:如果你的页面强依赖后台推送通知、蓝牙、NFC、后台保活、本地文件系统这些能力,PWA 目前还做不到原生应用那么顺手。有些业务对新增内容要求极高,比如实时行情、在线协作,如果缓存策略设计不好,用户看到的是舊数据,反而会产生严重投诉。这种场景不是不能做 PWA,而是你得把网络优先策略做得非常细致,复杂度会上来。
所以接到需求先别急着写代码,先回答三个问题:用户会不会反复打开?没有网络的时候页面还有没有价值?业务能不能接受一个“降级版但可用”的体验?都回答了,再动手不迟。
2. 核心技术拆解:可安装三件套
2.1 Manifest:应用的“身份证”
PWA 的第一个核心是 Web App Manifest,一个 JSON 文件。它干的事情特别朴素:向浏览器自我介绍,告诉自己叫什么名字、用什么图标、启动之后要不要隐藏浏览器边框、主题色是什么。
你可以把 manifest 理解成应用的名片或者身份证。浏览器在决定“这个页面能不能安装”的时候,会先读这张名片,看它有没有名字,有没有合适尺寸的图标,启动地址是什么。如果简历写得不清不楚,浏览器理都不理你;如果填得规范,浏览器不仅允许安装,还能在应用列表、系统分享菜单、启动屏这些地方自动读取里面的信息。
Manifest 本身不提供任何离线能力,它只负责“被识别为应用”这件事。所以我就把它理解为整个 PWA 的配置中心和信息源。很多新手一上来就折腾 Service Worker,结果发现页面怎么都触发不了安装按钮,排查半天才发现 manifest 里图标路径 404 了。基础文件都没伺候好,上层能力全是空中楼阁。
2.2 Service Worker:应用的“离线服务员”
Service Worker 是一个独立于页面的 JavaScript 脚本,它运行在单独的线程里,不走普通页面的 DOM 流程。页面的主线程负责渲染和交互,Service Worker 则像一个站在网络出入口的服务员:你发出的每个请求都要经过它,它可以决定直接返回缓存,还是放行去网络,或者把网络结果缓存下来再返回。
这个服务员有两个关键特点。第一,它能在浏览器后台运行,即使页面已经被用户关掉,下一次用户从桌面图标打开时,Service Worker 依然可以在请求发出之前截获请求并返回缓存内容。第二,它必须注册在 HTTPS 环境下,因为它能控制网络请求,如果在普通 HTTP 环境下运行,等于把大门钥匙交给了一个来历不明的脚本,这是完全不可接受的。本地开发时 localhost 是例外,可以不加证书调试,但一上线就必须走 HTTPS。
Service Worker 的事件模型很集中,主要就三个:install、activate、fetch。install 用于第一次安装时缓存关键资源;activate 用于新旧版本切换时清理旧缓存;fetch 用于拦截每一次网络请求,决定缓存和网络的分配方式。把这三个事件理解透了,PWA 的离线能力就掌握了一大半。
2.3 安全环境与图标标准
可安装还有一个硬性前置条件:HTTPS。原因我在上面说了,Service Worker 有能力替换网络请求内容,如果允许任意网站通过明文 HTTP 注册 Service Worker,中间人攻击就能悄无声息地篡改页面。所以主流浏览器都强制要求:Service Worker 只能在安全来源下注册。安全来源包括 HTTPS、localhost,以及少数浏览器认为安全的特殊地址。
图标标准也是一个容易被忽略的点。浏览器安装 PWA 的时候,需要从 manifest 的 icons 字段里挑一个合适的图标用来生成桌面图标、启动屏、任务栏图标。绝大多数浏览器约定俗成地要求至少提供 192x192 和 512x512 两种尺寸的 PNG 图标。如果你只丢一个 48x48 的缩略图进去,浏览器可能直接判定“无有效图标”,安装入口不出现。
更麻烦的是图标安全区。系统在渲染桌面图标的时候,图标不是完整显示的,周围的圆形或圆角区域会被裁掉。如果你把关键图形画到了边缘,装完之后图案可能被切掉一圈。所以现代 manifest 规范里增加了 purpose 字段标注 maskable 图标,设计时必须留出至少 20% 到 25% 的安全边距。这些细节虽然不直接影响安装,但直接影响安装后的观感,用户看到被裁掉的图标,第一反应就是“这是个半成品”。
3. Manifest 配置实操:让浏览器认识你的应用
3.1 最小可用配置:字段逐项拆解
我在那个预约页项目里用的 manifest 配置大概是这样的:
{ "name": "预约进度助手", "short_name": "预约助手", "start_url": "./index.html?source=pwa", "display": "standalone", "background_color": "#ffffff", "theme_color": "#1677ff", "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ] }这里每个字段都有讲究,我一个个说。
name 是应用的完整名称,一般十几二十个字没问题,会出现在启动屏、应用管理器里。short_name 是短名,占位有限,会出现在桌面图标下方,一定要短,四个字以内最佳,不然桌面图标底部会显示一串省略号。
start_url 是用户点桌面图标后打开的地址。这里强烈建议不要只写一个空字符串,而是加上一个参数标记来源,比如?source=pwa。这样你做数据分析时,能明确区分哪些访问来自桌面图标,哪些来自普通链接。有一点注意:start_url 必须是同源地址,不能跳去别的域名。
display 控制安装后的窗口外壳。standalone 表示完全独立窗口,不显示浏览器的地址栏和标签栏,这也是 PWA 最接近原生应用的模式。minimal-ui 会保留一部分浏览器控件,但相对精简。browser 就是普通浏览器模式。对于希望“像应用”的业务,直接选 standalone 就行。
theme_color 会影响浏览器工具栏、任务栏、系统栏的颜色,background_color 会作为启动屏的背景色。这两个颜色最好和你的页面主色保持一致,否则用户点图标后会先闪一下色块,再淡入页面,视觉上会有明显的割裂感。
3.2 图标处理:一张图怎么变成全家桶
图标的坑我在几个项目里反复踩过。一开始我也偷懒,只放一张 192 的图标,结果在部分设备的安装条件校验里直接被判失败。后来稳定下来的做法是:先让设计出一张 1024x1024 或者至少 512x512 的源文件,注意图形主体居中,四周留出安全边距,然后用图像处理脚本批量导出。
我通常产出四个文件:
- icon-192.png,用于安装条件校验和部分系统小图标场景。
- icon-512.png,用于应用列表和大部分安装场景。
- icon-maskable-512.png,同样是 512 尺寸,但把背景铺满整个方形,只保留中心安全区内的图形,供系统做圆形裁切。
- favicon 和各平台需要的其他尺寸由构建工具自动生成。
导出之后一定要做一次真实环境加载验证。很多开发者的图标是本地文件,一上线路径就 404,或者图片格式被压缩成了 WebP,浏览器不认。最靠谱的验证方式是打开浏览器开发者工具,在 Network 面板里看 manifest 请求和图标请求的响应状态码。如果图标请求是 404 或者 MIME 类型是 text/html,那基本就废了,安装按钮永远等不到。
3.3 引用方式与调试
manifest 文件需要在 HTML 头部用 link 标签引用:
<link rel="manifest" href="/manifest.json">引用路径我建议用站点的绝对根路径,比如/manifest.json,而不是相对路径./manifest.json。因为 manifest 文件里的图标、start_url 在很多浏览器里是相对于 manifest 文件的路径解析的,如果你把 manifest 放在某个子目录,图标路径又写成相对路径,很容易出现解析到错误地址的情况。统一使用根路径,能减少一半的路径坑。
文件后缀和响应头也别忽视。服务器返回 manifest.json 时,Content-Type 建议是application/manifest+json,有些服务器默认当成application/json也能跑通,但个别实现会比较严格。如果你是用静态服务器托管,最好显式配一下 MIME 类型,省得在别的环境部署时莫名其妙报错。
调试的时候,开发者工具里专门有 Application 或类似的面板,能看到 manifest 的解析结果。它会列出你填了哪些字段、缺了哪些字段、图标是否有效。一旦看到某个必填项被标红或者字段不匹配,先按那里给出的提示修,比自己瞎猜快得多。项目上线前,我还会用浏览器的“抓包”功能模拟一次完整的站点访问,确认 manifest 这个请求真的出去了、返回了,而不是被中间层的服务端渲染吞掉了。
4. Service Worker 注册与生命周期
4.1 注册脚本:放主线程与怎么选路径
Service Worker 不能自动生效,需要页面里的 JavaScript 主动去注册。注册代码非常简单:
if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker .register('/sw.js') .then((registration) => { console.log('Service Worker registered:', registration.scope); }) .catch((error) => { console.error('Service Worker registration failed:', error); }); }); }有几个细节需要解释。
第一,if ('serviceWorker' in navigator)是能力检测,老旧浏览器不支持的时候就不要继续了,页面照常工作,只是没有离线能力,这叫渐进增强。第二,放在 window 的 load 事件里注册,是因为我不希望 Service Worker 的注册行为抢在首屏渲染之前。虽然注册本身开销不大,但把注册逻辑放在用户已经看到主要内容之后,体验更稳。第三,register 的路径决定了 Service Worker 的作用范围。
Service Worker 的 scope 是基于脚本路径的。如果你把 sw.js 放在/static/service-worker.js,那么它的默认作用范围就是/static/,这意味着它只能拦截/static/下面的请求,页面在根路径/上时根本控制不了。想让它控制整个站点,最简单的方式就是把 sw.js 放在站点根目录,或者显式传一个 scope 参数,比如:
navigator.serviceWorker.register('/static/sw.js', { scope: '/', });显式指定 scope 能解决文件目录结构杂乱的问题,但我自己的经验是能放根目录就放根目录,少一个参数少一个坑。scope 一旦设置错,页面从桌面图标启动时,Service Worker 压根不接管,离线方案等于白做。
4.2 生命周期与事件:install、activate、fetch
Service Worker 从注册到真正接管页面,会经历一个生命周期。我在项目里实际使用的骨架脚本差不多是下面这样:
const CACHE_NAME = 'app-shell-v1'; const CORE_ASSETS = ['/', '/index.html', '/styles/app.css', '/js/app.js']; self.addEventListener('install', (event) => { event.waitUntil( caches .open(CACHE_NAME) .then((cache) => cache.addAll(CORE_ASSETS)) .then(() => 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)) ) ) .then(() => self.clients.claim()) ); });install 是第一次安装时触发的事件。我在这个事件里把应用外壳需要的核心资源一次性写入缓存。为什么用 event.waitUntil?因为它可以让浏览器知道,install 事件还没处理完,Service Worker 先别急着进入激活状态。如果 install 过程中资源加载失败,整个安装就会被标记为失败,浏览器会在下次访问时重新尝试。
self.skipWaiting()是我比较喜欢加的一步。默认情况下,新版 Service Worker 安装完成后并不会立刻生效,而是要等所有旧页面关闭之后才接管。对于不需要用户手动关闭十几个标签页的应用来说,这个默认行为太拖沓了。skipWaiting 可以让新版 Service Worker 跳过等待阶段,直接进入 activate。
activate 是激活时触发的事件。这时候最要紧的是清理旧缓存。如果旧版缓存没清掉,你的缓存空间会被一堆过期资源占满,用户设备上可能出现“旧资源还在但新资源也已写入”的混乱状态。activate 里按 CACHE_NAME 区分新旧版本,把不属于当前版本的缓存全部删掉,老版本残余才不会被 fetch 事件误用。
self.clients.claim()也是我建议加的。它让刚激活的 Service Worker 立刻接管所有已经打开的页面,而不是等到下一轮导航才生效。这两行代码几乎是成对出现的,不加的话,很多开发者会疑惑:明明 Service Worker 已经更新了,为什么刷新页面看到的内容还是旧的。
4.3 更新机制:为什么改了 Service Worker 不生效
Service Worker 文件本身是一个普通 JavaScript 文件,浏览器会定期或者每次导航时去对比这个文件的字节内容。只要文件有任何变化,浏览器就看作一个新版本,然后走一遍 install、activate 流程。
但这里有个常见的误解:你以为改了sw.js里的缓存版本号,旧缓存就会自动被清理。不是的。如果新版 sw.js 的 install 事件里还是用同一个缓存名app-shell-v1,并且没有把旧版资源清理,那旧缓存依然会存在,fetch 事件可能还是从旧缓存里取数据。所以我在更新上线时一定会做两件事:一是修改 sw.js 文件内容,哪怕只是一个注释变化,也要让浏览器感知到新版本;二是同步修改 CACHE_NAME 的版本号,并保证 activate 里有删除旧缓存名的逻辑。
另外一个默认限制是,浏览器对 Service Worker 的更新检查不是实时轮询的,一般会在页面导航时去后台比对一次,如果比对成功就进入安装序列,但真正接管页面可能还要等到 skipWaiting 和 clients.claim 执行完。如果你发布了一个新版本,用户隔了很久再打开,看到的不一定是新内容,这不能算 bug,而是 Service Worker 的工作原理。自己在测试环境里想验证新版本能不能生效,最简单的方式是在开发者工具里直接点“Update”或者“Unregister”,清掉旧状态再来一遍。
5. 离线缓存策略:PWA 脱胎换骨的关键
5.1 三种常用策略与选择
Service Worker 注册成功只是开始,怎么缓存才是真正决定用户体验的部分。我在实际落地时基本上用的是三种策略的组合,而不是一种策略走天下。
先看一个表格:
| 策略 | 核心逻辑 | 适用场景 | 风险 |
|---|---|---|---|
| 缓存优先 | 有缓存直接返回,没缓存再走网络 | 版本号固定的静态资源、图片、样式、脚本 | 内容更新时用户可能看到旧版本 |
| 网络优先 | 先尝试网络,失败才用缓存 | HTML 页面、需要实时性的数据接口 | 弱网环境下等待时间变长 |
| 存旧再更新 | 先返回缓存,同时后台发起网络请求更新缓存 | 列表数据、新闻列表、预约进度这类“晚一点更新也问题不大”的内容 | 首屏可能短暂显示旧数据 |
这三种策略不是互相排斥的,我通常会同时用:对带哈希的静态资源用缓存优先,对 HTML 页面用网络优先,对预约进度这类数据接口用存旧再更新。
5.2 应用壳缓存:把首次打开的资源放到核心清单
应用壳这个概念来自原生应用的设计思路。原生应用启动后,最先渲染出来的是外壳:导航栏、框架、布局,然后再去请求数据填充内容。PWA 的离线策略完全可以照搬这个思路。
我那个预约页,首次访问时就把以下资源放进了 CORE_ASSETS:站点的 HTML、全局样式、主脚本、检索页面需要用到的图标和字体。这部分资源被称作“应用外壳”。外壳缓存好之后,用户从桌面上点开图标,即使设备处于飞行模式,页面框架也能秒开,再加载数据时提示用户网络连接失败,或者展示上一次缓存下来的进度信息。
外壳资源的缓存务必放在 install 事件里。install 只会执行一次,如果你等到 fetch 事件里去逐个缓存,第一次访问时用户仍然会经历一段没有缓存的空窗期,如果网络差,外壳资源可能一直没机会被缓存。壳资源都没有,离线就无从谈起。
5.3 缓存版本与删除旧版:不要盲目清空
我踩过一个坑:一开始为了图方便,在 activate 事件里直接caches.delete('my-cache'),结果每次 Service Worker 一更新,就把用户本地缓存清空,所有资源只能从网络重新拉。这样确实不会出现旧资源,但也失去了 PWA 的意义。
正确的做法是按版本管理缓存,并保留一个清理逻辑。上面那个骨架里的代码就是标准写法:每次缓存名变了,就把旧名字的缓存删掉。你需要保证的是:每次发布新版本时,缓存名一定跟着变。我习惯把缓存名定义为app-shell-v1、app-shell-v2这种递进版本号,和前端构建产物一一对应。这样即使某个版本发布出问题,也能快速回溯到旧缓存看差异。
5.4 一个可落地的 fetch 拦截逻辑
在项目里,我用了一个简化但够用的 fetch 策略组合。先定义一个基础逻辑,不同路由走不同的分支:
self.addEventListener('fetch', (event) => { const { request } = event; if (request.method !== 'GET') { return; } const url = new URL(request.url); if (url.origin !== location.origin) { return; } if (request.mode === 'navigate') { event.respondWith( fetch(request) .then((response) => { const copy = response.clone(); caches.open('pages-v1').then((cache) => cache.put(request, copy)); return response; }) .catch(() => caches.match('/index.html')) ); return; } event.respondWith( caches.match(request).then((cached) => { const networkPromise = fetch(request) .then((response) => { if (response && response.ok) { const copy = response.clone(); caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); } return response; }) .catch(() => cached); return cached || networkPromise; }) ); });这段代码做了三件事。第一,非 GET 请求直接放行,不缓存也不拦截,避免把 POST 接口塞进 Cache Storage。第二,页面导航走网络优先,保证用户每次打开看到的是最新页面;如果网络挂了,就 fallback 到已经缓存的根页面,不至于白屏。第三,静态资源走缓存优先加后台更新,响应速度快,并且下一次访问时就能用到新缓存。
需要说明的是,这只是演示简化版的思路。生产环境里你还要处理跨域请求、Range 请求、媒体文件缓存、Cookie 凭证、鉴权接口这些边界问题。但核心策略思想不会变:页面导航要最新,静态资源要快,数据请求要按业务容忍度去折中。
6. 让 PWA 真正“可安装”
6.1 安装条件清单:浏览器到底看什么
很多人写完 manifest 和 Service Worker 之后,发现页面上没有出现安装按钮,就以为 PWA 技术有问题。其实浏览器判断“能不能安装”有一套严格条件,不是说你注册了就立刻弹窗。按我实践经验,条件大概有这么几条:
- 页面必须在 HTTPS 或 localhost 环境。
- 页面必须有 manifest,且包含 name 或 short_name、start_url、display、icons 这些关键字段。
- 必须至少提供一个 192x192 的图标,并且图标能被正确加载。
- 页面必须注册了 Service Worker,且 Service Worker 中包含了 fetch 事件处理器。
- 页面没有被标记为“已安装”或“不适合安装”。
有些浏览器还会要求用户对页面有一定的访问频次和使用时间,避免刚打开一次就弹安装提示骚扰用户。所以你在本地测试时,最好使用“重新检测”类的功能强制刷新安装条件,否则可能因为历史记录太少而迟迟等不到安装事件。
6.2 自定义安装按钮与 beforeinstallprompt
默认情况下,浏览器会自己决定什么时候弹出安装提示,这对体验来说不可控。更好的做法是把安装入口藏在自己的按钮里。浏览器为此提供了一个事件:beforeinstallprompt。
我的代码是这样写的:
let deferredPrompt; window.addEventListener('beforeinstallprompt', (event) => { event.preventDefault(); deferredPrompt = event; document.querySelector('#install-btn').hidden = false; }); 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; document.querySelector('#install-btn').hidden = true; });有几个点要说清楚。
beforeinstallprompt 事件触发的时机是浏览器已经完成了安装条件校验,并且判断该页面允许安装。此时你会拿到一个对象,可以用prompt()方法唤起系统的安装确认框。event.preventDefault()的作用是阻止浏览器默认自动弹出的安装提示,改用你自己的按钮来控制触发时机。
用户点击自定义安装按钮后,deferredPrompt.prompt()弹出系统安装框,用户确认后,应用就会安装到桌面。userChoice这个 Promise 会告诉你用户到底是点了确定还是取消,你可以根据结果做埋点或者引导后续操作。还需要把 deferredPrompt 置空,防止用户再次点击时重复唤起已经失效的安装流程。
一个容易踩的坑是事件时机。beforeinstallprompt 可能出现在页面加载完成之前,也可能出现在加载完成之后,如果你的按钮 DOM 还没渲染就绑定事件,会丢掉这个事件。稳妥的做法是把监听器在脚本最开始就绑定好,按钮先隐藏着,等事件来了再显示。
6.3 桌面端与移动端的差异
PWA 到了桌面端和移动端的表现并不完全一样。桌面端浏览器对安装的支持比较积极,安装之后会生成一个独立的桌面快捷方式,点开之后就是一个独立进程的窗口。移动端的情况更复杂,不同浏览器、不同系统版本对“添加到主屏幕”的支持程度都不一样。
如果你的目标用户主要使用移动端,我建议你不要只依赖 beforeinstallprompt。它虽然在很多环境里能触发,但在某些移动端浏览器上可能迟迟不出现。这时候需要做一个备选方案:检测如果页面没有被安装,且安装事件没有触发,就在页面上放一个“添加到主屏幕”的手动引导浮层,告诉用户通过浏览器菜单里的相应操作把页面添加到主屏。
我在预约页里就是这样的双轨方案。能触发 beforeinstallprompt 时,显示自定义安装按钮,走系统安装流程;触发不了时,页脚常驻一个“添加图标到桌面”的入口,点击之后展示三张截屏式的引导弹窗。这样既尊重了系统机制,又给了用户一条不依赖特定浏览器的落地路径。用户体验这件事,永远不要押注在某一个浏览器厂商的默认行为上。
7. 常见问题与排查技巧实录
7.1 页面为什么一直不能安装
遇到安装问题,我一般按顺序排查四件事。第一,先确认当前协议是不是 HTTPS,浏览器地址栏里有没有安全锁。第二,打开开发者工具的 Application 面板,看 manifest 是否成功解析,图标请求有没有 404。第三,检查 Service Worker 是否已经注册,里面是否包含 fetch 事件处理器。第四,如果所有条件都满足,但安装事件一直不触发,可以试试清除站点数据、关闭所有标签页后重新访问一次,或者换一个从未安装过该站点的浏览器环境。
还有一个容易被忽略的点:如果你在同一个浏览器里已经安装过这个 PWA,那么 beforeinstallprompt 事件不会再触发。不是代码出问题,而是系统认为你已经装过了,不需要再提示。测试前一定要先在桌面上卸载掉旧的应用,或者在浏览器设置里移除站点数据。
7.2 Service Worker 一直处于 waiting 状态
Service Worker 更新后一直不激活,是新手最容易卡住的问题。原因我之前已经提过:浏览器默认会等所有被旧 Service Worker 控制的页面关闭后,才让新版本接管。如果你在开发者工具里看到新版 Service Worker 状态一直停在 waiting,就说明它还没获得接管权。
解决办法有三个方向。第一个是在安装事件里直接调用 self.skipWaiting(),让新版跳过等待;第二个是在激活事件里调用 self.clients.claim(),让新版立刻控制现有页面;第三个是测试时手动在开发者工具里点击 Skip Waiting 按钮,先看看新版行为是否符合预期。这三个办法可以叠加使用,但我建议生产代码里至少保留前两个,否则每次更新都要等用户全部关掉页面,版本发布效率会变得很低。
7.3 离线状态下刷新白屏
离线白屏的根源大多数是页面导航没有被 Service Worker 接管,或者 HTML 本身没有被缓存。我遇到过两种情况。第一种,Service Worker 的作用范围太小,比如注册在/sub/目录下,而页面在根路径/上,打开根路径时它管不到。第二种,fetch 事件里只缓存了静态资源,没有对 navigate 请求做处理,离线时 HTML 请求直接失败,页面自然白屏。
所以我在 fetch 拦截逻辑里专门加了一个分支:request.mode 为 navigate 的请求先走网络,网络失败就回退到缓存的首页模板。这个首页模板一定要在 install 阶段就写进 CORE_ASSETS,否则第一次访问结束后如果没有把页面本身缓存下来,离线回退就没有可用的兜底资源。
7.4 缓存更新了但用户还是看旧内容
这个问题的根源几乎都在于“缓存名没有跟着版本走”。我给自己的规范是,每次前端资源有变动,构建产物里的文件名会带上新的哈希,同时在 Service Worker 文件里同步修改 CACHE_NAME 的版本号。两者只要有一处没改,就可能出现页面加载了新资源,但 Service Worker 仍然用旧资源做回退的情况。
还有一个细节:Service Worker 文件的 HTTP 缓存策略也很关键。很多服务器默认对 js 文件设置了长缓存,浏览器可能在很长一段时间内根本不重新请求 sw.js,导致新版 Service Worker 永远不被感知。部署时一定要给 sw.js 设置Cache-Control: no-cache,让它每次都去服务器比对是否内容有变化。这个配置看似不起眼,实际线上发版本时能救你一命。
7.5 常用排障工具和面板
排障时我几乎不靠直觉,直接用浏览器开发者工具的几个固定入口。第一个是 Application 面板,里面有 Manifest 和 Service Worker 两块。Manifest 部分会显示解析状态,Service Worker 部分可以手动 Unregister、Update、Offline。第二个是 Network 面板,勾选离线模式后刷新页面,看哪些请求被 Service Worker 返回了缓存,哪些请求直接失败。第三个是 Storage 面板,需要彻底清状态时可以直接清除站点缓存、Service Worker、IndexedDB。
实际测试离线体验时,我习惯先把页面完整加载一遍,然后切到 Network 面板的 Offline 模式,再点击桌面图标重新打开应用。注意一定要从桌面图标进入,而不是从浏览器地址栏进入,因为从图标进入才走的是安装后的启动路径,和用户真实使用场景一致。
7.6 问题速查表
| 症状 | 大概率原因 | 快速处理 |
|---|---|---|
| 安装按钮不出现 | 未满足 HTTPS、manifest 或图标条件 | 检查安全来源、manifest 解析、192 图标 |
| manifest 请求 404 | 路径写错或服务器未配置 MIME | 改用根路径/manifest.json,配 MIME |
| 图标被裁掉 | 没有 maskable 图标或安全区不足 | 增加purpose: maskable的 512 图标 |
| Service Worker 一直 waiting | 缺少 skipWaiting / clients.claim | install 中加 skipWaiting,activate 中加 claim |
| 离线白屏 | 没有缓存 HTML 或导航未接管 | install 缓存index.html,fetch 对 navigate 做回退 |
| 更新后仍是旧页面 | 缓存名未变、sw.js 被 HTTP 缓存 | 递增缓存名,给 sw.js 设置 no-cache |
| 测试时提示已安装 | 系统里已有旧安装记录 | 先卸载旧应用,再清站点数据 |
7.7 最后再分享一个我的习惯
做了几次 PWA 之后,我养成了一个固定习惯:任何一个新项目上线之前,我都会先把网站下到手机或电脑里,关掉网络,从桌面图标重新打开一次,然后把整个过程录下来发给业务方看。这个过程看起来简单,但能提前暴露很多工程问题。以前有次发布会前改了接口地址,CDN 上文件更新了,但用户端 Service Worker 还把旧接口和旧页面绑在一起,直接导致部分用户离线后看到了过期的预约状态,非常尴尬。
所以我后来把 Service Worker 的资源版本号和后台接口的版本号联动起来,接口一旦有不可兼容的变更,就把缓存名版本同步升一档,activate 时把旧数据一起清掉。这个动作不需要多高的技术含量,但它体现的是 PWA 改造里最容易忽略的一件事:离线缓存本质上是把服务端的版本管理扩展到了用户设备上,你需要的不是单纯的缓存机制,而是一套清晰的、能随业务同步演进的版本管理策略。
PWA 不会替代所有原生应用,但它能让一批本来“够不上做 App”的网页产品获得接近应用的使用体验。如果你手头正好有一个高频、轻量、需要常驻入口的网页项目,不妨按这篇文章的思路,先把 manifest 和 Service Worker 配起来,再根据自己业务的数据容忍度去调缓存策略。装到桌面上那一刻,你会觉得这些小配置都值了。