一种基于 Service Worker 的渐进式渲染方案的基本原理
前言
笔者前面发过两篇关于流式SSR的文章:
流式SSR就是一种渐进式渲染,在传统的页面加载流程是:请求 → 等待 → 渲染。而渐进式渲染的思路是:
- 立即展示缓存的页面快照(即使是旧内容)
- 后台请求最新的页面内容
- 无缝替换为最新内容
这样用户感知到的加载时间接近于零,体验类似于原生 App。
前面笔者的文章中,提到关于H5页面的快照是客户端做的。本篇文章讲述一种基于 Service Worker 的渐进式渲染方案的原理,简单来讲就是将客户端的工作挪到了service worker中。通过给站点开启一个后台运行的service worker(service worker可以独立于webview运行在后台),在service worker中劫持包括主文档在内的网络请求,对文档内容进行存储,并修改返回。
技术方案设计
整体架构
┌─────────────┐
│ 用户访问 │
└──────┬──────┘
│
▼
┌─────────────────┐
│ Service Worker │ ◄─── 拦截请求
└────┬────────┬───┘
│ │
│ └─────────┐
▼ ▼
┌─────────┐ ┌──────────┐
│ 缓存快照 │ │ 网络请求 │
└────┬────┘ └─────┬────┘
│ │
└────────┬────────┘
▼
┌─────────────┐
│ 流式替换 │
└─────────────┘
核心代码实现
1. HTML 页面注册 Service Worker
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>渐进式渲染示例</title>
</head>
<body>
<h1>Hello World</h1>
<script data-snapshot>
(function () {
const swEnabled = location.search.indexOf('x-sw=false') < 0;
// 注册 Service Worker
swEnabled && navigator.serviceWorker && navigator.serviceWorker.register('/sw.js')
.then(function (registration) {
console.log('Service Worker 注册成功:', registration);
})
.catch(function (error) {
console.log('Service Worker 注册失败:', error);
});
// 如果禁用,则注销 Service Worker
!swEnabled && navigator.serviceWorker && navigator.serviceWorker.getRegistration(location.href).then((r) => {
r && r.unregister();
});
}());
</script>
</body>
</html>
关键点说明:
-
data-snapshot属性标记这是快照阶段需要保留的脚本 - 支持通过
?x-sw=false参数禁用 Service Worker - 禁用时会自动注销已注册的 Service Worker
2. Service Worker 核心逻辑
// sw.js
self.addEventListener('fetch', (event) => {
// 只拦截主文档请求
if (event.request.destination !== 'document') {
return;
}
// 支持禁用功能
if (event.request.url.indexOf('x-sw=false') >= 0) {
event.waitUntil(caches.delete('my-cache'));
return;
}
event.respondWith(handleFetch(event.request));
});
self.addEventListener('install', (event) => {
console.log('Service Worker 安装');
self.skipWaiting(); // 立即激活
});
3. 脚本过滤策略
function replaceScripts(text, regularStream) {
return text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, (match) => {
// 快照阶段:只保留 data-snapshot 脚本
// 正式阶段:只保留普通脚本
if (match.indexOf('data-snapshot') >= 0) {
return regularStream ? '' : match;
}
return regularStream ? match : '';
});
}
为什么要过滤脚本?
- 快照阶段:避免执行业务逻辑脚本(可能依赖未加载的资源)
- 正式阶段:避免重复执行初始化脚本
4. 流式渲染核心
function withSnapshot(snapshot, request) {
return new Response(new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// 第一步:立即输出快照
controller.enqueue(encoder.encode(snapshot));
let firstStream = true;
// 第二步:请求最新内容
fetchAndStore(request).then((response) => {
const reader = response.body.getReader();
function push() {
reader.read().then(({ done, value }) => {
if (done) {
controller.close();
return;
}
if (firstStream) {
firstStream = false;
// 第三步:清空页面
controller.enqueue(encoder.encode(
'<script>document.head.innerHTML = "";document.body.innerHTML = "";</script>'
));
// 第四步:注入最新内容
const text = decoder.decode(value);
const head = text.match(/<head>([\s\S]*?)<\/head>/i);
const body = text.match(/<body>([\s\S]*?)<\/body>/i);
if (head && body) {
controller.enqueue(encoder.encode(
`<script>document.head.innerHTML = '${head[1].trim().replace(/\n/g, '')}'</script>`
));
controller.enqueue(encoder.encode(replaceScripts(body[1], true)));
}
} else {
controller.enqueue(value);
}
push();
});
}
push();
});
},
}));
}
为什么要清空 DOM?
- 快照内容和最新内容可能结构不同
- 直接追加会导致内容重复
- 清空后重新注入,确保页面状态一致
为什么用 innerHTML 注入?
- 流式响应中,我们无法直接操作 DOM
- 只能通过推送
<script>标签让浏览器执行 JavaScript -
innerHTML是最简单的 DOM 替换方式
5. 缓存管理
Service Worker 的缓存存储在 Cache Storage API 中,这是浏览器提供的专门用于 Service Worker 的持久化存储空间。实际上,不需要关心物理位置,因为浏览器完全管理这些文件。
function fetchAndStore(request) {
return fetch(request)
.then((networkResponse) => {
if (networkResponse.ok) {
// 克隆响应用于缓存
const cacheResponse = networkResponse.clone();
caches.open('my-cache').then((cache) => {
cache.put(request, cacheResponse);
});
}
return networkResponse;
});
}
function handleFetch(request) {
return caches.match(request)
.then((response) => {
if (response) {
// 有缓存:先展示快照,再更新
return readResponseText(response).then((snapshot) => {
return withSnapshot(snapshot, request);
});
}
// 无缓存:直接请求
return fetchAndStore(request);
});
}
为什么要 clone 响应?
- Response 对象的 body 只能读取一次(流的特性)
- 需要一份给缓存,一份给浏览器
-
clone()创建独立的副本
工作流程详解
首次访问(无缓存)
用户访问 → Service Worker 拦截 → 无缓存 → 网络请求 → 返回内容 → 存入缓存
二次访问(有缓存)
用户访问
↓
Service Worker 拦截
↓
读取缓存快照(去除普通脚本)
↓
立即返回快照内容 ← 用户看到页面
↓
后台发起网络请求
↓
清空 DOM
↓
注入最新 head 和 body
↓
更新缓存
注意事项
上述只讲述了该方案的基本原理,实际应用要考虑更多的因素如App 环境兼容性、缓存策略、基础设施依赖等,下面是方案对比:
| 维度 | 客户端方案 | Service Worker 方案 |
|---|---|---|
| 首次访问拦截 | ✅ 可以拦截 | ❌ 无法拦截 |
| 跨平台能力 | ❌ 需要各端适配 | ✅ Web 标准,通用 |
| 更新速度 | ⚠️ 需要发版 | ✅ 实时生效 |
| 开发成本 | ⚠️ 需要端上开发 | ⚠️ 需要 Web 开发 |
| 维护成本 | ❌ 多端维护 | ✅ 单一维护 |
| 灵活性 | ⚠️ 受限于客户端版本 | ✅ 完全可控 |
| 降级能力 | ⚠️ 需要发版回滚 | ✅ 秒级降级 |
总结:
- 如果你的业务是纯 Web 应用(PWA) → Service Worker 是最佳选择
- 如果你的业务在 App 内 → 优先考虑客户端方案
参考: