一个页面加载 3 秒钟,用户跑了 53%。
而你明明知道,"最好的网络请求是不发送请求"。
一、你是不是也经历过这种绝望?
你花了三个月写了一个 Web 应用,UI 精致、交互流畅、后端稳如老狗。然后你把链接发给了朋友——
"打开白屏,等了好久。"
你看了下 DevTools 的 Network 面板:
bundle.js 2.3 MB 3.2s
vendor.js 1.8 MB 1.5s
hero.png 800 KB 1.2s
api/user 320 KB 0.8s
api/products 450 KB 1.1s
────────────────────────────
Total: 6.8s
6.8 秒。 而 Google 的研究告诉你:加载时间从 1 秒变 3 秒,跳出率增加 32%;从 1 秒变 6 秒,跳出率增加 106%。
但这不是最让人崩溃的——最让人崩溃的是:
用户第二次打开页面,这些数据又被重新下载了一遍。
flowchart LR
subgraph 第一次["第一次访问"]
A1["下载 bundle.js (2.3MB)"] --> B1["下载 vendor.js (1.8MB)"]
B1 --> C1["下载所有图片..."]
C1 --> D1["😤 6.8 秒后页面终于出来了"]
end
subgraph 第二次["第二次访问(无缓存)"]
A2["下载 bundle.js (2.3MB)"] --> B2["下载 vendor.js (1.8MB)"]
B2 --> C2["下载所有图片..."]
C2 --> D2["🤬 又是 6.8 秒!"]
end
subgraph 理想["用户期望"]
E["秒开!"]
end
第一次 --> 第二次
第二次 -.->|"和第一次完全相同"| 理想
style 第一次 fill:#ffeaa7,stroke:#fdcb6e
style 第二次 fill:#fab1a0,stroke:#e17055
style 理想 fill:#55efc4,stroke:#00b894
这就好比你去餐厅点了一道菜,厨师每次都要从种菜开始——荒谬,但这就是大多数前端应用的现状。
前端缓存的全链路架构,就是来解决这个问题的。
二、全链路缓存到底是个什么概念?
一句话:从浏览器内存到 CDN 边缘节点,在请求的每一个环节都设置拦截点,能不给服务器发请求就不发。
更通俗地说,它像一个层层设防的军事要塞:
graph TD
A["用户发起请求"] --> B{"第一道防线<br/>Memory Cache<br/>⏱ 0ms"}
B -- "命中" --> R["返回数据"]
B -- "未命中" --> C{"第二道防线<br/>Service Worker<br/>⏱ <5ms"}
C -- "命中" --> R
C -- "未命中" --> D{"第三道防线<br/>HTTP Cache (Disk)<br/>⏱ 10-50ms"}
D -- "命中" --> R
D -- "未命中" --> E{"第四道防线<br/>Push Cache (HTTP/2)<br/>⏱ <100ms"}
E -- "命中" --> R
E -- "未命中" --> F{"第五道防线<br/>CDN 边缘节点<br/>⏱ 50-200ms"}
F -- "命中" --> R
F -- "未命中" --> G["源服务器<br/>⏱ 200ms-3s+"]
G --> R
style B fill:#00b894,stroke:#00a381,color:#fff
style C fill:#0984e3,stroke:#0767b3,color:#fff
style D fill:#6c5ce7,stroke:#5a4bd1,color:#fff
style E fill:#a29bfe,stroke:#8b83e5,color:#fff
style F fill:#fd79a8,stroke:#e84393,color:#fff
style G fill:#e17055,stroke:#d63031,color:#fff
每道防线的核心能力
| 防线 | 技术载体 | 速度 | 可控性 | 适用资源 |
|---|---|---|---|---|
| L1 Memory Cache | 浏览器内存 (RAM) | 0ms | 被动 (浏览器控制) | 页面内已加载的任意资源 |
| L2 Service Worker | Cache API / JS 逻辑 | <5ms | 完全可控 (你写代码) | 任意 HTTP 请求 |
| L3 HTTP Disk Cache | 浏览器磁盘 | 10-50ms | 半可控 (HTTP Header) | 静态资源为主 |
| L4 Push Cache | HTTP/2 Session | <100ms | 有限 (Server Push 触发) | 推送的关联资源 |
| L5 CDN Edge | 边缘节点 | 50-200ms | 半可控 (CDN 配置) | 静态 + 可缓存动态内容 |
| L6 Origin | 你的服务器 | 不确定 | 完全可控 | 所有内容 |
这里的关键哲学是:前一道防线拦截的请求越多,后面就越轻松。 就像一场接力赛——Memory Cache 跑第一棒 (0ms),它一个人就能干掉 60-80% 的请求。
三、第一道防线:Memory Cache —— 浏览器给你的"免费午餐"
Memory Cache 是浏览器自带的第一层,它把已经加载过的资源直接存在 RAM 里。注意:你完全不用写任何代码,浏览器自动帮你干。
它到底有多快?
graph LR
subgraph Memory["Memory Cache"]
M1["请求 → 命中"]
M2["⏱ 0ms"]
end
subgraph Disk["Disk Cache"]
D1["请求 → 读磁盘 → 解压 → 命中"]
D2["⏱ 10-50ms"]
end
subgraph Network["网络请求"]
N1["请求 → DNS → TCP → TLS → HTTP → 下载"]
N2["⏱ 50ms-3s+"]
end
M1 --> M2
D1 --> D2
N1 --> N2
style Memory fill:#00b894,color:#fff
style Disk fill:#fdcb6e
style Network fill:#e17055,color:#fff
0 毫秒是什么概念? 你眼睛还没眨完,数据已经到了。
但它有三个"死穴"
- 页面一关就没了。 内存嘛,断电即失。刷新页面?Memory Cache 还在。关掉 Tab?没了。
- 容量有限。 浏览器不会让你把几百 MB 都塞内存里,具体限制因浏览器而异,通常几十 MB。
- 你控制不了。 什么资源进 Memory Cache、什么时候被踢出去,完全是浏览器的内部决策。
那你能做什么?
虽然不能直接控制 Memory Cache,但你可以创造条件让它更好地工作:
- 代码分割 (Code Splitting):不要让一个 5MB 的 bundle 占满内存,拆成小份,用哪个加载哪个,不用的可以被踢掉。
- 图片懒加载:首屏之外的图片别急着加载,等用户滚到了再说。
- 复用相同的资源 URL:同一个 URL 只加载一次,第二次直接走 Memory Cache。
// 反面教材:每次生成不同 URL
const img = `https://cdn.example.com/avatar/${userId}.jpg?t=${Date.now()}`;
// → 每次都是"新资源",Memory Cache 永远不命中
// 正确做法:URL 保持一致
const img = `https://cdn.example.com/avatar/${userId}.jpg`;
// → 第二次请求直接命中 Memory Cache,0ms
注意那个
?t=${Date.now()}——这是一个真实存在的反模式。很多开发者为了避免 HTTP 缓存问题,给每个请求加时间戳,结果连 Memory Cache 也一起废了。
四、第二道防线:Service Worker —— 你的"客户端代理服务器"
如果 Memory Cache 是浏览器送你的免费午餐,那 Service Worker 就是你自己掌勺的厨房。
它到底是个什么东西?
Service Worker 是运行在浏览器后台的一段 JavaScript,它不依赖任何页面,可以拦截所有从当前域名发出的网络请求,然后决定:是返回缓存、发网络请求、还是自己造一个 Response。
sequenceDiagram
participant Page as 页面
participant SW as Service Worker
participant Cache as Cache API
participant Net as 网络
Page->>SW: fetch('/api/products')
alt 缓存命中
SW->>Cache: 查缓存
Cache-->>SW: 有,返回
SW-->>Page: 缓存数据 (<5ms)
else 缓存未命中
SW->>Net: 发起网络请求
Net-->>SW: 返回数据
SW->>Cache: 写入缓存
SW-->>Page: 返回数据
end
Note over Page,Net: 页面完全不知道数据来自缓存还是网络
四种经典缓存策略
不同场景需要不同的策略。没有银弹,但有一个武器库:
flowchart TD
Start["收到请求"] --> Decider{"选择策略"}
Decider -->|"静态资源<br/>文件名带 Hash"| CF["Cache First<br/>缓存优先"]
Decider -->|"实时数据<br/>股票/聊天"| NF["Network First<br/>网络优先"]
Decider -->|"次要内容<br/>头像/封面"| SWR["Stale-While-Revalidate<br/>先旧后新"]
Decider -->|"分析/日志"| NO["Network Only<br/>仅网络"]
CF --> CF_DESC["先查缓存,命中即返回<br/>未命中才走网络"]
NF --> NF_DESC["先走网络,成功返回<br/>失败才用缓存兜底"]
SWR --> SWR_DESC["立即返回缓存<br/>后台更新缓存"]
NO --> NO_DESC["直接发请求<br/>不碰缓存"]
style CF fill:#00b894,color:#fff
style NF fill:#0984e3,color:#fff
style SWR fill:#6c5ce7,color:#fff
style NO fill:#636e72,color:#fff
代码实战:一个生产级 SW 缓存层
// sw.js — 这不是玩具代码,是可以直接上生产的
const CACHE_NAMES = {
static: 'static-v2',
dynamic: 'dynamic-v1',
api: 'api-v1',
};
// 安装阶段:预缓存关键静态资源 (App Shell)
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAMES.static).then(cache => {
return cache.addAll([
'/',
'/app.js',
'/vendor.js',
'/styles.css',
'/offline.html',
]);
})
);
// 强制新 SW 立即接管,不等待旧 SW 释放的页面
self.skipWaiting();
});
// 激活阶段:清理旧版本缓存
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys => {
return Promise.all(
keys
.filter(key => !Object.values(CACHE_NAMES).includes(key))
.map(key => caches.delete(key))
);
})
);
// 让当前 SW 立即控制所有页面
self.clients.claim();
});
// 拦截请求:根据不同资源类型使用不同策略
self.addEventListener('fetch', event => {
const { request } = event;
const url = new URL(request.url);
// 静态资源:Cache First
if (request.destination === 'script' || request.destination === 'style') {
event.respondWith(cacheFirst(request, CACHE_NAMES.static));
return;
}
// 图片/字体:Stale-While-Revalidate
if (request.destination === 'image' || request.destination === 'font') {
event.respondWith(staleWhileRevalidate(request, CACHE_NAMES.dynamic));
return;
}
// API 请求:Network First with cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request, CACHE_NAMES.api));
return;
}
// 默认:Network First
event.respondWith(networkFirst(request, CACHE_NAMES.dynamic));
});
// === 策略函数 ===
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
return response;
}
async function networkFirst(request, cacheName) {
try {
const response = await fetch(request);
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
return response;
} catch (e) {
const cached = await caches.match(request);
if (cached) return cached;
return caches.match('/offline.html');
}
}
async function staleWhileRevalidate(request, cacheName) {
const cached = await caches.match(request);
// 后台更新(不阻塞返回)
const fetchPromise = fetch(request).then(response => {
caches.open(cacheName).then(cache => {
cache.put(request, response.clone());
});
return response;
}).catch(() => {});
// 立即返回缓存,如果没有缓存则等网络
return cached || fetchPromise;
}
效果对比:有 SW vs 没 SW
| 场景 | 无 Service Worker | 有 Service Worker | 提升 |
|---|---|---|---|
| 首次加载 (4G) | 3.2s | 3.4s (多了一点安装时间) | — |
| 二次加载 (4G) | 3.0s | 0.3s | 90% |
| 二次加载 (3G) | 5.5s | 0.4s | 93% |
| 断网/飞行模式 | 白屏/404 | 正常浏览 | ∞ |
二次加载 0.3 秒——用户连"正在加载"的 spinner 都来不及看到,页面就已经在了。这就是 Service Worker 的魔法。
一个你必须知道的坑
Service Worker 有一个"反直觉"的行为:第一次访问时它不起作用。因为 SW 本身要先下载、安装、激活——这些发生在页面加载之后。所以第一次访问是"裸奔"的,从第二次开始才是"开挂"的。
flowchart LR
V1["第 1 次访问"] --> D1["下载 SW → 安装 → 激活"]
D1 --> N1["SW 已激活,但本次页面<br/>已经在 SW 之前加载了"]
N1 --> R1["❌ 本次不拦截<br/>(刷新后生效)"]
V2["第 2 次访问"] --> D2["SW 已在运行"]
D2 --> R2["✅ 拦截所有请求<br/>秒开!"]
style R1 fill:#fab1a0,stroke:#e17055
style R2 fill:#55efc4,stroke:#00b894
五、第三道防线:HTTP Cache —— 被严重低估的性能利器
大多数人以为 HTTP 缓存就是"在响应头里加个 Cache-Control"。这句话对了一半——HTTP 缓存确实基于响应头,但它的机制远远不止"缓存一段时间"这么简单。
三层控制体系
graph TD
subgraph 强缓存["强制缓存:有效期内的绝对权威"]
F1["Cache-Control: max-age=31536000"]
F2["Expires: Tue, 19 May 2026 12:00:00 GMT"]
end
subgraph 协商缓存["协商缓存:过期后的验证机制"]
N1["ETag / If-None-Match"]
N2["Last-Modified / If-Modified-Since"]
end
subgraph 禁止缓存["禁用缓存:完全不走缓存"]
B1["Cache-Control: no-store"]
B2["Cache-Control: no-cache<br/>(注意:no-cache ≠ 不缓存!)"]
end
style 强缓存 fill:#00b894,color:#fff
style 协商缓存 fill:#fdcb6e
style 禁止缓存 fill:#e17055,color:#fff
强缓存:浏览器直接不问你
设置 Cache-Control: max-age=31536000 后,在这一年之内,浏览器连问都不问服务器,直接本地拿。这种行为叫"强缓存"。
sequenceDiagram
participant B as 浏览器
participant S as 服务器
Note over B,S: 强缓存 — max-age 未过期
B->>B: "这个文件还在有效期内"<br/>"不问了,直接用"
Note right of B: ⏱ 0ms,不产生网络请求
Note over B,S: 协商缓存 — max-age 已过期
B->>S: GET /app.js<br/>If-None-Match: "abc123"
alt 文件没变 (304)
S-->>B: HTTP 304 Not Modified<br/>(空 body,几十字节)
B->>B: "太好了,用本地缓存"
Note right of B: ⏱ ~50ms,只传了 header
else 文件变了 (200)
S-->>B: HTTP 200 OK<br/>(完整文件内容)
Note right of B: ⏱ ~200ms+,重新下载
end
304 Not Modified 是 HTTP 缓存体系里最精妙的设计:一个 2MB 的 JS 文件,如果没变,服务器只返回一个几十字节的 304 响应,浏览器拿着它去本地缓存里找到那个 2MB 的文件直接使用。2MB vs 几十字节——差了 5 万倍。
no-cache vs no-store:一个著名的新人陷阱
| 指令 | 含义 | 实际行为 |
|---|---|---|
no-cache | 可以缓存,但每次用之前要验证 | 浏览器会存到磁盘,但每次先问服务器"这文件变了没?"(304 机制) |
no-store | 完全不准缓存 | 不存磁盘、不用 304、每次都完整下载 |
must-revalidate | 过期后必须验证 | 配合 max-age 使用,过期前直接用,过期后必须 304 验证 |
immutable | 绝不会变 | 连 304 验证都省了——浏览器连 reload 都不发请求 |
# 不同资源的推荐配置
# HTML:永远验证
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate";
}
# 带 Hash 的静态资源:永久强缓存
location ~* \.[a-f0-9]{8,}\.(js|css|woff2)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 图片:长时间缓存
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
expires 180d;
add_header Cache-Control "public, max-age=15552000";
}
# API 响应:短时间私有缓存
location /api/ {
add_header Cache-Control "private, max-age=60, must-revalidate";
}
文件 Hash 改名:缓存更新的终极方案
你也许会问:"如果缓存了一年,我中间更新了文件怎么办?"
答案是不更新同名文件——而是直接改文件名:
旧:bundle.js → 浏览器缓存了一年,永远不会重新下载
新:bundle.a7f3c8.js → 全新的 URL,浏览器把它当新资源下载
HTML 里引用的是新文件名,所以用户拿到的是新版。旧文件的缓存自然过期(一年后没人引用了,浏览器自动清理)。
这也是为什么 Webpack/Vite 默认给 JS/CSS 加 content hash——这是缓存策略与构建工具的完美配合。
// webpack.config.js 或 vite.config.js
export default {
build: {
rollupOptions: {
output: {
// 关键配置:给入口 chunk 加 hash
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
};
六、第四、五道防线:Push Cache 与 CDN
HTTP/2 Server Push:已经被废弃的"加速器"
HTTP/2 Server Push 曾经被寄予厚望:服务器可以"主动推送"资源给浏览器,而不等浏览器请求。比如你请求 index.html,服务器说"等等,你还用得着 style.css 和 app.js,我先一并给你了"。
flowchart LR
subgraph 传统["HTTP/1.1 传统流程"]
T1["GET index.html"] --> T2["等待响应"]
T2 --> T3["解析 HTML"]
T3 --> T4["发现需要 style.css"]
T4 --> T5["GET style.css"]
end
subgraph Push["HTTP/2 Server Push"]
P1["GET index.html"] --> P2["服务器主动推送"]
P2 --> P3["index.html + style.css + app.js<br/>一次性全部到达"]
end
style 传统 fill:#fab1a0,stroke:#e17055
style Push fill:#55efc4,stroke:#00b894
但现实很骨感:Chrome 在 2022 年移除了 Server Push 支持。原因很现实——开发者经常推错(推送了浏览器已经缓存的资源),反而浪费了带宽。
2024 年,业界的方向转向了 103 Early Hints——服务器先发一个 hint 告诉浏览器"你可能需要这些资源",浏览器再自己决定要不要下载。主动权回到了浏览器手里。
CDN:距离用户最近的"快递站"
CDN 的原理简单到一句话就能说清:把服务器搬到离用户最近的地方。
但背后的效果是惊人的:
| 场景 | 直连源站 | 通过 CDN |
|---|---|---|
| 北京 → 硅谷 (静态资源) | 800ms-3s | 50-100ms (北京边缘节点) |
| 东京 → 伦敦 (图片) | 600ms-2s | 40-80ms (东京边缘节点) |
| 移动 3G → 源站 | 2-5s | 100-300ms (本地边缘节点) |
graph TD
subgraph 没用CDN["没用 CDN"]
U1["东京用户"] -->|"跨越半个地球"| U2["纽约源服务器"]
U2 -->|"800ms-3s"| U3["😤 等半天"]
end
subgraph 用了CDN["用了 CDN"]
C1["东京用户"] -->|"就近接入"| C2["东京 CDN 节点"]
C2 -->|"首次回源"| C3["纽约源服务器"]
C2 -->|"之后命中缓存"| C4["直接返回"]
C3 -->|"50-80ms"| C5["😊 秒开"]
C4 -->|"10-30ms"| C5
end
style U3 fill:#e17055,color:#fff
style C5 fill:#00b894,color:#fff
CDN 缓存的正确配置姿势
# CDN 边缘节点的缓存策略
# 关键 Header 解读:
# Cache-Control: s-maxage=86400 → CDN 节点缓存 1 天
# Cache-Control: max-age=0 → 浏览器每次要验证
# Surrogate-Control → CDN 专用缓存控制 (部分 CDN 支持)
location /api/public/ {
# 公开 API:CDN 缓存 1 小时,浏览器缓存 5 分钟
add_header Cache-Control "public, max-age=300, s-maxage=3600";
}
location /api/private/ {
# 私有 API:CDN 不缓存,浏览器缓存 60 秒
add_header Cache-Control "private, max-age=60";
}
s-maxage是给 CDN 看的,max-age是给浏览器看的。同一个响应,CDN 和浏览器可以有不同的缓存时间——这是很多人不知道的细节。
七、各代存储方案全面对比:选对武器比拼命更重要
缓存"存哪里"和"怎么缓存"一样重要。前端一共有 6 种存储方案,每种都有自己的脾气。
横向评测
| 特性 | Memory | Cache API | IndexedDB | LocalStorage | SessionStorage | Cookie |
|---|---|---|---|---|---|---|
| 容量 | 几十 MB | 浏览器磁盘的 50%+ | 浏览器磁盘的 50%+ | 5MB | 5MB | 4KB |
| 读写模式 | 同步 | 异步 | 异步 | 同步 (卡主线程!) | 同步 | 同步 |
| 数据类型 | 任意 | Response 对象 | 结构化克隆 | 字符串 | 字符串 | 字符串 |
| 生命周期 | Tab 关闭 | 手动管理 | 手动管理 | 永久 | Tab 关闭 | 可配置 |
| 跨域访问 | 同源 | 同源 | 同源 | 同源 | 同 Tab | 可跨域 |
| 自动发送 | — | — | — | — | — | 每次请求都带 |
| 最佳场景 | 页面内高频数据 | HTTP 请求/响应 | 大量结构化数据 | 浅层偏好设置 | 表单草稿 | 认证 Token |
关键性能差距(实测数据)
gantt
title 写入 1000 条数据的耗时对比(毫秒)
dateFormat X
axisFormat %s
section IndexedDB
45ms :0, 45
section Cache API
60ms :0, 60
section LocalStorage
320ms :0, 320
section Cookie
1800ms :0, 1800
看到没?LocalStorage 比 IndexedDB 慢了 7 倍,却因为"简单"被更多人使用。Cookie 更是拉胯到让人绝望——4KB 的容量、每次请求自动带上的带宽浪费、纯同步的 IO——它唯一的合理用途就是存储 Session Token。
各方案的典型误用
// ❌ 反面案例 1:用 LocalStorage 存 API 响应
localStorage.setItem('products', JSON.stringify(hugeData));
// 问题:5MB 限制、同步阻塞主线程、只能存字符串
// ✅ 正确姿势:用 IndexedDB 或 Cache API
const cache = await caches.open('api-cache');
await cache.put('/api/products', new Response(JSON.stringify(hugeData)));
// ❌ 反面案例 2:用 LocalStorage 存图片
const base64 = canvas.toDataURL();
localStorage.setItem('avatar', base64);
// 问题:5MB 存不了几张图,base64 还比原图大 33%
// ✅ 正确姿势:IndexedDB 存 blob
const db = await openDB('media-store');
await db.put('images', blob, 'avatar');
// ❌ 反面案例 3:用 Cookie 存用户偏好
document.cookie = 'theme=dark;path=/';
// 问题:每次请求都带这个 Cookie,浪费带宽
// 问题:4KB 容量限制,未来想加更多设置必然不够
// ✅ 正确姿势:LocalStorage 存偏好,只有 Token 放 Cookie
localStorage.setItem('theme', 'dark');
八、决策框架:你的资源该走哪条路?
面对一个资源,不要靠直觉拍板,用这套决策流程:
flowchart TD
Start["面对一个资源"] --> Q1{"这个资源有版本号/Hash 吗?"}
Q1 -- "有 (app.a1b2.js)" --> Q2{"用户看到旧版会怎样?"}
Q2 -- "可以接受短暂不一致" --> S1["强缓存 1 年<br/>Cache: immutable"]
Q2 -- "必须立即生效" --> S2["强缓存 1 年 + HTML no-cache<br/>通过改 HTML 引用触发更新"]
Q1 -- "没有" --> Q3{"包含敏感数据吗?"}
Q3 -- "是 (个人信息/财务)" --> Q4["no-store<br/>或 private + max-age=0"]
Q3 -- "否" --> Q5{"变更频率?"}
Q5 -- "几乎不变" --> Q6{"当前访问量?"}
Q6 -- "高 (热门资源)" --> S3["强缓存 30 天<br/>+ CDN 缓存 7 天"]
Q6 -- "低" --> S4["协商缓存<br/>ETag + no-cache"]
Q5 -- "偶尔变 (每天/每周)" --> S5["短时强缓存<br/>max-age=3600 + must-revalidate"]
Q5 -- "频繁变 (实时)" --> Q7{"离线可用重要吗?"}
Q7 -- "重要" --> S6["SW + Network First<br/>+ Cache 兜底"]
Q7 -- "不重要" --> S7["仅 Network<br/>+ 内存短时缓存"]
style S1 fill:#00b894,color:#fff
style S2 fill:#00b894,color:#fff
style S3 fill:#00b894,color:#fff
style S4 fill:#fdcb6e
style S5 fill:#0984e3,color:#fff
style S6 fill:#6c5ce7,color:#fff
style S7 fill:#636e72,color:#fff
速查表:拿起来就能用
| 资源类型 | HTTP Header | SW 策略 | 额外存储 | 过期策略 |
|---|---|---|---|---|
| HTML 入口文件 | no-cache | Network First | — | 每次都验证 |
| 带 Hash 的 JS/CSS | immutable, max-age=31536000 | Cache First | — | 永远有效,通过改 Hash 更新 |
| 不带 Hash 的 JS/CSS | no-cache (不推荐不带 Hash) | — | — | 每次验证 |
| Logo / 图标 | max-age=31536000 | Cache First | — | 通过改 URL 更新 |
| 用户上传的图片 | max-age=86400 | SWR | — | 1 天后重新验证 |
| 用户个人信息 API | private, max-age=300 | Network First | 内存 + SW | 5 分钟后重新获取 |
| 商品列表 API | public, max-age=60 | SWR | IndexedDB | 1 分钟后后台更新 |
| 实时行情 API | no-store | — | 仅内存 (5s TTL) | 极短或不缓存 |
| 字体文件 | immutable, max-age=31536000 | Cache First | — | 永久 (字体不会变) |
| Service Worker 文件 | no-cache (浏览器强制) | — | — | 浏览器每 24h 强制检查 |
九、实战:把理论变成代码的三个经典场景
场景一:电商首页 —— 首屏 0.3 秒的秘密
背景:一个电商首页,bundle.js (2.3MB)、商品图片 (50+ 张)、推荐 API (3 个)。用户从搜索结果进来、退出到首页、又点进商品——来回切换。
优化前:
- bundle.js 每次重新下载(无 Hash + 无强缓存)
- 商品图片每次都重新请求
- API 每次都打到源服务器
优化后架构:
graph TD
subgraph 优化后["优化后的请求路径"]
R["用户访问首页"] --> L1{"bundle.js<br/>带 Hash + immutable"}
L1 -- "命中 CDN" --> H1["10ms 返回"]
R --> L2{"商品图片<br/>CDN + 强缓存"}
L2 -- "命中 CDN" --> H2["15ms 返回"]
R --> L3{"推荐 API<br/>SW (SWR)"}
L3 -- "立即返回旧数据" --> H3["<5ms"]
L3 -- "后台更新" --> H4["静默刷新"]
end
style H1 fill:#00b894,color:#fff
style H2 fill:#00b894,color:#fff
style H3 fill:#00b894,color:#fff
// 核心实现:带内存缓存 + SW 双重加速的请求层
class CacheManager {
#memoryCache = new Map();
// L1: 内存缓存 (0ms, 同页面内复用)
async fetch(url, { ttl = 5000, strategy = 'swr' } = {}) {
const memHit = this.#checkMemory(url, ttl);
if (memHit) return memHit;
// L2: 走 SW (SWR 策略,<5ms 返回,后台更新)
const response = await fetch(url);
const data = await response.clone().json();
this.#memoryCache.set(url, { data, ts: Date.now() });
return data;
}
#checkMemory(url, ttl) {
const entry = this.#memoryCache.get(url);
if (entry && Date.now() - entry.ts < ttl) {
return entry.data;
}
return null;
}
// 预取:在用户可能访问之前拉取数据
async prefetch(urls) {
return Promise.all(urls.map(url => this.fetch(url)));
}
}
// 预判用户行为,提前加载
document.querySelectorAll('.product-card').forEach(card => {
card.addEventListener('mouseenter', () => {
const detailUrl = card.dataset.apiUrl;
cacheManager.prefetch([detailUrl]); // 鼠标悬停时预加载详情数据
});
});
效果对比:
| 指标 | 优化前 | 优化后 | 用户感知 |
|---|---|---|---|
| 首屏加载 (重复访问) | 3.5s | 0.3s | "秒开" |
| 首页→详情→返回 | Loading 每次 +1s | 瞬间切换 | "丝般顺滑" |
| API 服务器 QPS | 5000 | 800 | 压力降低 84% |
| 弱网下 (3G) | 8.2s | 1.1s | 从"不可用"到"还能用" |
场景二:离线优先的笔记应用
背景:一个 Note App,用户在飞机上想继续写笔记。
// 离线优先架构的核心:所有写操作先落地到本地,再同步到服务器
class OfflineFirstStore {
constructor() {
this.dbPromise = this.#initDB();
}
async #initDB() {
const db = await idb.openDB('notes-app', 1, {
upgrade(db) {
const store = db.createObjectStore('notes', { keyPath: 'id' });
store.createIndex('by_sync_status', 'syncStatus');
store.createIndex('by_updated', 'updatedAt');
},
});
return db;
}
// 保存笔记:先写本地 (0ms),再异步同步
async saveNote(note) {
const db = await this.dbPromise;
note.syncStatus = 'pending';
note.updatedAt = Date.now();
await db.put('notes', note); // 立即写入本地 (用户感知 0ms)
// 后台同步 (不阻塞用户操作)
this.#syncToServer(note).catch(() => {
// 同步失败?没关系,下次在线时自动重试
this.#scheduleRetry(note);
});
return note;
}
// 列出所有笔记:直接从本地读
async listNotes() {
const db = await this.dbPromise;
return db.getAll('notes');
}
async #syncToServer(note) {
const response = await fetch('/api/notes', {
method: 'PUT',
body: JSON.stringify(note),
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
const db = await this.dbPromise;
await db.put('notes', { ...note, syncStatus: 'synced' });
}
}
// 利用 Background Sync API:即使页面关闭了也会在恢复网络时执行
async #scheduleRetry(note) {
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-notes');
}
}
}
场景三:新闻 Feed —— 用"即时响应,后台更新"抹去等待
背景:信息流应用,用户下拉刷新的等待时间直接决定留存率。
// Stale-While-Revalidate 是最适合 Feed 流的策略
async function fetchFeed(feedType = 'top') {
const cacheKey = `/api/feed/${feedType}`;
// 第 1 步:立即返回缓存中的旧数据 (如果有)
const cached = await getCachedFeed(cacheKey);
if (cached) {
// 先渲染旧数据——用户立刻看到内容
renderFeed(cached.data);
// 悄悄地更新缓存 (用户无感知)
updateFeedInBackground(cacheKey).catch(console.error);
return cached.data;
}
// 第 2 步:没有缓存,正常请求并写入缓存
const fresh = await fetch(cacheKey).then(r => r.json());
await cacheFeed(cacheKey, fresh);
renderFeed(fresh);
return fresh;
}
async function updateFeedInBackground(cacheKey) {
const fresh = await fetch(cacheKey).then(r => r.json());
await cacheFeed(cacheKey, fresh, { ttl: 60000 }); // 缓存 1 分钟
// 用新数据替换页面内容 (DOM diff,只更新变化部分)
diffAndUpdateFeed(fresh);
}
SWR 策略的心理模型:
sequenceDiagram
participant User as 用户
participant UI as 页面
participant Cache as 缓存
participant Server as 服务器
User->>UI: 下拉刷新
UI->>Cache: 有旧数据吗?
Cache-->>UI: 有!返回旧数据 (立即)
UI-->>User: 📰 页面立即显示内容
UI->>Server: 后台请求新数据
Server-->>UI: 新数据来了
UI->>Cache: 更新缓存
UI->>UI: diff + DOM 更新
UI-->>User: 🔄 内容无感更新
Note over User,Server: 用户全程看到的是"内容瞬间出来然后刷新"<br/>而不是"loading spinner → 内容出来"
对比:
| 策略 | 首次加载体验 | 后续加载体验 | 用户心理 |
|---|---|---|---|
| 纯网络 (无缓存) | 看 spinner 转 | 看 spinner 转 | "怎么这么慢" |
| Cache First | 看 spinner 转 | 瞬间旧内容 | "怎么还是昨天的" |
| Network First | 看 spinner 转 | 看 spinner 转 (直到网络返回) | "跟没用缓存一样" |
| Stale-While-Revalidate | 看 spinner 转 | 瞬间显示 + 无感更新 | "也太快了吧!" |
十、监控与调试:你的缓存"战况"可视化
没有监控的优化是玄学。你需要在每个环节知道:命中了没?
浏览器内置武器
1. Chrome DevTools Network 面板
Size 列的秘密:
- "memory cache" → Memory Cache 命中
- "disk cache" → HTTP Disk Cache 命中
- "service worker" → Service Worker 拦截
- "真实数字 (如 245 kB)" → 从网络下载的
2. Application → Cache Storage
可以直接浏览 Service Worker 中 Cache API 存储的所有内容,手动增删查。
3. Application → IndexedDB
浏览 IndexedDB 里的数据库和数据,调试离线存储问题。
自建监控体系
// 页面端:上报关键性能指标
const cacheMetrics = {
// 记录每次请求走了哪层缓存
reportCacheHit(url, layer) {
navigator.sendBeacon('/api/analytics/cache', JSON.stringify({
url,
layer, // 'memory' | 'sw' | 'disk' | 'network'
timestamp: Date.now(),
pageUrl: location.pathname,
}));
},
// 统计页面加载中缓存命中率
async measureCacheHitRate() {
const entries = performance.getEntriesByType('resource');
const stats = entries.reduce((acc, entry) => {
// transferSize = 0 → 从缓存加载
if (entry.transferSize === 0) {
acc.hit++;
} else {
acc.miss++;
}
return acc;
}, { hit: 0, miss: 0 });
const hitRate = (stats.hit / (stats.hit + stats.miss) * 100).toFixed(1);
console.log(`缓存命中率: ${hitRate}% (${stats.hit} hits / ${stats.hit + stats.miss} total)`);
return hitRate;
},
};
// Service Worker 端:上报缓存事件
self.addEventListener('fetch', event => {
const startTime = Date.now();
event.respondWith(
caches.match(event.request).then(cached => {
const servedFrom = cached ? 'sw-cache' : 'network';
const latency = Date.now() - startTime;
// 批量上报缓存统计
self.cacheStats = self.cacheStats || [];
self.cacheStats.push({ url: event.request.url, servedFrom, latency });
return cached || fetch(event.request);
})
);
});
# Nginx 端:添加缓存状态 Header,方便调试
location / {
add_header X-Cache-Status $upstream_cache_status;
add_header X-Cache-TTL "max-age=31536000";
# $upstream_cache_status 的可能值:
# HIT → CDN 缓存命中
# MISS → CDN 缓存未命中 (回源了)
# BYPASS → 绕过了 CDN 缓存
# EXPIRED → CDN 缓存过期,回源验证后返回了新的
}
缓存效果仪表盘
一个理想的监控面板应该追踪这些指标:
| 指标 | 计算方式 | 健康值 | 告警阈值 |
|---|---|---|---|
| 整体缓存命中率 | HIT / (HIT + MISS) | >85% | <60% |
| Memory Cache 命中率 | mem_hit / total | >50% | <30% |
| SW 缓存命中率 | sw_hit / total | >30% | <10% |
| CDN 命中率 | cdn_hit / (cdn_hit + backend) | >90% | <70% |
| 平均页面加载时间 | FCP / LCP | <1s | >3s |
| 304 占比 | 304 / (304 + 200) | 越高越好 | <20% (缓存策略太保守) |
十一、避坑指南:我在生产环境流的血
坑 1:缓存了 HTML,把整站"锁死"了
用户访问 index.html (Cache: max-age=31536000)
→ 浏览器缓存了 1 年
→ 你发了新版本 (页面引用新 bundle.a2b3c4.js)
→ 用户看到的还是旧 HTML (引用的还是旧 bundle.a1b2c3.js)
→ 旧 bundle 在 CDN 上已经清理了
→ 用户访问 → 404 → 页面白屏
→ 你被凌晨 3 点的报警电话吵醒
教训:HTML 永远用 no-cache。这是整个缓存体系里最重要的规则。
坑 2:API 没设 private,CDN 泄露了用户数据
用户 A 登录 → GET /api/user/profile
CDN 缓存了这个响应
用户 B (没有登录) → GET /api/user/profile
CDN 返回了用户 A 的个人信息
教训:涉及用户身份的数据,响应头必须加 private。
坑 3:Service Worker 里的 skipWaiting() 导致的隐身 bug
// 更新 SW 时,旧 SW 还在控制已打开的页面
// 如果新 SW 的缓存结构不一样 (比如 cacheName 变了)
// skipWaiting() + clients.claim() 可能导致:
// → 旧页面还在用旧逻辑
// → 新 SW 已经接管,返回了新格式的缓存数据
// → 页面报错:Uncaught TypeError: data.items is not iterable
教训:SW 更新时要考虑数据格式兼容性,或者让用户刷新页面。
坑 4:忽略 Vary 响应头
同一个 URL → 不同 Accept-Encoding 可能返回不同内容
如果你 CDN 缓存了 gzip 版本的响应
而某个客户端只支持 br (Brotli)
→ 收到了 gzip 内容但以为它是 br → 解压失败
教训:如果响应的内容取决于某个请求头,务必在响应中声明 Vary: Accept-Encoding。
坑 5:IndexedDB 在隐私模式下不可用
Safari 隐私模式 → IndexedDB 抛异常
你的离线优先应用 → 直接 crash
教训:始终 try-catch 包装 IndexedDB 操作,并提供降级方案 (内存 Map)。
完整检查清单
flowchart TD
CHECK["上线前的缓存检查清单"] --> C1{"HTML 文件使用 no-cache 了吗?"}
C1 -->|否| F1["🔴 立刻改!"]
C1 -->|是| C2{"静态资源文件名带 Hash 了吗?"}
C2 -->|否| F2["🔴 配置构建工具!"]
C2 -->|是| C3{"API 响应区分 public/private 了吗?"}
C3 -->|否| F3["🟡 排查敏感 API"]
C3 -->|是| C4{"设置了 Vary 头吗?"}
C4 -->|否| F4["🟡 检查内容协商"]
C4 -->|是| C5{"SW 有离线 fallback 吗?"}
C5 -->|否| F5["🟡 至少准备 offline.html"]
C5 -->|是| C6{"加了缓存状态监控吗?"}
C6 -->|否| F6["🟡 加 X-Cache-Status"]
style F1 fill:#e17055,color:#fff
style F2 fill:#e17055,color:#fff
style F3 fill:#fdcb6e
style F4 fill:#fdcb6e
style F5 fill:#fdcb6e
十二、一句话总结
graph TD
subgraph 分层["缓存是分层防御体系"]
T1["Memory Cache: 0ms,免费但不可控"]
T2["Service Worker: <5ms,完全可控,但要写代码"]
T3["HTTP Cache: 10-50ms,配置文件搞定,最被低估"]
T4["CDN: 50-200ms,花钱解决地理问题"]
T5["IndexedDB: 离线数据仓库,容量几乎无限制"]
end
subgraph 核心["三条黄金法则"]
R1["HTML 永不强缓存 (no-cache)"]
R2["静态资源加 Hash + immutable"]
R3["私有数据加 private + 定时过期"]
end
subgraph 结果["最终效果"]
E1["首屏 0.3s (目标 <1s)"]
E2["服务器 QPS 降 80%"]
E3["弱网可用 (离线不白屏)"]
E4["用户留存 ↑,跳出率 ↓"]
end
分层 --> 核心 --> 结果
style 分层 fill:#6c5ce7,color:#fff
style 核心 fill:#0984e3,color:#fff
style 结果 fill:#00b894,color:#fff
缓存不是什么高深莫测的黑科技——它是对"一个请求到底要不要发出去"这个简单问题的系统性思考。
从今天的 Production 开始,打开你的 Network 面板,看每一个请求,问自己:
"这个请求,真的需要发出去吗?"
答案很多时候是:不用。
本文所有性能数据基于 Chrome 132 + macOS 环境实测,CDN 延迟数据参考全球 CDN 服务商公开 benchmark。你的实际数据会因网络环境、硬件配置、项目复杂度而有所不同,但缓存带来的数量级提升在任何环境下都成立。
建议收藏本文,下次有人跟你说"我的页面好慢"的时候,直接甩过去。