PWA 原理(Service Worker / Manifest / Cache Storage / 离线与安装机制)
PWA(Progressive Web App,渐进式 Web 应用)不是某个框架,而是一组让 Web「更像 App」的能力组合:可离线、可安装、可后台运行、体验更稳定。这篇文档用尽量少的篇幅把 PWA 的核心机制讲清楚,并给出可直接上手的示例与面试高频问答。
1. PWA 的“核心四件套”
- Service Worker:运行在浏览器后台的脚本,核心能力是拦截网络请求(
fetch事件)和处理后台事件(push/sync/message等)。 - Cache Storage(Cache API):专门缓存“请求/响应”的存储,常用于实现离线与更灵活的缓存策略。
- Manifest:告诉浏览器“这个 Web App 安装后长什么样、从哪启动、用什么图标”。
- 安装与离线机制:是否出现安装入口、离线可用程度、推送/后台同步权限等,都受浏览器策略与用户授权影响。
Manifest 管外观与入口,Service Worker 管拦截与后台,Cache Storage 管离线与加速。
2. 请求是如何被 Service Worker 接管的?
只要页面处于某个 Service Worker 的 scope 内,页面发起的网络请求就会先经过它(相当于“可编程的网络代理”):
理解这一点,你就能解释:
- 为什么 PWA 可以离线(缓存里有响应就能返回)。
- 为什么 PWA 更新“有延迟”(Service Worker 有独立生命周期与更新策略)。
- 为什么缓存策略写错会“永远加载旧资源”(缓存命中优先级太高)。
3. Web App Manifest:安装信息与“App 外壳”
Manifest 本质是一个 JSON(常见命名 manifest.webmanifest),通过 <link rel="manifest"> 声明:
<link rel="manifest" href="/manifest.webmanifest" />
3.1 最常用字段
| 字段 | 作用 | 例子/备注 |
|---|---|---|
name / short_name | 应用名称(桌面/启动器展示) | short_name 更短 |
start_url | 安装后启动入口 | 常带上 ?source=pwa |
scope | PWA 可“接管”的范围 | 必须与 start_url 同源且在范围内 |
display | 展示形态 | standalone / fullscreen / minimal-ui / browser |
icons | 图标数组 | 建议包含多尺寸 PNG |
theme_color / background_color | 主题/启动背景色 | 影响地址栏/启动屏 |
id | 应用 ID(部分浏览器使用) | 便于识别同一应用 |
3.2 一个可用的最小示例
{
"name": "PWA Demo",
"short_name": "Demo",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"theme_color": "#1677ff",
"background_color": "#ffffff",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
scope 与 Service Worker 的 scope 不是一回事- Manifest 的
scope:决定“安装后的应用允许访问/导航的范围”,更多是“应用边界”。 - Service Worker 的
scope:决定“哪些页面请求会被它拦截”。 两者最好都设置为一致的根路径(例如/),否则容易出现“装了但不接管”“接管了但应用边界不对”的怪问题。
4. Service Worker:生命周期、更新与缓存
4.1 特点与限制(面试必问)
- 无 DOM:不能直接操作页面 DOM;与页面通信靠
postMessage。 - 事件驱动:不常驻运行,没事件会被浏览器挂起,来事件再唤醒。
- 强安全要求:通常要求 HTTPS(
localhost例外)+ 同源策略约束。 - 可拦截网络:通过
fetch事件决定返回缓存/网络/兜底响应。
4.2 注册与 scope
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' });
});
}
- scope 的默认值:由
sw.js所在路径决定。想接管全站,通常把sw.js放到站点根路径(/sw.js)。 - 首次生效时机:注册成功并不等于立刻接管所有页面,往往需要刷新后才由
navigator.serviceWorker.controller接管。
4.3 生命周期(Install → Activate → Fetch)
对应到代码事件:
install:常用于预缓存(App Shell、离线页、核心静态资源)。activate:常用于清理旧缓存、接管页面(可配合clients.claim())。fetch:决定每个请求的响应(缓存策略的主战场)。
4.4 更新机制:为什么“改了代码但用户还在用旧版本”?
Service Worker 的更新是“后台悄悄发生”的:
- 浏览器发现
sw.js有变化 → 下载新版本 - 新 SW 触发
install,进入 waiting - 只有当旧 SW 控制的页面都关闭(或你主动接管),新 SW 才会
activate
如果你希望更快切换(有取舍):
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
skipWaiting() + clients.claim() 会让新 SW 更快接管,但也可能导致“页面仍在运行旧 JS,缓存却切换到新资源”的不一致问题。实际项目通常会配合“提示用户刷新”或“App Shell 版本协商”来做平滑升级。
4.5 Cache Storage(Cache API)怎么用:预缓存 + 运行时缓存
预缓存(precache):安装时把核心资源放进缓存(App Shell 思路)。
const CACHE_NAME = 'app-shell-v1';
const PRECACHE_URLS = ['/', '/offline.html', '/assets/app.css', '/assets/app.js'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)));
});
清理旧缓存:在 activate 做版本回收。
const KEEP_CACHES = new Set([CACHE_NAME]);
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => !KEEP_CACHES.has(key)).map((key) => caches.delete(key)))
)
);
});
运行时缓存(runtime cache):请求发生时再缓存(更适合图片、接口返回等动态内容)。
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request)
.then((response) => {
if (response && (response.ok || response.type === 'opaque')) {
const copy = response.clone();
event.waitUntil(caches.open('runtime-v1').then((cache) => cache.put(request, copy)));
}
return response;
})
.catch(() => (request.mode === 'navigate' ? caches.match('/offline.html') : undefined));
})
);
});
- HTTP 缓存由浏览器内置策略控制(
Cache-Control/ETag等),你很难“精细编排”。 - Cache Storage是可编程缓存,你决定存什么、何时更新、何时清理,适合离线与复杂缓存策略。
4.6 常见缓存策略(怎么选?)
| 策略 | 核心思路 | 适用场景 | 风险点 |
|---|---|---|---|
| Cache First | 先读缓存,没命中再上网 | 静态资源(JS/CSS/字体) | 容易“缓存永不过期” |
| Network First | 先上网,失败再读缓存 | API 数据、列表页 | 离线时延迟变大 |
| Stale-While-Revalidate | 先返回旧缓存,同时后台更新 | 图片/非关键数据 | 需要处理并发与更新 |
| Cache Only / Network Only | 只走缓存 / 只走网络 | 极端场景 | 体验不稳 |
实际项目建议直接使用 Workbox(成熟的策略与预缓存清单生成),减少手写 SW 的坑。
5. 离线与“可安装”:浏览器到底凭什么给你装?
5.1 离线(Offline)落地要点
离线不是“把全站都缓存”这么简单,通常分两层:
- App Shell(外壳)离线:确保页面骨架(HTML/CSS/JS)能打开,离线也能渲染基本 UI。
- 数据离线:缓存接口响应/本地数据库(IndexedDB),让部分内容也可浏览。
最常见的“保底体验”是:导航请求失败时返回 offline.html(见上面的 fetch 兜底)。
5.2 安装(Install)机制要点
浏览器是否提示安装,通常取决于(不同浏览器略有差异):
- 站点可通过 HTTPS 访问
- 有可用的 Manifest(图标、
start_url、display等) - 有 Service Worker,且能提供基本离线能力
- 用户有一定访问/交互行为(安装入口往往不是“第一次打开就弹”)
在部分浏览器(多见于 Chromium)里,可以用 beforeinstallprompt 自定义安装按钮:
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
});
async function promptInstall() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
await deferredPrompt.userChoice;
deferredPrompt = undefined;
}
安装提示、beforeinstallprompt 事件在不同浏览器/系统上支持差异较大(尤其移动端)。实际项目请以目标环境为准,并准备“引导用户添加到主屏幕”的兜底方案。
6. 安全与最佳实践(少踩坑)
- 只缓存 GET 请求;涉及鉴权/隐私数据的接口谨慎缓存(避免“登出后仍可离线读到敏感数据”)。
- 缓存要版本化(
app-shell-v1/v2),并在activate及时清理旧缓存。 - 给导航请求做离线兜底(
offline.html),避免离线白屏。 - 不要盲目
skipWaiting():更新要考虑“旧页面 + 新缓存”的一致性。 - 如果资源走构建产物(带 hash),优先做 cache-first;如果资源无 hash,优先走 network-first 或 SWR。
7. 调试与排查清单(DevTools)
在 Chrome DevTools 的 Application 面板里重点看:
- Service Workers:是否已注册、是否是
activated、是否勾选了 Update on reload - Cache Storage:缓存里到底存了什么、有没有清理旧版本
- Clear storage:一键清空,快速验证“首次安装”体验
- Network:切换 Offline,验证离线兜底是否生效
面试高频问答
Q1:PWA 是什么?核心技术栈有哪些?
答:PWA 是一组让 Web 具备“更像 App”体验的能力集合,核心是 Manifest + Service Worker + Cache Storage(再加上 HTTPS 与浏览器安装/权限策略)。目标是可靠(离线可用)、快速、可安装与可沉浸式使用。
Q2:Service Worker 和 Web Worker 有什么区别?
答:两者都运行在 Worker 线程、无 DOM,但 Service Worker 能拦截网络请求、拥有独立生命周期、可被多个页面共享,并能处理 push/sync 等后台事件;Web Worker 更偏向“页面内的计算线程”,跟随页面生命周期,不能拦截请求。
Q3:为什么 Service Worker 通常要求 HTTPS?
答:因为 Service Worker 能拦截并篡改网络请求/响应,本质是强权限能力。如果允许在不安全的 HTTP 环境下运行,会被中间人攻击轻易劫持,风险极高,所以浏览器要求 HTTPS(localhost 用于开发调试通常放行)。
Q4:Service Worker 的更新流程是怎样的?为什么会出现 waiting?
答:浏览器发现 sw.js 变化会下载新 SW 并 install,但为避免“新旧版本资源混用”,新 SW 通常会进入 waiting,等旧 SW 控制的页面都关闭后再 activate。这就是“更新延迟”的根源。
Q5:Cache Storage 和浏览器 HTTP 缓存有什么本质区别?
答:HTTP 缓存由浏览器根据响应头自动管理;Cache Storage 是可编程缓存,你可以在 Service Worker 里自定义“缓存哪些请求、何时更新、如何清理、离线兜底”等策略,更适合 PWA 离线与精细化控制。
Q6:什么是预缓存(precache)与运行时缓存(runtime cache)?
答:预缓存是在 install 阶段提前缓存核心资源(App Shell),确保可离线启动;运行时缓存是在 fetch 时按策略动态缓存(图片、接口响应等),更灵活但更容易踩策略坑。
Q7:常见缓存策略有哪些?怎么选?
答:常见策略有 Cache First、Network First、Stale-While-Revalidate。一般静态资源(带 hash)适合 Cache First;接口数据适合 Network First;图片/非关键数据适合 SWR。选择的关键是“是否允许用旧数据换速度”和“更新一致性要求”。
Q8:PWA 离线白屏通常是什么原因?
答:常见原因是:没有缓存 App Shell(HTML/核心 JS/CSS);fetch 兜底没处理 navigate 请求;缓存策略把入口 HTML 缓死导致版本不一致。解决思路是:预缓存外壳 + 导航失败返回离线页 + 入口 HTML 更偏向 network-first。
Q9:skipWaiting() 一定要用吗?有什么风险?
答:不一定。它能让新 SW 更快接管,但可能造成“页面运行旧代码、缓存切换到新资源”的不一致。更稳妥的做法是:检测到新 SW waiting 后提示用户刷新,或在应用层做版本协商。
Q10:为什么有时候浏览器不出现“安装”按钮?
答:通常是未满足安装条件(Manifest 不完整、无可控的 Service Worker、非 HTTPS、图标不合规、用户交互不足),或者目标浏览器本身不支持相关安装提示事件,需要做兼容与兜底引导。
Q11:Push、Notification、Background Sync 是什么关系?
答:Push 是“服务器主动推消息到浏览器”的通道;Notification 是“把消息展示给用户”的能力;Background Sync 是“网络恢复后再补发请求”的机制。它们常由 Service Worker 承接,但都受权限与平台支持影响。
Q12:PWA 的能力边界在哪里?
答:PWA 仍是 Web:受同源、安全沙箱、权限与平台策略限制;不同浏览器(尤其移动端)支持差异明显。工程上要做渐进增强:有能力就用,没有能力也能正常使用。