
一句话:网络优化的三件事是「用对资源提示(
preconnect/preload/prefetch,别滥用)+ 配好 HTTP 缓存(hash 资源一年 immutable、HTML 用no-cache、stale-while-revalidate兜住过期)+ 用 Service Worker 做分层缓存(外壳/资源/数据各用不同策略)」。
一、资源提示:四个 rel 的分工
| 提示 | 做什么 | 什么时候用 | 代价 |
|---|---|---|---|
dns-prefetch |
只解析 DNS | 早期就写(兼容性最好) | 极小 |
preconnect |
DNS + TCP + TLS | 确定会用的第三方源(CDN、字体、API) | 占一个连接(别超过 4~6 个) |
preload |
提前下载「当前页面」要用的资源 | 关键路径上「发现晚」的资源(LCP 图、首屏字体) | ⚠️ 抢占带宽;未使用会浪费 |
prefetch |
空闲时下载「将来导航」要用的资源 | 下一页的 chunk、用户可能点的路由 | 低优先级;别太多 |
modulepreload |
preload 并解析 ES module 的依赖树 | 入口 chunk / 关键动态 import | 比 preload 更适合 ESM |
<!-- ✅ 一个「正确使用」的完整示例 -->
<head>
<!-- ① 第三方源:提前建连(dns-prefetch 兜底 + preconnect 加速) -->
<link rel="dns-prefetch" href="//cdn.example.com" />
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<!-- ② 首屏字体:preload(⚠️ 必须 crossorigin,字体是 CORS 请求) -->
<link rel="preload" href="/fonts/main-subset.woff2" as="font" type="font/woff2" crossorigin />
<!-- ③ LCP 图片:preload(⚠️ 必须带 imagesrcset/imagesizes,否则可能下载两份) -->
<link rel="preload" as="image"
imagesrcset="/img/hero-800.webp 800w, /img/hero-1600.webp 1600w"
imagesizes="100vw" fetchpriority="high" />
<!-- ④ 入口脚本:modulepreload(对 ESM 比 preload 更好) -->
<link rel="modulepreload" href="/assets/index-abc123.js" />
<!-- ⑤ 下一页的 chunk:prefetch(空闲时下,别在首屏就下) -->
<link rel="prefetch" href="/assets/checkout-def456.js" as="script" />
</head>
⭐⭐⭐ `preload` 的四个「经典坑」(每一个都真实发生过):
① 【preload 了但没用到】(最浪费)
浏览器会在控制台警告:
"The resource ... was preloaded using link preload but not used within a few seconds"
原因:预加载了「条件渲染才用的资源」(如某个 Modal 的脚本),
而用户没打开它。
✅ 只 preload「**这次导航确定会用**」的资源。
② 【字体 preload 忘了 `crossorigin`】
→ 浏览器认为「preload 的那个」与「CSS 里请求的那个」不是同一资源 → **下载两次**
✅ <link rel="preload" href="/f.woff2" as="font" type="font/woff2" crossorigin>
⭐ 记忆点:**字体、fetch、以及任何跨域资源,preload 都要 `crossorigin`**。
③ 【图片 preload 只写 `href`,不写 `imagesrcset`】
→ preload 阶段用「单一 URL」,而实际渲染时 `srcset` 可能选中另一张 → **下载两次**
✅ 带上 `imagesrcset` + `imagesizes`(与 `<img>` 上的一致)。
④ 【preload 太多 → 抢走 LCP 的带宽】
⚠️ `preload` 是「高优先级」,你 preload 10 个资源,它们会一起挤占带宽,
反而让真正的 LCP 资源更慢。
✅ 只 preload「1~2 个」真正关键的东西(通常是一个字体 + 一张 LCP 图)。
⭐⭐ `prefetch` 与 `preload` 的本质区别:
· **`preload`**:**当前页面**要用 → **高优先级**(与「正常加载」同量级)→ 抢占带宽
· **`prefetch`**:**将来的导航**要用 → **最低优先级**(只在空闲时下载)→ 不抢
⚠️ prefetch 的资源会被放进 **HTTP 缓存**(而不是 memory cache),
所以「下次导航」时才真正受益。
⭐ 所以:「首屏关键资源用 preload,下一跳资源用 prefetch」。
<!-- ⭐ 现代替代:Speculation Rules API(预取 + 预渲染,Chrome 109+) -->
<script type="speculationrules">
{
"prerender": [
{ "source": "list", "urls": ["/checkout"], "eagerness": "moderate" }
],
"prefetch": [
{ "source": "document", "where": { "href_matches": "/products/*" }, "eagerness": "conservative" }
]
}
</script>
⭐⭐ `prerender`(预渲染)与 `prefetch`/`preload` 的区别:
· preload/prefetch 只处理「**资源**」
· **prerender** 会在后台**完整渲染一个页面**(包括 JS 执行、子资源加载)
→ 用户点进去时**几乎是瞬间显示**
· `eagerness`(急迫程度)控制「什么时候开始」:
conservative(hover 才做)→ moderate(hover 一小会)→ eager(立刻)
⚠️ 代价:预渲染会消耗大量资源(CPU/内存/带宽)→ 只对「高概率的下一跳」用
⭐ `rel="prerender"`(老的写法)已废弃,改用 **Speculation Rules**。
⭐ 预渲染页面里的 `document.prerendering` 为 true,
可以做「预渲染时先不请求分析/广告」这类优化。
二、Priority Hints:告诉浏览器「谁更重要」
<!-- ✅ fetchpriority 可用在 img / script / link / iframe 上 -->
<img src="hero.webp" fetchpriority="high" alt="主视觉" /> <!-- LCP 图 -->
<img src="related.webp" fetchpriority="low" alt="相关推荐" /> <!-- 屏幕外 -->
<!-- ⭐ 用于 preload 时也能「降低」优先级(避免抢 LCP 带宽) -->
<link rel="preload" as="image" href="/secondary.webp" fetchpriority="low" />
<!-- ⭐ 用于 script:让「非关键脚本」不要抢首屏带宽 -->
<script src="/analytics.js" fetchpriority="low" async></script>
⭐⭐ 三个「优先级」使用的判断点:
① 【只给 LCP 资源设 high】
「全部设 high」=「都没设」——浏览器会在同一优先级里按顺序执行。
② 【用 low 把「非关键」压下去】
· 埋点/监控脚本 → low
· 屏幕外图片 → low
· 预加载的「次要资源」→ low
→ ⭐ 这比「提升关键资源」更有效(因为总带宽是有限的,降低竞争收益更大)。
③ 【与 preload 的配合】
如果你 preload 了「次要资源」,一定给它 `fetchpriority="low"`,
否则它会以高优先级抢占 LCP 资源的带宽。
三、103 Early Hints:让「等待后端」的时间也在加载资源
# ✅ 服务端(在最终响应准备好之前先发 103)
HTTP/1.1 103 Early Hints
Link: </style.css>; rel=preload; as=style
Link: </fonts/main.woff2>; rel=preload; as=font; crossorigin
HTTP/1.1 200 OK
Content-Type: text/html
...
⭐⭐ 103 解决的问题:
场景:SSR 的 TTFB 是 600ms(服务端要查数据库/调后端)。
问题:这 600ms 里,浏览器**什么都不能做**——因为它还不知道要加载哪些资源。
解法:服务端「一收到请求就立刻」发一个 **103 Early Hints**,
带上「即将需要的资源」的 `Link` 头 →
⭐ 浏览器在「服务端还在算」的这 600ms 里就已经开始下载 CSS/字体了。
→ 相当于把「TTFB」与「资源下载」**并行**起来。
⭐ 适用与注意:
· 最适合「TTFB 长」的页面(SSR + 后端聚合、边缘计算)
· 需要服务端/CDN 支持(Nginx、Next.js、Cloudflare 等多数已支持)
· ⚠️ 只能「提示」不能「保证」(浏览器可能忽略)
· ⚠️ 别一次发几十条(与 preload 滥用同理)
四、HTTP 缓存:把「不用重新下载」做到极致
| 指令 | 含义 | 典型用法 |
|---|---|---|
max-age=N |
N 秒内直接用缓存(不发请求) | 静态资源 |
s-maxage=N |
只对共享缓存(CDN)生效,覆盖 max-age |
让 CDN 缓存更久 |
immutable |
在 max-age 内不重新验证(连刷新都不验证) |
⭐ 带 hash 的资源 |
no-cache |
可以缓存,但每次必须向服务端验证 | HTML |
no-store |
完全不缓存(也不写入) | 敏感接口 |
private |
只允许浏览器缓存(不允许 CDN) | 用户数据 |
public |
允许任何缓存(含 CDN) | 公共资源 |
stale-while-revalidate=N |
过期后先返回旧内容,同时后台更新(N 秒内允许) | ⭐ 数据接口 |
stale-if-error=N |
源站错误时,用旧内容兜底(N 秒内) | 提升可用性 |
must-revalidate |
过期后必须验证(不允许用陈旧内容) | 一致性要求高 |
⭐⭐⭐ 「黄金组合」(这是缓存策略的标准答案):
① 【带内容 hash 的静态资源】
Cache-Control: public, max-age=31536000, immutable
→ ⭐ 「一年 + immutable」+「文件名带 hash」=
「内容变了文件名就变 → 永远不会读到旧内容」+「永远不用重新验证」
→ 这是**零成本的最优缓存**
② 【HTML】
Cache-Control: no-cache
→ 必须「每次向服务端验证」(用 ETag → 304 时几乎零成本)
⚠️ 不要给 HTML 设长 max-age(会导致「用户看到旧版本」的经典事故)
③ 【数据接口(可容忍短暂陈旧)】
Cache-Control: private, max-age=0, stale-while-revalidate=60
→ 或者交给前端缓存层(React Query 的 staleTime,见 7.4)
④ 【CDN 层的「双层」缓存】
Cache-Control: public, s-maxage=600, max-age=60
→ CDN 缓存 10 分钟(s-maxage),浏览器只缓存 1 分钟(max-age)
→ ⭐ 这样「回源压力」与「用户拿到的陈旧程度」都能控制
⭐⭐ `stale-while-revalidate` 为什么重要(它是「体验」与「一致性」的平衡点):
问题:`max-age=60` 的数据,60 秒后就「必须重新请求」→ 第 61 秒的用户
要等一个 RTT(几百毫秒)才能看到内容。
解法:`max-age=60, stale-while-revalidate=600`
→ 第 61 秒的用户 **立刻看到旧内容**,同时浏览器**后台请求新版本**,
下一个用户就看到新的了。
⭐ 结果:**几乎所有人都不会等**,且「陈旧窗口」被限制在 10 分钟内。
⚠️ 注意:`stale-while-revalidate` 是「**浏览器/CDN 的缓存层**」行为,
如果你在前端用 React Query 之类的库,「同样的语义」是由库实现的
(`staleTime` + 后台 refetch)——两者可以叠加(见 7.4)。
⭐⭐ 两个「验证型」缓存(304)的机制(面试常问):
【ETag / If-None-Match】
服务端:`ETag: "abc123"`
下一次请求:`If-None-Match: "abc123"`
→ 没变 → 返回 **304 Not Modified**(**无响应体**)
【Last-Modified / If-Modified-Since】
精度是「秒」,且如果「内容变了但修改时间没变」(或反之)会出错
⭐ 优先级:**ETag 优先于 Last-Modified**(更精确)
⭐ 304 的成本:仍然要「一个完整的往返」(RTT)。
所以「304 优于重新下载,但劣于『不请求』」——
这就是为什么「hash 文件名 + 长 max-age + immutable」是最优解
(它连请求都不用发)。
五、HTTP/2 与 HTTP/3:从「投机优化」到「正常写」
⭐⭐ 协议演进改变了「优化手段的适用性」:
【HTTP/1.1】
问题:一个连接同时只能处理一个请求(队头阻塞)
当时的手段(⭐ 现在很多变成反模式):
· 域名分片(domain sharding)→ 多个域名并行
· 雪碧图(CSS sprite)→ 合并小图为一张
· 内联小图(base64)→ 减少请求数
· 合并 JS/CSS 文件 → 减少请求数
【HTTP/2】
✅ 多路复用(一个连接上并行多个请求流)→ 队头阻塞(应用层)基本解决
✅ 头部压缩(HPACK)→ 重复的 header 不再重复传输
⭐ 所以:
· 域名分片 → ❌ 反模式(反而是「放弃多路复用」还多建连接)
· 雪碧图 → ⚠️ 收益下降(但仍可减少请求)→ 现在多用 SVG sprite(按需)
· 合并所有 JS 成一个文件 → ❌ 反模式(破坏缓存粒度,见 5.14)
【HTTP/3】
✅ 基于 QUIC(UDP):解决 **TCP 层的队头阻塞**(丢包不再阻塞其它流)
✅ 0-RTT / 1-RTT 建连(重连更快)
✅ 连接迁移(切换 WiFi/4G 不断连)
⚠️ 部分企业网络会「屏蔽 UDP」→ 需要「回退到 H2」的能力
| 曾经的优化 | H1 | H2/H3 | 现在该怎么做 |
|---|---|---|---|
| 域名分片 | ✅ | ❌ 反而有害 | 同一个源 |
| 合并所有 JS | ✅ | ⚠️ 破坏缓存 | 按「变更频率」拆分 |
| CSS 雪碧图 | ✅ | ⚠️ 收益低 | SVG sprite / 图标组件 |
| 内联小图 base64 | ✅ | ⚠️ 阻塞 HTML 解析 | 只内联「极小且必需」的 |
| CDN | ✅ | ✅ | 依然重要 |
六、完整实现:一个 210 行的 Service Worker 缓存策略库
// sw.js —— 分层缓存策略(外壳 / 资源 / 数据 各用不同策略)
// ⚠️ 这是一个「可直接用」的实现(不依赖 Workbox),生产环境也可考虑 Workbox。
const VERSION = 'v3';
const CACHE_NAMES = {
shell: `shell-${VERSION}`, // HTML 外壳(navigation)
assets: `assets-${VERSION}`, // 带 hash 的静态资源
images: `images-${VERSION}`, // 图片
data: `data-${VERSION}`, // API
};
/** 需要「离线可用」的最少外壳资源 */
const PRECACHE_URLS = ['/', '/offline.html'];
/** ============ 安装:预缓存外壳 ============ */
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE_NAMES.shell);
await cache.addAll(PRECACHE_URLS);
// ⚠️ 这里【不】调用 skipWaiting —— 让「新版本」等用户刷新后再接管,
// 避免「页面资源与 SW 版本不一致」导致的白屏(见下方「特有的坑」)
})()
);
});
/** ============ 激活:清掉旧版本缓存 ============ */
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => !Object.values(CACHE_NAMES).includes(k))
.map((k) => caches.delete(k))
);
// ⭐ 让「下一次导航」立即用新 SW(而不是再等一次刷新)
await self.clients.claim();
})()
);
});
/** ============ 三种缓存策略 ============ */
/** ① cache-first:静态资源(带 hash → 内容不会变,命中就是命中) */
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok && response.type === 'basic') {
cache.put(request, response.clone());
}
return response;
}
/** ② network-first:HTML / 需要「尽量新」的内容 */
async function networkFirst(request, cacheName, timeoutMs = 3000) {
const cache = await caches.open(cacheName);
try {
// ⭐ 给网络加超时(避免弱网下长时间白屏)
const response = await fetchWithTimeout(request, timeoutMs);
if (response.ok) cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
if (cached) return cached;
// ⭐ 兜底:离线页面
const offline = await caches.match('/offline.html');
if (offline) return offline;
return new Response('离线,且没有缓存', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
}
/** ③ stale-while-revalidate:先给旧的,同时后台更新(图片/数据最常用) */
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
// ⭐ 后台更新(不 await,让「当前请求」立刻返回)
const networkPromise = fetch(request)
.then((response) => {
if (response.ok && response.type === 'basic') {
cache.put(request, response.clone());
}
return response;
})
.catch(() => null);
if (cached) {
// ⭐ 注意:用 event.waitUntil 保证「后台更新」不被中断
return cached;
}
const response = await networkPromise;
if (response) return response;
return new Response('', { status: 504 });
}
function fetchWithTimeout(request, ms) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timeout')), ms);
fetch(request).then(
(res) => { clearTimeout(timer); resolve(res); },
(err) => { clearTimeout(timer); reject(err); }
);
});
}
/** ============ 路由分发 ============ */
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return; // ⚠️ 只处理 GET
const url = new URL(request.url);
// ⭐① 非本域:直接放行(不缓存第三方,避免「跨域 opaque 响应」的坑)
if (url.origin !== self.location.origin) return;
// ⭐② 带 hash 的构建产物(/assets/xxx-abc123.js|css)→ cache-first
if (/\/assets\/.*\.[0-9a-f]{6,}\.(js|css|woff2?)$/.test(url.pathname)) {
event.respondWith(cacheFirst(request, CACHE_NAMES.assets));
return;
}
// ⭐③ 图片 → stale-while-revalidate(用户对「旧图」不敏感)
if (/\.(png|jpe?g|webp|avif|gif|svg)$/i.test(url.pathname)) {
event.respondWith(
staleWhileRevalidate(request, CACHE_NAMES.images).then((response) => {
// ⭐ 图片缓存命中时,用 event.waitUntil 保证「后台更新」有机会完成
return response;
})
);
return;
}
// ⭐④ API → network-first(数据要新)+ 短超时
if (/^\/api\//.test(url.pathname)) {
event.respondWith(networkFirst(request, CACHE_NAMES.data, 2500));
return;
}
// ⭐⑤ 导航请求(HTML)→ network-first + 离线兜底
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request, CACHE_NAMES.shell, 3000));
return;
}
// 其余:走默认网络
});
/** ============ 更新流程(避免「资源与 SW 版本不一致」) ============ */
// 页面侧:监听 SW 更新,提示用户刷新
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
navigator.serviceWorker.addEventListener('controllerchange', () => {
// ⭐ SW 换了 → 页面上的资源可能已经不匹配 → 只在「用户主动刷新」时重载
// 这里用「一次性标志」避免无限刷新
if (window.__refreshOnce) return;
window.__refreshOnce = true;
location.reload();
});
navigator.serviceWorker.ready.then((registration) => {
// ⭐ 检查更新(可在「路由切换时」或「定时」执行)
registration.update();
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// 有新版本「等待中」→ 提示用户(或自动切换)
showUpdateToast(() => {
newWorker.postMessage({ type: 'SKIP_WAITING' });
});
}
});
});
});
}
// SW 侧:响应 SKIP_WAITING 消息
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
⭐⭐⭐ 五条「SW 策略」的关键判断:
① 【带 hash 的资源永远用 cache-first】
因为「文件名带 hash」= 「内容永不变」→ 命中就是最优,不需要网络。
② 【HTML 用 network-first(而不是 cache-first)】
否则用户会「一直看到旧版本」(因为 HTML 被缓存了,它引用的还是旧的 chunk)。
⚠️ 这是 SPA 更新的经典事故:「index.html 被缓存 → 指向旧 chunk」。
③ 【图片用 stale-while-revalidate】
图片「旧一点」无感知,而「立刻显示」的价值很大。
④ 【不要一开始 `skipWaiting()`】
立即接管会导致「页面上正在使用的旧 chunk」与「新 SW 缓存的资源」不匹配
→ 用户点了按钮报「ChunkLoadError」(见下)
✅ 做法:新 SW 进入 waiting → 提示用户「有新版本,点击刷新」
→ 用户点击后 `skipWaiting()` + reload
⑤ 【只缓存同源 GET】
⚠️ 跨域响应通常是 `opaque`(`status` 读不到、`ok` 为 false),
缓存它们容易出问题;非 GET(POST/PUT)本来就不该缓存。
七、本篇特有的坑
<!-- ① preload 了没用的资源(浪费带宽 + 控制台警告) -->
<link rel="preload" as="script" href="/heavy-modal.js" /> <!-- ⚠️ 用户没打开 Modal -->
<!-- ✅ 只 preload「本次导航确定会用」的 -->
<!-- ② 字体 preload 忘了 crossorigin → 下载两次 -->
<link rel="preload" href="/f.woff2" as="font"> <!-- ❌ -->
<link rel="preload" href="/f.woff2" as="font" type="font/woff2" crossorigin> <!-- ✅ -->
<!-- ③ preload 缺 `as` → 浏览器不知道类型 → 可能重复下载 -->
<link rel="preload" href="/app.js"> <!-- ❌ -->
<link rel="preload" href="/app.js" as="script"> <!-- ✅ -->
<!-- ④ preload 太多 → 抢 LCP 的带宽 -->
<!-- ✅ 1~2 个;次要的加 fetchpriority="low" -->
<!-- ⑤ preconnect 开太多(每个都占连接) -->
<link rel="preconnect" href="https://a.com">
<link rel="preconnect" href="https://b.com"> <!-- ⚠️ × 6 个 = 资源浪费 -->
<!-- ✅ 只 preconnect「真正会用且握手成本高」的 2~4 个源 -->
⑥ 给 HTML 设长 max-age(用户长期看到旧版本)
✅ HTML 用 `Cache-Control: no-cache`(配合 ETag → 304)
⚠️ 另一个常见变体:「Nginx 默认给所有文件加 expires 1y」
→ HTML 也被缓存一年 → 必须按类型区分
⑦ 忘记「带 hash 的资源」才能用 `immutable`
⚠️ 如果文件名【不带 hash】而用了 `max-age=1y, immutable`,
你就**永远无法更新**那个文件了(用户不会重新请求)
✅ 「hash 文件名」与「长缓存」是绑定的一对
⑧ CDN 缓存了「用户相关」的响应(数据串用户)
✅ `Cache-Control: private` 或 `Vary`(但 `Vary` 的维度不要太多)
⚠️ 尤其注意「带 locale / 登录态 / A/B 分组」的响应:
一定要在「缓存键」里体现(或用 `s-maxage` + `Vary`)
⑨ CDN 的缓存键包含了「无关参数」(`?utm_source=...`)
→ 同一个页面被缓存成 N 份(命中率暴跌)
✅ 在 CDN 配置里「忽略无关 query 参数」
⑩ 用 `no-store` 来处理「不想缓存」的一切
⚠️ 它会「完全不缓存」,导致每次都要完整下载
✅ 「可以缓存但要验证」用 `no-cache`;
「敏感数据」才用 `no-store`
⑪ ST 只说「用 CDN」但不知道「分层」
⭐ 正确说法是「浏览器缓存 + CDN 边缘缓存 + 源站」三层,
并用 `max-age`/`s-maxage` 分别控制(见黄金组合 ④)
⑫ SW 一开始就 `skipWaiting()` → ChunkLoadError
⚠️ 新 SW 立刻接管并清了旧缓存,但**当前页面**还在用旧 chunk 的引用
→ 用户点按钮时「找不到旧 chunk」→ 报错
✅ 提示用户刷新(或「等所有 tab 关闭后再接管」)
⑬ SW 缓存了「非 GET 请求」或「跨域 opaque 响应」
⚠️ opaque 响应的 `status` 是 0、`ok` 为 false → 判断逻辑会错
✅ 只处理同源 GET
⑭ 没有「离线兜底」却注册了 SW(离线时白屏)
✅ `precache` 一个 `/offline.html`,在 network-first 失败时返回它
⑮ 以为「HTTP/2 之后『域名分片』仍然有用」
⚠️ H2 下它反而有害(放弃了多路复用 + 多建连接)
✅ 同一个源;把「优化请求数」的心思转到「优化缓存粒度」上
⑯ 更新 SW 后忘记「清理旧版本缓存」(占满存储)
✅ `activate` 里删掉不在当前版本白名单里的 cache
⚠️ 还要注意「浏览器存储配额」(超了会被整体清除)
⑰ 只看 TTFB 不看「资源发现时间」
⭐ LCP 的四段里,「资源加载延迟」往往比 TTFB 更容易优化(preload/early hints)
(见 5.11 的 LCP 拆解)
⑱ `stale-while-revalidate` 用在了「一致性要求高」的接口上
(如「账户余额」「订单状态」)
⚠️ 用户可能看到 10 分钟前的数据 → 这类接口用 `no-cache` 或短 max-age
⭐ ⑫ 的详细说明(这是 SW 最容易出的线上事故):
时序:
① 用户打开页面 → 用 v3 的 SW + v3 的 chunk
② 你发布了 v4 → SW 更新,且 `skipWaiting()` 立刻接管
③ v4 的 SW 在 `activate` 里**删掉了 v3 的缓存**
④ 但用户**当前页面**还是 v3 的 HTML/JS,
里面有个懒加载 chunk 的 URL 是 `chunk-v3-abc.js`
⑤ 用户点了按钮 → 触发动态 import → 请求 `chunk-v3-abc.js`
⑥ 缓存里已经被删了 → 从网络请求 → 服务器上只有 v4 → **404**
⑦ 用户看到 "Failed to fetch dynamically imported module" / "ChunkLoadError"
✅ 三种缓解方式:
a. **不立即接管**:新 SW 等待,提示用户「刷新以更新」
b. **保留旧版本缓存一段时间**(delayed cleanup)
c. **监听 `vite:preloadError` / `unhandledrejection`**,
遇到 ChunkLoadError 时**自动 reload 一次**
→ ⭐ 这是「兜底」的必备手段(即使 a/b 都做了也建议有)
// ✅ c 的实现(Vite 项目)
window.addEventListener('vite:preloadError', () => {
// ⭐ 只在「第一次」失败时刷新,避免无限循环
const KEY = 'chunk-reload-at';
const last = Number(sessionStorage.getItem(KEY) ?? 0);
if (Date.now() - last > 10_000) {
sessionStorage.setItem(KEY, String(Date.now()));
location.reload();
}
});
八、面试延伸
- 「
preload/prefetch/preconnect/dns-prefetch有什么区别?」
按「做多少事」递增:①dns-prefetch——只做 DNS 解析(最便宜,兼容性最好,可作为兜底);②preconnect——DNS + TCP + TLS(省掉完整的建连 RTT,适合「确定会用的第三方源」,但别开太多,每个都占连接);③preload——提前下载当前页面要用的资源(高优先级,会抢带宽 → 只用于「1~2 个关键资源」,如 LCP 图与首屏字体);④prefetch——空闲时下载「将来导航」要用的资源(最低优先级,放进 HTTP 缓存,下次导航受益)。另有modulepreload——对 ES module 更优(会提前解析依赖树)。 -
「
preload有哪些坑?」
四个经典坑:① preload 了但没用到(控制台会警告 “preloaded but not used”,白费带宽);② 字体 preload 忘记crossorigin——字体是 CORS 请求,不带crossorigin会被视为「不同资源」→ 下载两次;③ 图片 preload 只写href不写imagesrcset/imagesizes——preload 用「单一 URL」而渲染时srcset可能选另一张 → 下载两次;④preload太多——它是高优先级,会抢占 LCP 资源的带宽(次要资源应加fetchpriority="low")。 -
「HTTP 缓存怎么配?」
按「资源类型」分三类:① 带内容 hash 的静态资源 →Cache-Control: public, max-age=31536000, immutable(⭐ hash 文件名 + 一年 + immutable = 永远不会读到旧内容,也永远不用验证);② HTML →Cache-Control: no-cache(必须每次验证,配 ETag → 304 几乎零成本;⚠️ 绝不能给 HTML 长 max-age,否则用户长期看到旧版本);③ 数据接口 →private, max-age=0, stale-while-revalidate=60(先返回旧的、后台更新)。CDN 层用s-maxage控制共享缓存时长,形成「浏览器 / CDN / 源站」三层。 -
「
stale-while-revalidate是什么?为什么有用?」
含义是「缓存过期后,允许在 N 秒内先返回陈旧内容,同时后台请求新版本」。为什么有用:没有它时,max-age=60的数据在第 61 秒就「必须重新请求」——用户要等一个 RTT 才能看到内容;有了它,第 61 秒的用户立刻看到旧内容、后台更新,下一个用户就看到新的了。结果是「几乎没有人需要等待,而陈旧窗口被限制在可接受范围内」。⚠️ 但它不适合「一致性要求高」的数据(账户余额、订单状态)。 -
「HTTP/2 之后,哪些前端优化变成了反模式?」
三个:① 域名分片(domain sharding)——H2 有多路复用,分片反而「放弃复用 + 多建连接」,纯负收益;② 把所有 JS 合并成一个文件——H2 不需要「减少请求数」了,而合并破坏了缓存粒度(改一行 → 用户重新下载全部);③ 雪碧图/内联 base64 的收益显著下降(前者仍能减少请求但可用 SVG sprite 替代,后者会阻塞 HTML 解析,只应内联「极小且必需」的东西)。优化的重心从「减少请求数」转移到了「优化缓存粒度与优先级」。 -
「Service Worker 的缓存策略怎么设计?」
分三层:① 静态资源(带 hash)→cache-first(内容不变,命中就是最优);② HTML/导航 →network-first+ 离线兜底(⚠️ HTML 用 cache-first 会导致用户一直看到旧版本);③ 图片/数据 →stale-while-revalidate(旧一点无感知,立刻显示价值大)。⚠️ 两个要点:只处理同源 GET(跨域响应是opaque,ok为 false 容易判断错);不要一开始skipWaiting(),否则会出现「当前页面用旧 chunk,但缓存的旧 chunk 已被删」→ChunkLoadError。 -
「SW 更新后用户报
ChunkLoadError,是什么原因?」
时序是:① 用户用 v3 打开页面;② 你发布 v4,SW 立即skipWaiting()接管;③ v4 的 SW 在activate里删掉了 v3 的缓存;④ 但当前页面仍是 v3 的代码,某个懒加载 chunk 指向chunk-v3.js;⑤ 用户点按钮触发动态 import → 缓存已删 → 从网络请求 → 服务器只有 v4 → 404。解法三层:① 不立即接管(新 SW 进 waiting,提示用户刷新);② 延迟清理旧缓存;③ ⭐ 监听vite:preloadError/unhandledrejection,遇到 ChunkLoadError 自动 reload 一次(带「防无限刷新」的时间戳判断)——这是必备兜底。 -
「103 Early Hints 解决什么问题?」
解决「服务端还在算的时候,浏览器什么都不能做」的问题。典型场景:SSR 的 TTFB 是 600ms(后端聚合查询慢),而浏览器在这 600ms 里还不知道要加载哪些资源。103 的做法是「服务端一收到请求就立刻返回一个103 Early Hints响应,带上Link: </style.css>; rel=preload; as=style这类头」→ ⭐ 浏览器在「服务端还在算」的这段时间里就已经开始下载 CSS/字体了,相当于把 TTFB 与资源下载并行。适用于「TTFB 长」的页面;注意只发「少数几条」关键资源提示。
一句话速记
资源提示的分工:
dns-prefetch(只解析 DNS)→preconnect(DNS+TCP+TLS,别超过 4~6 个)→preload(当前页面关键资源,高优先级,只 preload 1~2 个)→prefetch(下次导航用,最低优先级),ESM 用modulepreload,现代替代是 Speculation Rules(prefetch/prerender);preload四大坑:没用上、字体缺crossorigin、图片缺imagesrcset、preload 太多抢 LCP 带宽;Priority Hints 的关键是「给 LCP 设high,把次要资源压成low」(全部设 high = 都没设);103 Early Hints 让「服务端计算」与「资源下载」并行;HTTP 缓存的黄金组合是「带 hash 的资源public, max-age=31536000, immutable+ HTMLno-cache(配 ETag→304)+ 数据stale-while-revalidate」,CDN 层用s-maxage形成三层;H2/H3 之后「域名分片」「合并所有 JS」变成反模式,优化重心从「减少请求数」转到「缓存粒度 + 优先级」;SW 分层策略是「资源 cache-first、HTML network-first + 离线兜底、图片/数据 stale-while-revalidate」,只处理同源 GET、别立即skipWaiting(否则ChunkLoadError),并监听vite:preloadError自动 reload 一次兜底。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!