效果
- 我让 AI 写的简单页面 LCP 100 ms 左右。首个请求完成也就二三十毫秒。
缺陷
- 首次访问别想了,该卡还是卡。
- 部分解决 Edge 上的一个导致延迟大的问题比较复杂。解决方法在后面。
- 只对可预测性比较强的网站效果更好。可预测性不强的网站想要快代价很可能比较大,具体方法在后面。
- 如果网站更新了,用户刷新两次才能获得最新内容。
代价
- 比较复杂。
- 网站有一定概率会彻底崩,而且比较难恢复。
- 可能会消耗更多的流量。
主要技术
- Service Worker
- SWR
方法
简单说就是用 service worker 实现一个 SWR 缓存策略。
真传一句话,下面假传万卷书。
service worker 我理解就是一个运行在浏览器的类似代理的东西,可以任意修改特定网站所有的请求的响应。SWR 我理解就是如果没缓存,就先访问服务器获取内容,然后返回响应;如果有缓存,就直接返回缓存,然后再访问服务器更新缓存。这段我写的时候没有参考任何资料,都是凭记忆写的。在意的话自己搜搜。
虽然看起来挺简单的,但实际实现的时候还是会遇到一些比较恶心的问题。比如如果 service worker 安装过程比较耗时的话,可能导致一些页面无法缓存,最好是在 service worker 的激活事件里显式缓存一下。否则可能导致第三次访问的时候才能快速打开。
缺陷详解
我相信在这种条件下让首次访问也快是不可能的。不过我也没啥根据,基本就是直觉,我倒是希望首次也快。可能不同的网站互相缓存可以部分提升。不过 service worker 是不能跨域的,用 service worker 应该是不行。
在 edge 上就算使用了缓存,五分钟以上不访问之后再次访问还是偶尔会出现一两秒的延迟。解决方法是在所有页面中添加一个隐藏的框架,框架的内容就是网站内的一个页面,比如说 /keepalive ,然后每隔一段时间刷新这个框架的内容。这样如果页面开着的话,基本就不会出现卡顿的情况了。我测试半分钟效果不错。另外在 service worker 中添加一点代码,如果请求的 pathname 部分是 /keepalive ,就直接用代码返回响应,不要经过服务器。我试过了,效果一样。我基本可以确定这种刷新不会导致太多的服务器请求。DNS 请求应该是免不了了,我在 CF 的 DNS 请求页面看了,不访问的时候基本没有 DNS 请求,持续刷新的过程中五分钟十次请求。据说那个十次是假的,不知道真假。如果在 service worker 中直接返回响应的话,理论上应该是不会有 web 服务器流量的。但是由于 cloudflare 好像是没有监控静态页面流量的功能,所以我就不管了。Wireshark 我不太会用,不想学。谁要是能帮我分析一下我谢谢你。我问过微软的客服的,看起来微软没有解决这个问题的意思,微软客服大概说 chrome 用了缓存能瞬间打开是链接复用之类的机制比较激进,edge 没那么激进。我有点怀疑 edge 就是没优先用缓存的 dns 映射(这个词可能不准确),chrome 可能直接用缓存的 dns 记录了。就说是 chrome 可能对 dns 映射用的是 SWR 策略。
我想做的是一个类似解谜游戏的网站,实际不是游戏,具体我不想说。我预测大部分用户都会一关一关访问,所以在首页预载第一关,在第一关预载第二关,以此类推效果应该非常不错。这部分代码里没有。我之前试过,但是没有跟解决 edge 问题的代码一起测试,我估计是没什么问题。如果用户访问行为不好预测的话,并且网站页面不多的话,直接暴力预载所有内容可能也不错。网站内容多的话可能就不行了。预载访问量排前几个的?
如果更新了需要刷新两次才能看到最新内容是 SWR 策略决定的。我试过让 AI 实现检测到更新在页面上显示一个通知,但实际搞下来我感觉太复杂了,放弃了。你要是有勇气可以试试。
代价详解
复杂就不多解释了,前面那么多字应该有点体现了。而且我可能还没有把所有坑都踩遍。
service worker 出问题很危险,可能会导致全站无法访问。而且有一些坑。比如如果 service worker 的脚本浏览器缓存时间比较长的话,用户的浏览器可能无法及时更新。解决方法挺复杂。我建议能跑就尽量别改 service worker 的代码。如果有新的需要改 service worker 的需求,如果价值不大就别实现了,保命要紧。反正我就打算这么干了。
cloudflare 一般情况下好像不会给出完整的响应,而是通过对比一个请求头判断浏览器缓存是否过时了,过时了才发完整的请求。我不知道 SWR 会不会利用这个机制。
弯路
刚开始我是想通过浏览器缓存很长时间的 HTML 加动态加载各个页面对应的 js 文件动态修改页面内容来实现的,后来好像被 AI 提醒 service worker 更好就用了后者。
临时 Demo
过一段时间很可能会失效。
在 edge 上测试方法就是开着首页,再打开 https://sdwpub.cc.cd/test 再关闭,缓存这个页面。然后过一段时间访问 https://sdwpub.cc.cd/test 。我测了很多次都没卡顿。如果不开着首页过五分钟就有可能卡顿。chrome 的话,打开一次首页之后再访问首页就不卡顿了。
代码
index.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Edge 卡顿缓解</title>
</head>
<body>
<!-- 正常页面内容 -->
<span>测试。</span>
<!-- 不可见框架,用于保持页面活跃 -->
<iframe id="keepAliveFrame" src="keepalive" style="display:none;"></iframe>
<script>
// 每半分钟刷新一次不可见框架
setInterval(function() {
var frame = document.getElementById('keepAliveFrame');
if (frame) {
frame.src = 'keepalive?t=' + Date.now();
}
}, 30000);
// 注册 Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function(err) {
console.error('SW 注册失败:', err);
});
});
}
</script>
</body>
</html>
sw.js:
// == sw.js ==
// Service Worker:全站 GET 请求 SWR 缓存 + 预缓存首页
const CACHE_NAME = 'swr-cache-v1';
// 安装时预缓存首页
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.add('/').catch(() => {}))
);
self.skipWaiting(); // 立即激活,不等待旧 SW
});
// 激活时接管所有客户端
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim());
});
// SWR 策略:拦截所有同源 GET 请求
self.addEventListener('fetch', event => {
const { request } = event;
if (request.method !== 'GET') return;
const url_tmp = new URL(event.request.url);
// 判断 pathname 是否为 /keepalive
if (url_tmp.pathname === '/keepalive') {
// 直接返回 "test" 四个字符作为响应
event.respondWith(
new Response('test', {
status: 200,
headers: { 'Content-Type': 'text/plain' }
})
);
return; // 已处理,不再继续
}
// 仅处理同源请求,避免跨域 opaque 响应的复杂性
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
event.respondWith(
caches.open(CACHE_NAME).then(cache =>
cache.match(request).then(cached => {
// 后台网络请求,用于更新缓存
const networkFetch = fetch(request).then(response => {
if (response && response.status === 200) {
cache.put(request, response.clone());
}
return response;
}).catch(() => {});
if (cached) {
// 命中缓存:立即返回,同时后台更新
event.waitUntil(networkFetch);
return cached;
}
// 未命中:等待网络响应
return networkFetch;
})
)
);
});