资讯动态

PWA 落地实战:用 Manifest 与 Service Worker 打造可安装的网页应用

发布时间:2026/10/21 23:36:25 来源:尧图企业网站定制
到了周五下午业务方又来了这个预约页用户每天都得打开看进度能不能像 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?sourcepwa, 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 是用户点桌面图标后打开的地址。这里强烈建议不要只写一个空字符串而是加上一个参数标记来源比如?sourcepwa。这样你做数据分析时能明确区分哪些访问来自桌面图标哪些来自普通链接。有一点注意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 relmanifest href/manifest.json引用路径我建议用站点的绝对根路径比如/manifest.json而不是相对路径./manifest.json。因为 manifest 文件里的图标、start_url 在很多浏览器里是相对于 manifest 文件的路径解析的如果你把 manifest 放在某个子目录图标路径又写成相对路径很容易出现解析到错误地址的情况。统一使用根路径能减少一半的路径坑。文件后缀和响应头也别忽视。服务器返回 manifest.json 时Content-Type 建议是application/manifestjson有些服务器默认当成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、fetchService 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.claiminstall 中加 skipWaitingactivate 中加 claim离线白屏没有缓存 HTML 或导航未接管install 缓存index.htmlfetch 对 navigate 做回退更新后仍是旧页面缓存名未变、sw.js 被 HTTP 缓存递增缓存名给 sw.js 设置 no-cache测试时提示已安装系统里已有旧安装记录先卸载旧应用再清站点数据7.7 最后再分享一个我的习惯做了几次 PWA 之后我养成了一个固定习惯任何一个新项目上线之前我都会先把网站下到手机或电脑里关掉网络从桌面图标重新打开一次然后把整个过程录下来发给业务方看。这个过程看起来简单但能提前暴露很多工程问题。以前有次发布会前改了接口地址CDN 上文件更新了但用户端 Service Worker 还把旧接口和旧页面绑在一起直接导致部分用户离线后看到了过期的预约状态非常尴尬。所以我后来把 Service Worker 的资源版本号和后台接口的版本号联动起来接口一旦有不可兼容的变更就把缓存名版本同步升一档activate 时把旧数据一起清掉。这个动作不需要多高的技术含量但它体现的是 PWA 改造里最容易忽略的一件事离线缓存本质上是把服务端的版本管理扩展到了用户设备上你需要的不是单纯的缓存机制而是一套清晰的、能随业务同步演进的版本管理策略。PWA 不会替代所有原生应用但它能让一批本来“够不上做 App”的网页产品获得接近应用的使用体验。如果你手头正好有一个高频、轻量、需要常驻入口的网页项目不妨按这篇文章的思路先把 manifest 和 Service Worker 配起来再根据自己业务的数据容忍度去调缓存策略。装到桌面上那一刻你会觉得这些小配置都值了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑