地铁里信号差,朋友圈刷不出来?不对------为什么我刚才进站前刷过的新闻,还能继续看?飞机上没网,为什么有些App还能用?
答案是一个叫 Service Worker 的技术。但与其直接讲技术,不如用一个生活场景来说清楚:
预制菜。
你下班前把菜放进微波炉,定时加热。到点了直接吃,不用现做。离线用的网页就像预制菜------提前准备好内容,等你需要的时候直接"加热"给你,完全不需要厨师(服务器)在场。
原文地址
先说说为什么以前的网页不行
普通网页的逻辑很简单:
md
你想看新闻 → 浏览器发请求 → 服务器返回内容 → 显示
没网的时候,浏览器发出去请求,收到的只有"无法连接"四个字。内容?对不起,服务器连不上,什么都没法看。
问题出在哪?普通网页完全依赖厨师------厨师下班了(服务器挂了),你就没得吃(网页一片空白)。
Service Worker 改变了这个逻辑。它不是在网页里跑,而是常驻在后厨。即使厨师不在(没网),它也能从冰箱里把提前备好的菜端出来给你。
Service Worker 是什么
它本质上是一个独立于网页的代理层,夹在浏览器和网络之间。
yaml
没有Service Worker:
浏览器 → 发请求 → 网络 → 服务器
有Service Worker:
浏览器 → Service Worker拦截
├── 查缓存 → 直接返回(没网也能)
└── 没有缓存 → 发网络请求 → 服务器
它能干三件普通 JavaScript 干不了的事:
1. 拦截请求 ------ 网络请求从它手里过,它决定放行还是返回缓存 2. 持久存储 ------ Cache Storage,容量比 LocalStorage 大得多 3. 后台推送 ------ 不用打开网页也能收到通知
唯一限制:只能在 HTTPS 下使用(localhost 除外)。因为它能拦截所有请求,权限太大,必须是安全环境才给开。
Service Worker 的生命周期:三步走
理解 PWA 的核心就是理解 Service Worker 的生命周期。
第一步:安装------把菜备好
Service Worker 文件被浏览器下载解析成功后,触发 install 事件。这就像厨师上班后先备菜------把核心食材准备好。
这个阶段通常用来预缓存内容。把页面的核心资源(HTML骨架、CSS、JS图标)提前存起来,这样即使服务器挂了,网页的"壳"还能用。
js
const CACHE_NAME = 'news-v1';
const ASSETS = ['/', '/index.html', '/styles.css', '/app.js', '/logo.png'];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
);
});
event.waitUntil 是关键------它告诉浏览器"等我把菜备齐了,再告诉我准备好了"。如果中途出错,Service Worker 直接进入错误状态,不会激活。
第二步:激活------交接班
旧版 Service Worker 在控制着页面,新版下载好了但不会立即生效------要等所有用旧版的页面都关闭后,新版才接管。这就像餐厅交接班:等最后一桌客人走了,才能换班。
激活阶段通常做清理旧缓存的工作。新版上线了,旧食材该扔掉就扔掉。
js
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(names =>
Promise.all(
names.filter(n => n !== 'news-v1').map(n => caches.delete(n))
)
)
);
});
这一步不做的话,旧缓存会越堆越多,既占空间,又可能导致加载过期内容。
第三步:干活------接单出餐
激活之后,Service Worker 正式上岗。每次页面发请求,都会触发 fetch 事件------它来决定:走缓存还是走网络?
js
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request);
})
);
});
这就是最基础的策略------Cache-First(缓存优先):先看看冰箱里有没有,有就直接上;没有再去做。
三大缓存策略:什么时候走缓存,什么时候去现做
Service Worker 的核心能力就是在这两种方式之间做选择。不同场景有不同的最优策略。
策略一:Cache-First(缓存优先)
思路:先看冰箱,有就直接吃;没有再去做,顺便存一份。
适合场景:静态资源(CSS、JS、图片、字体)。这些基本不变,存一份能用很久。
md
请求来了 → 查缓存
├── 有 → 直接返回(秒开)
└── 无 → 发请求 → 返回 + 写入缓存
这个策略的代价是:万一内容更新了,用户可能一直吃的是旧菜 。解决方案是给缓存加版本号(news-v1 → news-v2),新版本发布时主动清除旧缓存。
策略二:Network-First(网络优先)
思路:优先去买新鲜食材现做,买不到(没网)就从预制菜里凑合一顿。
适合场景:频繁变化的数据(新闻列表、个人信息)。你总想看到最新的,旧的不太介意。
js
self.addEventListener('fetch', event => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(response => {
const clone = response.clone();
caches.open('api-cache').then(cache => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
}
});
断网了?这个策略天然降级------网络走不通就回退到缓存。
策略三:Stale-While-Revalidate(过期更新)
思路:先吃预制菜垫肚子,同时去买新鲜食材,下一顿再吃新的。
适合场景:既想要速度快,又不想内容太过时的场景。这是用户体验最好的策略,很多 CDN 默认就用这个思路。
yaml
请求来了 → 立刻返回缓存(不用等)
同时发网络请求 → 用来更新缓存
用户感受到的是"秒开",内容在后台悄悄更新好了,下次访问就是新的。
三种策略对比
| 策略 | 速度 | 新鲜度 | 离线能力 | 适合什么 |
|---|---|---|---|---|
Cache-First |
最快 | 可能旧 | ✅ 强 | CSS、JS、字体、图片 |
Network-First |
取决于网 | 总是最新 | 一般(降级) | 频繁变化的数据 |
Stale-While-Revalidate |
快(缓存) | 最终一致 | ✅ 强 | 经常访问但偶尔更新的内容 |
实战中通常是混合策略 :静态资源 Cache-First,接口数据 Stale-While-Revalidate 或 Network-First。
App Shell:预制菜的"餐具"
App Shell 是 PWA 的性能关键。
它的思路是:把餐具和桌子先摆好,再上菜。页面的"壳"(骨架)先缓存起来,用户一打开就看到内容,而不是盯着白屏等加载。
yaml
第一次访问:
用户打开网页 → 骨架先显示 → API数据加载 → 填满骨架
Service Worker缓存了:
App Shell(HTML/CSS/JS骨架)
骨架渲染 → 秒开
数据再填充
第二次访问(即使没网):
用户打开网页 → 骨架秒开 → 数据从缓存来 → 正常显示
就像去餐厅:桌椅餐具(App Shell)早就摆好了,你坐下菜就能上;不用等厨房先把桌子搬出来。
Web App Manifest:让网站"装"到桌面
Service Worker 解决了离线可用,Manifest 解决的是让网站看起来像个App。
加一个 manifest.json:
json
{
"name": "离线新闻",
"short_name": "新闻",
"start_url": "/",
"display": "standalone",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
在 HTML 里引入:
html
<link rel="manifest" href="/manifest.json">
搞定之后,用户在浏览器里会看到"添加到主屏幕"的选项。添加到桌面后,点开------没有地址栏,没有浏览器按钮,看起来和原生 App 一模一样。
display 字段决定打开方式:
| 模式 | 效果 |
|---|---|
standalone |
独立窗口,无地址栏,最像原生 App |
minimal-ui |
保留少量系统 UI |
browser |
普通浏览器模式 |
消息推送:厨师主动敲门
Service Worker 还有一个杀手级能力:后台推送。
不用打开 App,厨师也能主动来敲门告诉你菜好了。服务器通过推送服务(比如 Firebase Cloud Messaging)发消息,Service Worker 在后台收到后弹出系统通知。
js
self.addEventListener('push', event => {
const data = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(data.title || '新消息', {
body: data.body || '您有一条新通知',
icon: '/icon-192.png'
})
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(clients.openWindow('/'));
});
这需要用户授权,而且不同浏览器有不同的推送通道 ------ Chrome 用 FCM,Safari 用 APNs。但对于新闻、社交、效率类 App,这能显著提升用户留存。
工程化:用 Workbox 不用手写
手写 Service Worker 不难,但当项目变大有几十条路由规则时,自己维护一堆缓存逻辑会很痛苦。
Workbox 是 Google 推出的 PWA 开发工具,把各种缓存策略封装成简洁的 API:
js
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// 静态资源 Cache-First,过期30天
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({ cacheName: 'images', plugins: [new ExpirationPlugin({ maxAgeSeconds: 30 * 24 * 60 * 60 })] })
);
// API 数据 Network-First,断网时降级到缓存
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({ cacheName: 'api-data' })
);
不用手写 fetch 监听器,路由匹配、过期清理、并发控制 Workbox 都帮你做好了。生产环境推荐使用。
完整示例:从零实现一个离线新闻App
综合以上所有内容,一个最小可用的离线新闻 PWA 结构:
yaml
/app
├── index.html
├── styles.css
├── app.js
├── sw.js ← Service Worker
└── manifest.json
index.html:
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线新闻</title>
<link rel="manifest" href="/manifest.json">
</head>
<body>
<h1>新闻列表</h1>
<div id="news">加载中...</div>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('已注册', reg.scope))
.catch(err => console.log('注册失败', err));
}
</script>
</body>
</html>
sw.js:
js
const CACHE = 'news-v1';
const ASSETS = ['/', '/index.html', '/styles.css', '/app.js'];
// 安装时预缓存核心资源
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
self.skipWaiting();
});
// 激活时清理旧缓存
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(ns => Promise.all(ns.filter(n => n !== CACHE).map(c => caches.delete(c))))
);
self.clients.claim();
});
// 干活时根据请求类型走缓存或网络
self.addEventListener('fetch', e => {
if (e.request.url.includes('/api/')) {
e.respondWith(
fetch(e.request)
.then(r => { caches.open(CACHE).then(c => c.put(e.request, r.clone())); return r; })
.catch(() => caches.match(e.request))
);
} else {
// 其他请求走缓存或网络
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request))
);
}
});
这段代码实现了:
- 静态资源
Cache-First,秒开 - API 数据
Network-First,断网时降级到缓存 Service Worker注册后立即接管(skipWaiting+clients.claim)- 新版本自动清理旧缓存
回到地铁那个场景
现在你应该明白了。进站前刷了新闻,Service Worker 把内容和骨架都存了下来。等地铁进了地下没信号,Service Worker 照常工作------骨架秒开,数据从缓存来,你完全感知不到网络断了。
就像预制菜:提前备好,随时能吃,不需要厨师一直在场。
这才是 PWA 的核心价值------让网页真正变成一个"离线也能用、还能推送、还能装到桌面"的存在。