跳到主要内容

Service Worker 与 PWA

前端里一提到离线、预缓存、安装到桌面、后台同步,主线基本都会落到 Service WorkerPWA

这两件事经常被一起提,但并不是同一个概念:

  • Service Worker 是浏览器提供的一层可编程代理能力
  • PWA 是一组更完整的体验目标,通常会把 Service Workermanifest、离线能力、可安装性放在一起看

先把关系理顺

Service Worker 是什么

它是一段运行在浏览器后台的脚本,可以拦截页面发出的网络请求,并决定:

  • 直接走网络
  • 先查缓存
  • 网络失败后兜底
  • 在后台做同步、推送或更新

它不直接操作页面 DOM,也不和页面共享同一执行环境。

PWA 是什么

PWA,Progressive Web App,更像一套体验目标:

  • HTTPS
  • 可安装
  • 有图标、名称、启动页
  • 弱网或离线时仍然能工作一部分
  • 更接近原生 App 的访问方式

所以可以简单记成:

  • Service Worker 更偏底层能力
  • PWA 更偏最终产品体验

Service Worker 在解决什么

普通前端页面如果完全依赖网络,一旦进入这些场景,体验就会明显掉下来:

  • 地铁、电梯、弱网环境
  • 首屏要请求很多静态资源
  • 二次访问本可以更快,但还是反复走网络
  • 某些接口可以短期容忍旧数据,但页面还是完全阻塞

Service Worker 能补上的,通常是这些:

  • 静态资源预缓存
  • 离线兜底页
  • API 响应缓存
  • 更新时机控制
  • 后台同步
  • 推送通知

它为什么经常被说成“浏览器里的代理”

因为请求路径会变成:

  1. 页面发起请求
  2. Service Worker 决定怎么处理
  3. 再去网络或缓存拿结果
  4. 把结果返回给页面

这意味着它很适合接管:

  • HTML
  • CSS
  • JS
  • 图片
  • 字体
  • 部分 API 请求

但不适合把所有接口都一把抓进缓存。数据一致性要求高的接口,还是要谨慎。

生效前提

1. 必须是安全上下文

通常要求:

  • https://
  • 本地开发环境的 localhost

2. 有作用域概念

注册脚本的位置会影响它能控制的路径范围。

例如:

  • /sw.js 通常能控制整个站点
  • /app/sw.js 通常只能控制 /app/ 及其子路径

一个最小注册例子

if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('service worker registered', registration.scope)
} catch (error) {
console.error('service worker register failed', error)
}
})
}

生命周期先记住三段就够了

install

通常用来做预缓存。

activate

通常用来清理旧缓存、接管页面。

fetch

页面请求资源时,Service Worker 可以在这里决定响应策略。

一个最小例子:

const CACHE_NAME = 'app-shell-v1'
const APP_SHELL = ['/', '/index.html', '/styles.css', '/main.js']

self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)))
})

self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
),
)
})

self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached
return fetch(event.request)
}),
)
})

Cache Storage 和浏览器缓存不是一回事

这块很容易混。

浏览器 HTTP 缓存

更偏协议层,由:

  • Cache-Control
  • ETag
  • Last-Modified

这些响应头驱动。

Cache Storage

这是 Service Worker 常配套使用的可编程缓存空间。

前端可以决定:

  • 存什么
  • 什么时候删
  • 命中失败后怎么兜底

所以:

  • HTTP 缓存更偏浏览器内建规则
  • Cache Storage 更偏业务自定义策略

常见缓存策略

Cache First

先查缓存,没有再走网络。

适合:

  • logo
  • 图标
  • 字体
  • 版本化静态资源

Network First

先走网络,失败再回退缓存。

适合:

  • 新闻列表
  • 时间敏感但可接受旧数据的页面

Stale While Revalidate

先返回旧缓存,同时后台拉新数据,下次再更新。

适合:

  • 文章内容
  • 个人主页
  • 二次访问频繁的页面

Cache Only / Network Only

一般只在非常明确的场景下使用。

一个更实际的 Network First 例子

self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return

event.respondWith(
fetch(event.request)
.then(async (response) => {
const cache = await caches.open('runtime-v1')
cache.put(event.request, response.clone())
return response
})
.catch(async () => {
const cached = await caches.match(event.request)
return cached || caches.match('/offline.html')
}),
)
})

离线页通常怎么做

比较常见的做法是:

  • 预缓存一个 /offline.html
  • 网络请求失败时,导航请求统一回退到离线页
self.addEventListener('fetch', (event) => {
if (event.request.mode !== 'navigate') return

event.respondWith(
fetch(event.request).catch(() => caches.match('/offline.html')),
)
})

更新为什么经常让人困惑

Service Worker 最大的使用门槛之一,不是注册,而是更新时机。

常见现象:

  • 明明发了新版本,用户页面没刷新
  • 新旧资源混着跑
  • 更新了 SW 文件,但页面还是旧缓存

原因通常在这

新的 Service Worker 下载到本地之后,不一定立刻接管当前页面。浏览器通常会等旧页面全部关闭,再让新版本生效。

常见做法

  • 发版本时给静态资源带 hash
  • 用不同缓存名区分版本
  • 在页面里提示“发现新版本,刷新后生效”
  • 需要更激进时,再考虑 skipWaiting()clients.claim()
self.addEventListener('install', () => {
self.skipWaiting()
})

self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim())
})

这两句能让新版本更快接管,但也更容易让“旧页面 + 新缓存”一起出现,所以要配合版本化资源使用。

页面和 Service Worker 怎么通信

常见方式有:

  • postMessage
  • BroadcastChannel

如果你想单独看“浏览器多标签页怎么做状态同步、BroadcastChannellocalStorage/SharedWorker/Service Worker 怎么选”,更适合继续看 BroadcastChannel 与多标签页通信

例如页面通知 SW 主动清缓存、触发更新,或者 SW 通知页面“有新版本可刷新”。

PWA 还需要 manifest

如果只注册了 Service Worker,还不算一个完整的 PWA。

通常还需要 manifest.webmanifest,至少包括:

  • name
  • short_name
  • icons
  • start_url
  • display
  • theme_color
  • background_color

一个最小例子:

{
"name": "Torli Website",
"short_name": "Torli",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#111111",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

前端项目里更常见的接法

Vite

通常会配 vite-plugin-pwa

它能帮忙处理:

  • manifest
  • 自动生成或注入 Service Worker
  • 预缓存清单
  • 更新提示

Next.js

Next 本身不直接提供完整 PWA 方案,常见做法是配 next-pwa 之类的插件,或者自己接 Workbox。

Workbox 为什么经常被提到

因为手写 Service Worker 一开始很直观,但一旦进入:

  • precache manifest
  • runtime caching
  • background sync
  • route matching
  • 更新策略

Workbox 会比完全手写省很多心智成本。

Debug 时先看哪几处

Chrome DevTools 里最常用的是 Application 面板:

  • Service Workers
  • Cache Storage
  • Manifest

高频排查顺序通常是:

  1. Service Worker 是否注册成功
  2. scope 对不对
  3. 当前页面是否已受控
  4. Cache Storage 里到底缓存了什么
  5. 新版本有没有正确替换旧缓存
  6. 离线时命中的到底是哪条策略

常见误区

1. 把所有接口都缓存起来

如果接口数据对一致性要求高,这会把问题从“慢”变成“错”。

2. 不做缓存版本管理

缓存名不变,旧资源就很容易长期残留。

3. 忽略代理层缓存和浏览器缓存的叠加

问题可能不在 Service Worker 本身,而是在:

  • CDN
  • Nginx
  • HTTP 缓存

4. 开发环境忘记清 Service Worker

本地调试时,如果路径、缓存策略、scope 改过,旧 SW 很容易干扰当前结果。

更稳的落地顺序

  1. 先只做 manifest 和安装能力
  2. 再给静态资源做预缓存
  3. 再补离线页
  4. 再按页面类型增加 runtime caching
  5. 最后再考虑后台同步、推送、更新提示

和这组其他文章怎么配合看

  • 想先理解缓存基础:看 浏览器缓存
  • 想看浏览器端存储能力:看 浏览器存储
  • 想看请求层的实时能力:去 请求与数据层SSEWebSocket

参考来源