跳到主要内容

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
scopePWA 可“接管”的范围必须与 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
  • 事件驱动:不常驻运行,没事件会被浏览器挂起,来事件再唤醒。
  • 强安全要求:通常要求 HTTPSlocalhost 例外)+ 同源策略约束。
  • 可拦截网络:通过 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 的更新是“后台悄悄发生”的:

  1. 浏览器发现 sw.js 有变化 → 下载新版本
  2. 新 SW 触发 install,进入 waiting
  3. 只有当旧 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));
})
);
});
重要理解:Cache Storage ≠ HTTP 缓存
  • 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_urldisplay 等)
  • 有 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:受同源、安全沙箱、权限与平台策略限制;不同浏览器(尤其移动端)支持差异明显。工程上要做渐进增强:有能力就用,没有能力也能正常使用。