网络接口存储架构
# 11.Web API 网络与存储架构
📍 上接第 10 篇《浏览器渲染像素之路》。像素怎么上屏已了然。本文回答:上屏的数据从哪来、存哪里——fetch 怎么流式读取?WebSocket 心跳怎么设计?IndexedDB 为什么 API 这么难用?
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. fetch详解
- 4. WebSocket
- 5. SSE(Serv
- 6. Web存储详解
- 7. IndexedDB
- 8. Cache缓存
- 9. 存储选型决策矩阵
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 PWA 离线打开
先看一个真实的生产事故——一个 PWA 新闻阅读器,上线一个月后,客服收到大量投诉:"飞机上打开 App,页面一片空白"。
// service-worker.js —— 初版缓存策略
self.addEventListener('install', event => {
event.waitUntil(
caches.open('v1').then(cache =>
cache.addAll(['/', '/app.js', '/styles.css', '/api/news']) // ← 把 API 也缓存了
)
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request) // ← Cache-First:永远先看缓存
.then(cached => cached || fetch(event.request))
);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
现象:
- 首次安装:正常运行,JavaScript/API 响应都缓存了
- 24 小时后:在线打开正常(因为
/api/news返回了新数据) - 离线打开:白屏——缓存里的
/api/news是 24 小时前的旧数据,而那份旧 AJAX 响应里引用了一张 CDN 图片——CDN 把老图片删了,<img>onError 没处理,整个渲染中断
直觉怀疑:是不是缓存出错了?caches.match 是不是匹配逻辑有问题?打开 Chrome DevTools → Application → Cache Storage,发现 /api/news 的缓存还在,响应状态码 200,Body 也完整——缓存本身没坏。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:Cache-First 策略本身有问题?——不,Cache-First 对静态资源(JS/CSS/字体)是正确的。问题在于对 API 响应也用了 Cache-First——API 的数据会过期,但缓存不会自动刷新。
- 假设 2:那为什么在线打开正常?——因为在线时
fetch(event.request)拿了最新数据,更新了 DOM。但因为 SW 代码里没有把新响应写回缓存,缓存里的旧数据永远不更新。 - 假设 3:为什么换了 Cache-First 还是白屏?——因为
/api/news只在install时被缓存了一次。install之后再也没有更新缓存的逻辑——这个缓存策略等于**"缓存即冻结"**。 - 假设 4:这个问题的根因不是 SW 的 bug——是缓存策略选错了。API 响应应该用 Stale-While-Revalidate 或 Network-First,而不是 Cache-First。
# 1.3 我们要回答什么
这一段事故里至少藏着 6 个原理点:
① fetch 怎么流式读取?为什么没有 onprogress? → 第 3 章
② WebSocket 帧协议长什么样?心跳 + 退避重连怎么设计? → 第 4 章
③ SSE vs WS:什么时候单工就够? → 第 5 章
④ 五种存储(Cookie/ls/ss/IDB/Cache)各自的容量/生命周期/API形态 → 第 6~9 章
⑤ Cache-First / Network-First / SWR 到底怎么选? → 第 8 章
⑥ IndexedDB 为什么 API 这么难用?事务为什么自动提交? → 第 7 章
2
3
4
5
6
本篇路线:
架构总图(第 2 章)
↓
网络层 fetch/WS/SSE(第 3~5 章)──→ 解开"数据怎么从服务端到达浏览器"
↓
存储层 Cookie/ls/ss/IDB/Cache(第 6~9 章)──→ 解开"五把存储利器,各用在哪"
↓
综合案例(第 10 章)──→ 案例彻底剖开 + 离线优先方案 + 哲学回扣
2
3
4
5
6
7
📌 本篇定位:上接渲染管线篇(像素怎么上屏),下接 Node 运行时篇。读完本篇后,你能画出"从 HTTP 请求到像素上屏"的完整数据路径:网络获取 → 内存处理 → 持久化存储 → 离线读取 → 渲染到屏幕上。这是 Web 应用的"数据工程"——不是怎么写 UI,而是怎么让数据在正确的时间、以正确的形态、出现在正确的地方。
# 2. 架构全景概览
# 2.1 网络层(fetch
┌───────────────────────────────────────────────────────────────┐
│ 浏览器数据平面全景图 │
├───────────────────────────────────────────────────────────────┤
│ │
│ 网络层——数据从哪来(输入通道) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌───────────────┐ ┌───────────────┐ ┌─────────────┐ │ │
│ │ │ fetch() │ │ WebSocket │ │ EventSource│ │ │
│ │ │ 请求/响应模型 │ │ 全双工帧协议 │ │ 单工推送 │ │ │
│ │ │ ReadableStream│ │ ws:// wss:// │ │ HTTP SSE │ │ │
│ │ │ + AbortController│ │ │ 自动重连 │ │ │
│ │ └───────┬───────┘ └───────┬───────┘ └──────┬──────┘ │ │
│ │ │ │ │ │ │
│ │ └──────────────────┼──────────────────┘ │ │
│ │ │ │ │
│ └─────────────────────────────┼───────────────────────────┘ │
│ │ 数据到达 JS 线程 │
│ ▼ │
│ 存储层——数据存哪里(持久化通道) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Cookie localStorage sessionStorage IndexedDB Cache API │
│ │ ┌─────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌───────┐│
│ │ │~5KB │ │ ~5MB │ │ ~5MB │ │~几百MB │ │~几百MB││
│ │ │附带 │ │ 同步 API │ │ Tab关闭 │ │ 异步事务│ │SW专用 ││
│ │ │请求 │ │ 永久 │ │ 即清除 │ │ 可索引 │ │可拦截 ││
│ │ └─────┘ └──────────┘ └──────────┘ └─────────┘ └───────┘│
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 2.2 数据生命周期
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 网络到达 │ ──→ │ 内存暂存 │ ──→ │ 持久化 │ ──→ │ 离线消费 │
│ (fetch/WS)│ │ (JS变量) │ │ (IDB/Cache)│ │ (ServiceW) │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
~200ms ~0ms ~10ms ~0ms
有网络时 总是存在 写入一次 离线时
2
3
4
5
6
每一步的生命周期不同、性能代价不同、可靠性要求不同——这就是为什么浏览器提供五种存储方案:没有一种能同时满足"容量大、写入快、自动同步、安全隔离"四个要求。
# 2.3 为什么浏览器的存
疑惑:直接给浏览器一个 SQLite,让开发者自己建表查询不行吗?为什么要搞 Cookie、localStorage、IndexedDB、Cache API 这么多种?
论证:
安全隔离需要——Cookie 因为自动附带请求的特性,天然被 CSRF/XSS 攻击利用。所以必须有 HttpOnly(JS 不可读)、Secure(仅 HTTPS)、SameSite(禁第三方)这三道防线。把 Cookie 和 localStorage 合二为一,等于让 XSS 能偷到所有本地数据。
生命周期不同——会话数据(表单草稿)应该随 Tab 关闭而销毁(sessionStorage);用户偏好(主题/语言)应该永久保留(localStorage)。如果只有一种存储,开发者需要在应用层管理"哪些数据该活多久"——历史上已经证明开发者做不到。
读写模式不同——localStorage 是同步的(每次
setItem都阻塞主线程),在大数据量时会造成渲染卡顿。IndexedDB 是异步的(所有操作走事件/回调),不阻塞主线程。同步/异步不是实现细节——它决定了这个 API 能在什么场景下使用。访问控制不同——Cache API 只能被它所属的 Service Worker 访问——页面 JS 不能直接读写 Cache API。这是一道安全边界:缓存策略逻辑和业务逻辑完全隔离。
反向验证:WeChat 小程序把
wx.setStorage设计成异步的(吸取了 localStorage 同步阻塞的教训),但它只有一种存储——于是开发者在"存 1KB 用户偏好"和"存 50MB 离线包"时用同一个 API,容量管理全靠开发者自觉。实践结果是:大量小程序的 storage 被单个大文件撑爆。
结论:五种存储不是因为"标准委员会各派势力妥协出来的"——它们各自的生命周期、容量、API 形态、安全隔离性、并发行为,对应着 Web 应用中五类完全不同性质的数据。存储不是"找一个最大的往里面塞",而是"给每类数据找到生命周期和访问模式都匹配的那个容器"。
# 3. fetch详解
# 3.1 Request
疑惑:fetch 返回的 Response 对象,为什么 .json() 只能读一次?
论证:
const res = await fetch('/api/data');
const data1 = await res.json(); // ✅ 正常
const data2 = await res.json(); // ❌ TypeError: body stream already read
// 原因:Response.body 是一个 ReadableStream,只能被消费一次
// .json() / .text() / .blob() 都是"读取流到底"的操作
// 如果你需要读多次——先 clone()
const res2 = await fetch('/api/data');
const clone = res2.clone(); // tee 了一个新的 stream
const data3 = await res2.json(); // 消费原始 stream
const data4 = await clone.json(); // 消费克隆 stream ✅
2
3
4
5
6
7
8
9
10
11
Response 对象的核心结构:
Response
├── status: 200 ← HTTP 状态码
├── statusText: "OK" ← 状态文本
├── ok: true ← 200-299 时为 true
├── headers: Headers ← 响应头(可迭代)
├── body: ReadableStream ← 响应体(核心——流)
│ └── getReader() ← 手动读取 chunk
├── .json() ← 消费流 → 解析 JSON
├── .text() ← 消费流 → 文本
├── .blob() ← 消费流 → Blob
├── .arrayBuffer() ← 消费流 → ArrayBuffer
├── .formData() ← 消费流 → FormData
└── .clone() ← tee 一个新流(不消费原始流)
2
3
4
5
6
7
8
9
10
11
12
13
关键陷阱:
// ❌ fetch 不会 reject HTTP 4xx/5xx —— 只 reject 网络错误
const res = await fetch('/api/404');
console.log(res.ok); // false
// 代码继续执行——你必须手动检查!
// ✅ 正确的模式
const res = await fetch('/api/data');
if (!res.ok) {
// 即使是 4xx/5xx,body 仍然可读(可能有错误详情)
const errBody = await res.text();
throw new Error(`HTTP ${res.status}: ${errBody}`);
}
const data = await res.json();
2
3
4
5
6
7
8
9
10
11
12
13
# 3.2 流式读取:get
疑惑:为什么 fetch 没有 onprogress 回调?XHR 明明有。
论证:
XMLHttpRequest 的 onprogress 是在每次收到数据块时同步触发——它运行在主线程上,数据块的到达由浏览器网络栈直接推送。这是一种"事件推送"模式。
fetch 的设计哲学不同——它把响应体暴露为 ReadableStream,让开发者自己拉取数据。这不是倒退,而是把控制权交给开发者:你可以决定什么时候读、读多少、读到什么时候停。
const res = await fetch('/large-file.zip');
const total = +res.headers.get('Content-Length');
const reader = res.body.getReader();
let loaded = 0;
// 流式读取——你控制读多少、什么时候读
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
console.log(`Progress: ${(loaded / total * 100).toFixed(1)}%`);
// 你可以在这里:
// - 暂停读取(break)
// - 跳过大块(不 push chunks)
// - 把 value 直接写入 IndexedDB(不等全部下载完)
// - 在 worker 里处理已读到的数据
}
const blob = new Blob(chunks);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
流式 vs 事件推送的对比:
XHR onprogress | fetch getReader() | |
|---|---|---|
| 模式 | 事件推送(被动) | 主动拉取(主动) |
| 暂停/恢复 | ❌ 不支持 | ✅ await reader.read() 控制节奏 |
| 对流中数据的操作 | 只能累积 | 可以在读取过程中边读边处理(写入 IDB/渲染 etc) |
| 取消 | xhr.abort() | reader.cancel() + AbortController |
| 进度 | event.loaded / event.total | 手动累加 value.length |
结论:fetch 不给 onprogress 不是"做不了"——是它的设计理念不同:给你一个流,你自己决定怎么读。这不是缺少功能,而是把控制权从浏览器还给开发者。
# 3.3 AbortCon
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('/slow-api', { signal: controller.signal });
clearTimeout(timer);
return await res.json();
} catch (e) {
if (e.name === 'AbortError') {
console.log('Request was cancelled'); // 正常的取消——不是错误
} else {
throw e; // 真正的网络错误
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
AbortController 的底层原理:
controller.abort()
│
▼
AbortSignal.aborted = true
│
▼
浏览器网络栈收到 signal → 关闭底层 TCP 连接(发送 RST 包)
│
▼
fetch Promise → reject(DOMException: AbortError)
2
3
4
5
6
7
8
9
10
关键细节:一个 AbortController 可以同时传递给多个 fetch——批量取消。AbortSignal 还有静态方法 AbortSignal.timeout(5000)(浏览器较新 API),省去手动 setTimeout。
# 3.4 fetch 的反
| 反模式 | 为什么危险 | 修复 |
|---|---|---|
不检查 res.ok | 4xx/5xx 不会 reject,代码继续执行 → 后续 .json() 可能拿到错误页面的 HTML | if (!res.ok) throw new Error(...) |
| 没有超时 | 服务端 hang 住 → 浏览器默认超时可能几分钟,用户已经关了页面 | AbortController + setTimeout |
忘了 try/catch | 网络错误(DNS 失败 / 连接拒绝 / TLS 握手失败)会 reject,未捕获 → 静默吞掉 | 始终用 try/catch 或 .catch() |
| 并发请求串行化 | 3 个独立 API 各 200ms → 总耗时 600ms 而不是 200ms | Promise.all([fetch1, fetch2, fetch3]) |
未处理 AbortError | 把正常的取消当作异常上报 | if (e.name === 'AbortError') return |
# 4. WebSocket
# 4.1 帧格式:opco
疑惑:WebSocket 是基于 TCP 的,那为什么还有一个帧的概念?TCP 不已经把数据分好了吗?
论证:TCP 是字节流——没有消息边界。你在 TCP 上写"hello" 10 次,对端可能一次 read() 就拿到 "hellohellohello..."——需要应用层协议来区分"哪一段是一个完整消息"。帧就是 WS 的消息边界。
WebSocket 帧(RFC 6455 §5.2):
字节 0 字节 1 字节 2-3/9 字节 4+ / 10+
┌──────────┬──────────┬───────────────┬──────────────────┐
│ FIN(1bit)│ MASK(1bit)│ │ │
│ RSV1-3 │ 长度(7bit) │ 扩展长度(可选) │ mask-key(可选) │ 载荷数据
│ opcode │ │ 0-8 字节 │ 0 或 4 字节 │ (Payload)
│ (4bit) │ │ │ │
└──────────┴──────────┴───────────────┴──────────────────┘
2
3
4
5
6
7
8
帧头的关键字段:
| 字段 | 大小 | 含义 |
|---|---|---|
| FIN | 1 bit | 1 = 最后一片(消息结束)。0 = 还有后续帧(分片) |
| opcode | 4 bit | 0x1=文本帧, 0x2=二进制帧, 0x8=关闭帧, 0x9=ping, 0xA=pong |
| MASK | 1 bit | 客户端→服务器必须为 1(mask 防缓存投毒攻击);服务器→客户端为 0 |
| Payload length | 7/7+16/7+64 bit | ≤125: 直接写;126: 后续 2 字节是无符号 16 位长度;127: 后续 8 字节是 64 位长度 |
| Mask-key | 4 字节 | 仅当 MASK=1 时存在。客户端随机生成 4 字节 |
为什么客户端必须 mask——这不是安全加密,是防中间缓存投毒:
攻击者:在某个 HTTP 代理中伪造一个"看起来像 HTTP 请求"的 WS 帧
└→ 代理把伪造帧当作"真实请求"缓存了
└→ 每个请求这个缓存的用户都会收到这个伪造帧
mask 机制:客户端用 4 字节随机密钥异或载荷
└→ 攻击者无法控制"被代理看到的内容"(每帧密钥不同)
└→ 无法构造出"在代理看来是有效 HTTP 请求"的字节序列
2
3
4
5
6
7
# 4.2 心跳机制 + 自
疑惑:TCP 本身有 keep-alive,为什么 WebSocket 还要额外做心跳?
论证:TCP keep-alive 的默认探测间隔是 2 小时(Linux net.ipv4.tcp_keepalive_time = 7200),且它只在连接空闲时发探测包。关键问题是:
- TCP 半开连接——中间某个路由器 NAT 表项过期了,连接"看起来还在"但实际已经断了。TCP keep-alive 发现这个可能要 2 小时 11 分钟
- 应用层代理超时——Nginx/ALB 等反向代理有自己的空闲超时(通常 60s~300s),断了之后不会通知浏览器
- 移动端网络切换——WiFi → 4G 切换时,TCP 连接必然断开,但
ws.readyState仍可能是 OPEN
class RobustWS {
constructor(url, {
pingInterval = 25000, // 25s 发一次 ping
pongTimeout = 5000, // 5s 内必须收到 pong
maxDelay = 30000, // 最大重连间隔 30s
} = {}) {
this.url = url;
this.pingInterval = pingInterval;
this.pongTimeout = pongTimeout;
this.attempt = 0;
this.maxDelay = maxDelay;
this._connect();
}
_connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.attempt = 0;
this._startHeartbeat();
this.onOpen?.();
};
this.ws.onmessage = (e) => {
// 过滤心跳消息——不让业务层看到 ping/pong
if (e.data === 'pong') return;
this.onMessage?.(e);
};
this.ws.onclose = () => {
clearInterval(this._pingTimer);
clearTimeout(this._pongTimer);
this._reconnect();
};
this.ws.onerror = () => {}; // close 事件会跟在 error 后面
}
_startHeartbeat() {
this._pingTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send('ping');
// 5s 内如果没收到 pong → 主动断开(触发重连)
this._pongTimer = setTimeout(() => {
console.warn('Pong timeout——closing connection');
this.ws.close();
}, this.pongTimeout);
}
}, this.pingInterval);
}
_reconnect() {
this.attempt++;
// 指数退避:1s → 2s → 4s → 8s → 16s → 30s(封顶)
const base = Math.min(1000 * Math.pow(2, this.attempt - 1), this.maxDelay);
// jitter:在 50%~100% 之间随机——避免"惊群效应"
const delay = base * (0.5 + Math.random() * 0.5);
console.log(`Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.attempt})`);
setTimeout(() => this._connect(), delay);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
} else {
console.warn('Cannot send: not connected');
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
为什么 jitter 是必须的:
场景:服务器挂了 → 10000 个客户端同时检测到断连
├─ 无 jitter → 10000 个客户端全部在精确的 1s/2s/4s 后同时重连 → "惊群效应" → 服务器二次崩溃
├─ 有 jitter → 重连时间分散在 [1s×50%, 1s×100%] 即 [0.5s, 1s] 区间
│ 10000 个客户端分散到 0.5s 内 → 每秒 20000 连接 → 可承受
2
3
4
结论:TCP keep-alive 是操作系统层面的"保险丝"(2 小时太慢)。WebSocket 心跳是应用层对"网络不可靠"的诚实认知——你自己保活,别指望 OS 替你保活。
# 4.3 对比 SSE
| 维度 | WebSocket | SSE (EventSource) |
|---|---|---|
| 通信方向 | 全双工(双向) | 单工(仅服务器→客户端) |
| 协议 | ws:// / wss://(新协议) | HTTP(复用现有基础设施) |
| 自动重连 | ❌ 需手写 | ✅ 浏览器自动,带 Last-Event-ID |
| 二进制支持 | ✅ 原生(opcode=2) | ❌ 仅文本(需 base64 编码) |
| 心跳 | 需自己发 ping/pong | 浏览器自动维持连接 |
| 适用场景 | 聊天/游戏/协同编辑/控制台 | 股票推送/通知/Feed 流/进度更新 |
| 实现复杂度 | 高(心跳+重连+退避全要手写) | 低(new EventSource(url) 就够了) |
一句话选型口诀:客户端需要主动发送 → WS;只有服务端推送 → SSE。大多数"通知推送"场景 SSE 就够了——省掉心跳和重连代码。
# 5. SSE(Serv
# 5.1 单工推送 + 自
const es = new EventSource('/events');
es.onmessage = e => {
console.log('Data:', JSON.parse(e.data));
};
// 自定义事件类型
es.addEventListener('stock-update', e => {
const stock = JSON.parse(e.data);
updateStockUI(stock);
});
es.addEventListener('error-alert', e => {
showAlert(e.data);
});
// 连接错误(浏览器自动重连——你不需要写重连代码)
es.onerror = () => {
console.log('Connection lost. Browser will auto-reconnect...');
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
服务端响应格式:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: {"symbol": "AAPL", "price": 150.23}
event: stock-update
data: {"symbol": "GOOG", "price": 2800.12}
id: 42
event: error-alert
data: Market closed
2
3
4
5
6
7
8
9
10
11
12
13
# 5.2 什么时候 SSE
论证——三个维度判断:
- 你只需要推送:聊天室用 WS 是因为客户端也要发消息;股票行情用 SSE 是因为客户端只"看"不发
- 你不想写重连逻辑:SSE 的自动重连(带
Last-Event-ID)是浏览器内置的,零代码 - 你走 HTTP/2:SSE 走 HTTP,可以复用 HTTP/2 的多路复用——不需要单独的连接。WS 需要独立连接
# 5.3 SSE 的事件
SSE 自动重连时,浏览器会在请求头里带上 Last-Event-ID: <上一次收到的id>。服务端可以根据这个 ID 进行断点续传——这是 WS 原生不具备的能力。
客户端断开时收到的最后一个事件 ID:42
↓
自动重连 → POST /events
Headers: Last-Event-ID: 42
↓
服务端:SELECT * FROM events WHERE id > 42 → 只推 43, 44, 45...
↓
客户端:无缝衔接,不漏一条
2
3
4
5
6
7
8
# 6. Web存储详解
# 6.1 Cookie
疑惑:Cookie 只有 4KB,每次请求都自动带上——这种设计在 2025 年还有用吗?
论证:
Cookie 的设计哲学是 "把状态编码在 HTTP 请求中"——这不是缺陷,而是 HTTP 无状态协议下的必然。当你访问 example.com/dashboard,服务器怎么知道你是谁?答案是:浏览器在请求头里带上了 Cookie: session_id=abc123。
Cookie 的核心安全属性:
┌──────────────────────────────────────────────┐
│ │
│ HttpOnly → JS 不可读 document.cookie │
│ 防 XSS 窃取 Session Token │
│ │
│ Secure → 仅 HTTPS 传输,明文 HTTP 不带 │
│ 防中间人嗅探 │
│ │
│ SameSite → Strict: 完全禁止第三方携带 │
│ → Lax: 链接跳转可带,iframe不带 │
│ → None: 不限制(必须同时设 Secure) │
│ 防 CSRF │
│ │
│ Max-Age → 过期时间(秒,现代替代 Expires) │
│ │
│ Path → 限制 Cookie 的作用路径 │
│ │
│ Domain → 限制 Cookie 的作用域名 │
│ │
└──────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
为什么不是用 Authorization: Bearer xxx 头替代所有 Cookie:
| Cookie(HttpOnly + Secure + SameSite) | Bearer Token(localStorage) | |
|---|---|---|
| JS 可读 | ❌(防 XSS 偷 token) | ✅(XSS 可以直接偷) |
| 自动附带 | ✅(无需前端手动加头) | ❌(每个 fetch 需手动加 Authorization 头) |
| CSRF 风险 | ✅(SameSite 防护) | ✅(本来就不自动带) |
| 跨域 | ❌(SameSite Strict 完全禁止) | ✅ |
| SSR 可用 | ✅(Node 端直出 HTML 时 Cookie 在请求中) | ❌(localStorage 不存在于 Node 端) |
结论:Bearer Token 解决的是"跨域 + SPA"场景的认证。Cookie(配齐 HttpOnly+Secure+SameSite)解决的是"Web 页面 + SSR + 防 XSS/CSRF"场景的认证。它们不是竞品——是两套不同安全模型的产物。生产环境最佳实践:Refresh Token 存 HttpOnly Cookie,Access Token 存内存(JS 变量)。
# 6.2 localSto
| 维度 | localStorage | sessionStorage |
|---|---|---|
| 容量 | ~5 MB(浏览器实现差异,通常 5-10 MB) | ~5 MB |
| 生命周期 | 永久(除非 JS 删除或用户清缓存) | 关闭最后一个 Tab 即清除 |
| 跨 Tab 共享 | ✅ 同源所有 Tab 共享 | ❌ 每个 Tab 独立 |
| 跨域 | 严格同源 | 严格同源 |
| API 类型 | 同步(阻塞主线程) | 同步(阻塞主线程) |
| storage 事件 | ✅(在同源其他 Tab 修改时触发) | ❌ |
| 适用场景 | 用户偏好/主题/配置 | 表单草稿/向导步骤/临时状态 |
跨 Tab 通信的 storage 事件:
// Tab A:修改 localStorage
localStorage.setItem('theme', 'dark');
// Tab B:自动收到通知
window.addEventListener('storage', e => {
if (e.key === 'theme') {
applyTheme(e.newValue); // 'dark'
}
});
// 注意:storage 事件只在"其他"同源 Tab 触发——当前 Tab 不触发
2
3
4
5
6
7
8
9
10
# 6.3 为什么 loca
疑惑:localStorage 的读写是同步的——为什么不能像 IndexedDB 一样做成异步的?
论证:
localStorage 是 2009 年随 HTML5 一起出现的。那时的 Web 还是"页面式"的——每次交互都可能刷新页面。同步 API 的简单性(getItem 直接返回结果,不需要回调)在这个时代是优势。
但到了今天——你的 JS bundle 在主线程上跑 React 的 reconciler,每 16ms 要完成一次渲染帧。localStorage 的一次 setItem 大约需要:
setItem 调用
│
├── 将键值对写入内存中的 Map(<1µs)
├── 将键值对写入磁盘上的 SQLite 文件(~1-5ms,视磁盘繁忙度)
└── 返回
2
3
4
5
这 1-5ms 的磁盘写入完全阻塞主线程。如果你在 React 的 render 函数里调了 localStorage.setItem,你的渲染帧预算就少了 1-5ms。
为什么不能改成异步的:
- 历史包袱——所有网站都假设
getItem立刻返回。改异步 = 破坏 Web - 调用模式匹配——
storage事件是同步触发的。如果setItem是异步的,storage 事件的触发时机在并发场景下会变得不明确 - Cache API 已经替代——大量 KV 存取场景现在用 Cache API(异步)
结论:localStorage 适合低频、小数据、对同步阻塞不敏感的场景(用户偏好、配置项)。任何高频写入场景(自动保存、实时同步)都应该用 IndexedDB 或 Cache API。
# 7. IndexedDB
# 7.1 对象仓库 + 索
// 打开数据库——范式化包装
function openDB(name, version, upgradeCallback) {
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, version);
req.onupgradeneeded = (e) => upgradeCallback(e.target.result);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
const db = await openDB('newsApp', 1, db => {
// 创建对象仓库(类似 SQL 的 table)
const articles = db.createObjectStore('articles', { keyPath: 'id' });
// 创建索引(类似 SQL 的 INDEX)
articles.createIndex('byDate', 'date'); // 按日期索引
articles.createIndex('byCategory', 'category'); // 按分类索引
articles.createIndex('byDateCategory', ['date', 'category']); // 复合索引
});
// 写入——必须包裹在事务中
const tx = db.transaction('articles', 'readwrite');
const store = tx.objectStore('articles');
store.add({ id: 1, title: 'Hello', date: '2026-06-11', category: 'tech' });
store.add({ id: 2, title: 'World', date: '2026-06-10', category: 'sports' });
// 事务完成——只有在这里才能确认写入成功
await new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
// 读取
const getReq = db.transaction('articles').objectStore('articles').get(1);
const article = await new Promise((resolve, reject) => {
getReq.onsuccess = () => resolve(getReq.result);
getReq.onerror = () => reject(getReq.error);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
IndexedDB 内部结构:
数据库 (newsApp)
└── 对象仓库 "articles" (Object Store)
├── 主键索引 (keyPath: 'id')
├── 索引 "byDate" (key: 'date')
│ └── B+ Tree → O(log n) 范围查询
├── 索引 "byCategory" (key: 'category')
│ └── B+ Tree
└── 索引 "byDateCategory" (key: ['date', 'category'])
└── B+ Tree 复合键
IndexedDB 内部使用 LevelDB(Chrome)/ SQLite(Firefox/Safari)作为存储后端。
索引是一颗 B+ Tree——范围查询/游标遍历都是 O(log n + k),k 是结果行数。
2
3
4
5
6
7
8
9
10
11
12
# 7.2 为什么 IDB
疑惑:同样是数据库操作,SQLite 的 API 可以 db.execute('SELECT ...') 一行完事。为什么 IDB 要 db.transaction() → store.openCursor() → onsuccess → onerror?
论证:
IndexedDB 的 API 设计是 2011 年的产物——那一年 Promise 还不存在(ES6 是 2015 年)、async/await 更不存在。所有的异步都只能走事件回调:onsuccess / onerror / oncomplete。
但这只解释了"为什么是事件回调"。事务自动提交——没有显式的 tx.commit()——才是真正让 API 别扭的马鞍:
// ❌ 你以为能工作的写法
const tx = db.transaction('articles', 'readwrite');
const store = tx.objectStore('articles');
store.add({ id: 1, data: 'hello' });
setTimeout(() => {
store.add({ id: 2, data: 'world' }); // 💥 事务已经自动提交了!
}, 1000);
2
3
4
5
6
7
8
为什么会自动提交:IDB 的事务在它的所有同步回调执行完毕后自动提交——即"当前宏任务清空后"。这不是 bug——这是为了避免开发者"开了事务忘了关闭"导致的锁泄露。但这意味着:
- 你不能在
setTimeout/fetch.then里继续用这个事务 - 批量操作必须在一个事件循环内全部入队
- 你不能显式
rollback——唯一"回滚"是事务 error 后浏览器自动清理
结论:IDB 的 API 是"2011 年的异步设计"加上"为安全牺牲了易用性的事务模型"的产物。用 idb(Google 的 Promise 包装库)是标准操作——但理解原生的底层模型,才能知道为什么"批量插入必须在一个 Promise chain 内完成"。
# 7.3 复合键 + ID
// 场景:查询 2026-06-01 到 2026-06-11 之间,分类为 'tech' 的所有文章
const store = db.transaction('articles').objectStore('articles');
const index = store.index('byDateCategory');
// 复合键的范围:[startDate, category] → [endDate, category]
const range = IDBKeyRange.bound(
['2026-06-01', 'tech'],
['2026-06-11', 'tech']
);
const articles = [];
index.openCursor(range).onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
articles.push(cursor.value);
cursor.continue(); // ← 移到下一条
} else {
console.log('Done:', articles);
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复合索引的查询原理:B+ Tree 的键是逐字段比较的——['2026-06-01', 'tech'] < ['2026-06-05', 'tech'] < ['2026-06-11', 'tech']。范围查询时,B+ Tree 先定位起始键,然后沿着叶子层链表顺序遍历——全程 O(log n + k)。
# 7.4 IDB 事务的自
事务生命周期:
db.transaction('store', 'readwrite')
│
├── 事务创建(T0)
│ └── 开始收集操作(add/put/delete)
│
├── 所有同步回调执行完(微任务队列也清空了)
│ └── 事务自动提交(flush 到磁盘)
│
├── oncomplete 事件触发
│
└── 事务结束
⚠️ 如果事务中发生未被捕获的异常 → onerror → 事务 abort(已收集的操作全部回滚)
2
3
4
5
6
7
8
9
10
11
12
13
VACUUM:当你删除大量数据后,IDB 底层 SQLite 文件不会自动收缩——它只在浏览器觉得"空闲且碎片足够多"时才做 VACUUM。这意味着:删除了 500MB 数据,磁盘占用不会立刻降。你也不能手动触发 VACUUM——这是浏览器的内部决策。
# 8. Cache缓存
# 8.1 五种缓存策略全景
疑惑:为什么不做一种"智能缓存策略"——自动判断该用网络还是缓存?
论证:因为"智能"意味着浏览器替你做决策——而不同资源有不同的时效性要求。一个 JS 文件可以缓存一年不更新,但实时股价必须每次走网络。
┌────────────────────────────────────────────────────────────────────┐
│ 五种缓存策略 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Cache-First(缓存优先) │
│ ┌──────┐ 命中 ┌──────┐ │
│ │ Cache │ ───→ │ 返回 │ │
│ └──────┘ └──────┘ │
│ │ 未命中 │
│ ▼ │
│ ┌──────┐ ┌──────┐ │
│ │Network│ ──→ │ Cache │ ──→ 返回 │
│ └──────┘ └──────┘ │
│ 适用:不常变的静态资源(JS/CSS/字体/图片) │
│ 风险:缓存永远不会更新——除非 SW 版本升级清缓存 │
│ │
│ 2. Network-First(网络优先) │
│ ┌──────┐ 成功 ┌──────┐ │
│ │Network│ ───→ │ 返回 │ │
│ └──────┘ └──────┘ │
│ │ 失败 │
│ ▼ │
│ ┌──────┐ │
│ │ Cache │ ──→ 返回(降级) │
│ └──────┘ │
│ 适用:需最新数据但离线也要有兜底 │
│ 风险:网络正常但慢 → 用户等待时间长 │
│ │
│ 3. Stale-While-Revalidate(第一时间返缓存,后台更新) │
│ ┌──────┐ 有缓存? ┌──────┐ │
│ │ Cache │ ───→ │ 返回 │ ← 用户立刻看到(0ms) │
│ └──────┘ └──────┘ │
│ │ │ │
│ └──────────────┤ │
│ ▼ (后台,不阻塞返回) │
│ ┌──────┐ ┌──────┐ │
│ │Network│ → │ Cache │ → 下次刷新用新数据 │
│ └──────┘ └──────┘ │
│ 适用:API 响应 / 可能稍旧但能接受的数据 │
│ UX 最优解:用户看到的是瞬间出现的缓存,而不是 loading spinner │
│ │
│ 4. Cache-Only(仅缓存) │
│ 适用:离线专属资源(预置在 SW install 中的固定内容) │
│ │
│ 5. Network-Only(仅网络) │
│ 适用:必须实时数据(支付/身份验证) │
│ │
└────────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# 8.2 每种策略的适用场
SWR(Stale-While-Revalidate)的适用矩阵:
| 数据特征 | 策略 | 原因 |
|---|---|---|
| 几乎不变 | Cache-First | 像 JS bundle——文件名带 hash,缓存了不会过期 |
| 实时性要求低 | SWR | 像文章列表——用户能接受看 30 秒前的数据 |
| 必须有最新数据 | Network-First | 像股票价格——旧数据就是错误数据 |
| 必须有最新,离线不可用 | Network-Only | 像支付确认——没网就不能用,不要用缓存误导 |
| 预置内容 | Cache-Only | 像离线帮助文档 |
# 8.3 Workbox
Google 的 Workbox 将上述五种策略封装为一行代码:
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
// JS/CSS 用 Cache-First
registerRoute(
({ request }) => request.destination === 'script' || request.destination === 'style',
new CacheFirst({ cacheName: 'static-resources' })
);
// API 用 SWR
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({ cacheName: 'api-responses' })
);
2
3
4
5
6
7
8
9
10
11
12
13
14
手写 vs Workbox 的取舍:
- 手写:理解底层原理后,你可以针对每一种资源定制策略(比如只缓存 200 响应、只缓存特定 Content-Type)
- Workbox:生产环境推荐——它处理了"缓存名版本管理""预缓存清单生成""跨域请求 Workbox 自带的策略类和插件体系"等细节
# 9. 存储选型决策矩阵
# 9.1 五维对比
| 维度 | Cookie | localStorage | sessionStorage | IndexedDB | Cache API |
|---|---|---|---|---|---|
| 容量 | ~5KB(每个域) | ~5-10MB | ~5-10MB | 浏览器限额的百分比(通常 GB 级) | 浏览器限额的百分比 |
| 生命周期 | 可设过期时间 | 永久(手动删) | Tab 关闭即删 | 永久(手动删) | SW 控制(caches.delete) |
| API 类型 | 同步(字符串) | 同步(KV) | 同步(KV) | 异步(事件回调 → Promise 包装) | 异步(Promise) |
| 同源跨 Tab | ✅ | ✅ | ❌ | ✅ | SW 域内 |
| 自动附带请求 | ✅ | ❌ | ❌ | ❌ | ❌(SW 拦截) |
| JS 可读写 | 默认✅(可设 HttpOnly 关闭 JS 读) | ✅ | ✅ | ✅ | ❌(仅 SW 可读写) |
| 结构化数据 | ❌(仅文本) | ❌(仅文本,需 JSON.stringify) | ❌(仅文本) | ✅(任意可序列化 JS 对象) | ✅(Response 对象) |
| 索引查询 | ❌ | ❌ | ❌ | ✅(主键+索引+游标) | ❌(仅请求 URL 匹配) |
| 事务 | ❌ | ❌ | ❌ | ✅(readonly/readwrite 事务) | ❌ |
| 清除策略 | 过期 | 用户主动 / 浏览器清理 | Tab 关闭 | 用户主动 / 浏览器清理 | SW 更新 / 用户主动 |
| 安全特性 | HttpOnly/Secure/SameSite | 无(XSS 可直接读) | 无 | 无(XSS 可读) | SW 隔离 |
# 9.2 四种场景的最佳选择
| 场景 | 推荐存储 | 为什么 |
|---|---|---|
| 用户偏好(主题/语言/字体大小) | localStorage | 小数据(<1KB)、永久、同步读取方便,访问频率极低 |
| 会话Token | HttpOnly Cookie | JS 不可读(防 XSS)、自动附带(无需手动加头)、SameSite 防 CSRF |
| 聊天消息(大量 + 离线可用) | IndexedDB | GB 级容量、异步不阻塞、支持按时间索引范围查询 |
| 静态资源(JS/CSS/字体/图片) | Cache API + SW | 请求拦截、离线可用、配合 SW 策略自动更新 |
| 离线数据包(地图瓦片/词典) | IndexedDB | 大容量 + 根据 tile 坐标用复合键索引 |
| 表单草稿(填写到一半的退货单) | sessionStorage | 关 Tab 自动清理(草稿不应该永久保存) |
| API 响应缓存 | Cache API + SWR 策略 | SWR = 用户先看缓存,后台静默更新 |
| 临时文件(上传前预览) | 内存(JS 变量) | 不需要持久化,Blob URL 即可 |
高频写入场景下的选择:
// ❌ localStorage 高频写入(每 100ms 自动保存编辑器内容)
setInterval(() => {
localStorage.setItem('draft', editor.getValue()); // 每 100ms 阻塞主线程 1-5ms
}, 100);
// ✅ IndexedDB 高频写入
setInterval(() => {
const tx = db.transaction('drafts', 'readwrite');
tx.objectStore('drafts').put({ id: 'current', content: editor.getValue() });
// 异步——不阻塞主线程
}, 100);
2
3
4
5
6
7
8
9
10
11
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的 PWA 白屏事故——六个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
| ① fetch 怎么流式读取? | 第 3 章:res.body.getReader() 逐块拉取 + 手动累加计算进度。fetch 没有 onprogress 是因为它把响应体暴露为 ReadableStream——开发者自己控制读取节奏 |
| ② WebSocket 心跳怎么设计? | 第 4 章:25s ping + 5s pong timeout + 指数退避 min(1000*2^n, 30000) + 50%随机 jitter。jitter 防"惊群" |
| ③ SSE vs WS 选哪个? | 第 5 章:客户端需要主动发 → WS;只有服务端推送 → SSE。SSE 的自动重连 + Last-Event-ID 断点续传是 WS 没有的独特能力 |
| ④ 五种存储分别在哪用? | 第 6~9 章:Cookie→认证(HttpOnly),ls→用户偏好,ss→草稿,IDB→大量结构化数据,Cache→静态资源+API缓存 |
| ⑤ Cache-First vs SWR 怎么选? | 第 8 章:静态资源(永不改变)→ Cache-First;API 响应(会变但可接受稍旧)→ SWR;股票行情(必须最新)→ Network-First |
| ⑥ IDB 为什么难用? | 第 7 章:2011 年的 API 设计(事件回调)+ 事务自动提交(无显式 commit/rollback)+ 无原生 Promise |
修复方案(按改动量从小到大):
方案 A:把 API 缓存策略从 Cache-First 改为 Network-First(最小改动)
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
// API 响应:先走网络 → 失败了才用缓存
event.respondWith(
fetch(event.request)
.then(res => caches.open('api-v1').then(cache => {
cache.put(event.request, res.clone()); // 更新缓存
return res;
}))
.catch(() => caches.match(event.request)) // 离线降级
);
} else {
// 静态资源:依然 Cache-First
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
方案 B:API 用 SWR(UX 最优)
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
event.respondWith(
// 1. 先查缓存 → 如果有立刻返回(0ms 首屏)
caches.match(event.request).then(cached => {
// 2. 同时发网络请求 → 拿到新数据后更新缓存
const fetchPromise = fetch(event.request).then(res => {
caches.open('api-v1').then(cache => cache.put(event.request, res.clone()));
return res;
});
// 3. 如果缓存命中 → 先返回旧的,后台更新
return cached || fetchPromise;
})
);
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
方案 C:完整的离线优先架构(终极方案)
// 三层存储策略:
// 1. 内存(JS Map):热数据,读取最快(0ms)——有网络时填充,离线时清空
// 2. IndexedDB:温数据,异步读取(~5ms)——网络获取后写入,离线时从 IDB 读
// 3. Cache API:冷数据,请求拦截(SW 决策)——静态资源和 API 响应缓存
async function getArticles() {
// 1. 先看内存(上次渲染的数据可能还在)
if (memoryCache.get('articles')) return memoryCache.get('articles');
// 2. 次看 IDB(离线时的主要数据源)
const fromIDB = await db.getAll('articles');
if (fromIDB.length > 0) return fromIDB;
// 3. 最后走网络
const fromNet = await fetch('/api/articles').then(r => r.json());
// 存入 IDB 供离线使用
await db.transaction('articles', 'readwrite').store.put(fromNet);
return fromNet;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 10.2 实现「离线优先」
// ===== service-worker.js =====
const CACHE_NAME = 'news-v2';
const API_CACHE = 'news-api-v1';
// install: 预缓存壳资源(HTML/JS/CSS)
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache =>
cache.addAll(['/', '/app.js', '/styles.css']) // ← 注意:不缓存 API!
)
);
});
// activate: 清理旧版本缓存
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys.filter(k => k !== CACHE_NAME && k !== API_CACHE)
.map(k => caches.delete(k))
)
)
);
});
// fetch: 分策略处理
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// 静态资源 → Cache-First
if (event.request.destination === 'script' ||
event.request.destination === 'style' ||
event.request.destination === 'font' ||
event.request.destination === 'image') {
event.respondWith(caches.match(event.request)
.then(cached => cached || fetch(event.request)));
return;
}
// API → SWR(Stale-While-Revalidate)
if (url.pathname.startsWith('/api/')) {
event.respondWith(
caches.match(event.request).then(cached => {
const networkFetch = fetch(event.request).then(res => {
if (res.ok) {
caches.open(API_CACHE).then(c => c.put(event.request, res.clone()));
}
return res;
});
return cached || networkFetch;
})
);
return;
}
// 默认 → Network-First
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});
// ===== app.js =====
let db;
async function initDB() {
db = await new Promise((resolve, reject) => {
const req = indexedDB.open('newsApp', 1);
req.onupgradeneeded = () => {
const store = req.result.createObjectStore('articles', { keyPath: 'id' });
store.createIndex('byDate', 'date');
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function loadNews() {
// 第一层:内存(上一次渲染的数据,0ms)
const memCache = window.__newsCache;
if (memCache && memCache.length > 0) {
render(memCache);
// 不 return——后台还要更新
}
// 第二层:IndexedDB(离线时的数据源)
const fromIDB = await db.transaction('articles')
.objectStore('articles')
.getAll();
if (fromIDB.length > 0) {
render(fromIDB);
window.__newsCache = fromIDB;
}
// 第三层:网络(拿到最新数据)
try {
const res = await fetch('/api/news');
const fresh = await res.json();
// 回写 IDB
const tx = db.transaction('articles', 'readwrite');
const store = tx.objectStore('articles');
store.clear();
fresh.forEach(a => store.add(a));
await new Promise(r => { tx.oncomplete = r; });
// 更新内存 + 渲染
window.__newsCache = fresh;
render(fresh);
} catch {
// 离线——用户已经看到了缓存版本(从 IDB 来的),体验流畅
console.log('Offline: showing cached data');
}
}
await initDB();
loadNews();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
三层缓存的内存轨迹:
首屏(缓存命中) 网络有响应后
RSS RSS
│ ╱────── 内存(~1MB 文章列表) │
│ 0ms ╱ │
│ ╱ │ ╱── 网络 → 更新内存
│ ╱ IDB 读取(~5ms, ~2MB) │ ╱ → 回写 IDB(~10ms)
│ ╱ │ ╱ → 更新 DOM(~50ms)
│ ╱ │╱
└──────────────────── t └──────────────────── t
总内存占用:~3MB(文章列表 + IDB 缓存 + DOM 节点)
离线首屏时间:~5ms(IDB 读取,用户几乎感觉不到延迟)
在线更新时间:~200ms(网络 RTT + 解析 + DOM 更新)
2
3
4
5
6
7
8
9
10
11
12
13
# 10.3 设计哲学回扣
哲学一·「存储是时间的折现——不同生命周期的数据值得不同的存储介质」
Cookie 的生命周期由服务器通过 Max-Age 控制——它的设计哲学是"我说了算,你不用管"。localStorage 的生命周期是"永久"——它的哲学是"你想删才删"。sessionStorage 的生命周期是"当前 Tab"——它的哲学是"你走了我就忘"。IndexedDB 的生命周期也是"永久",但它给了你索引和游标——它的哲学是"你存了,以后还能查"。
五种存储对应的不是五种"容器大小",而是五种"时间承诺"。选错存储,等于给数据承诺了错误的生命周期——聊天记录值得 IndexedDB(永久 + 可查),而不是 sessionStorage(关 Tab 没了)。
哲学二·「fetch 不是 XMLHttpRequest 的语法糖——是流式思考的范式转换」
XMLHttpRequest 把 HTTP 响应看成一个"最终到达的完整对象"——下载完才能处理。fetch 把响应看成一个 ReadableStream——可以在数据还没完全到达时就开始处理、暂停、甚至取消。这不是 API 的语法变简洁了,是数据处理的思维方式变了:从"先全部拿到,再处理"到"边拿边处理"。
哲学三·「SWR 是最诚实的用户体验——承认网络不可靠」
Stale-While-Revalidate 的背后假设是:用户宁可看到稍旧的数据,也不愿面对 loading spinner。这是一种诚实的 UX 设计——它承认网络和服务器不可靠,并以此为前提构建方案,而不是假设"每次都能在 200ms 内拿到最新数据"。SWR 不是"妥协掉数据新鲜度"——它是"用数据新鲜度换用户体验"的自觉设计。
哲学四·「Cache API 的策略分离——让开发者替浏览器做决策」
HTTP 协议设计了 Cache-Control: max-age=3600 这样的声明式缓存——响应头告诉浏览器"这个资源可以缓存 3600 秒"。这解决了 90% 的场景。但剩下的 10%——"API 响应要缓存,但 10 秒后就要刷新""图片缓存永久,但 JS 要按版本号更新"——HTTP 头表达不了。
Service Worker + Cache API 的设计哲学是:把缓存决策权从浏览器和 CDN 手里拿回来,交给开发者。这是 Web 平台从"声明式"到"编程式"的一次范式跃迁——它承认了"缓存不是一个通用的全局策略,而是一个每个资源都有一个策略的细粒度问题"。
# 10.4 速查表
缓存策略五模式速查:
| 策略 | 网络? | 缓存? | 首屏延时 | 数据新鲜度 | 场景 |
|---|---|---|---|---|---|
| Cache-First | 未命中时 | ✅ 先 | ~0ms | 低(可能是旧数据) | 不变的静态资源 |
| Network-First | ✅ 先 | 失败时 | ~200ms | 高(总是最新) | 需最新但离线可降级 |
| Stale-While-Revalidate | ✅ 后台更新 | ✅ 先 | ~0ms(缓存命中) | 中(首批稍旧,次刷更新) | API 响应(推荐) |
| Cache-Only | ❌ | ✅ | ~0ms | 取决于预缓存时间 | 离线专属资源 |
| Network-Only | ✅ | ❌ | ~200ms | 最高 | 支付/身份验证 |
fetch 防遗忘清单:
| 检查项 | 代码 |
|---|---|
| 检查 HTTP 状态 | if (!res.ok) throw new Error(\HTTP ${res.status}`)` |
| 设置超时 | AbortSignal.timeout(5000) 或 AbortController + setTimeout |
| 检查 Content-Type | res.headers.get('content-type')?.includes('application/json') |
处理 AbortError | catch(e => { if (e.name === 'AbortError') return }) |
| 并发请求 | Promise.all([fetch1, fetch2, fetch3]) |
存储五维速查:
| Cookie | localStorage | sessionStorage | IndexedDB | Cache API | |
|---|---|---|---|---|---|
| 容量 | ~5KB | ~5MB | ~5MB | GB 级 | GB 级 |
| 生命 | 设过期 | 永久 | Tab 关 | 永久 | SW控 |
| 同步/异步 | 同步 | 同步 | 同步 | 异步 | 异步 |
| 跨Tab | ✅ | ✅ | ❌ | ✅ | SW域内 |
| 自动带请求 | ✅ | ❌ | ❌ | ❌ | ❌ |
| JS可读 | ✅ | ✅ | ✅ | ✅ | ❌ |
| 索引查询 | ❌ | ❌ | ❌ | ✅ | ❌ |
60 秒诊断命令清单:
# Chrome DevTools 快速诊断
# 1. 看所有存储内容
# Application → Storage → 左侧面板逐一检查
# 2. 看 SW 缓存匹配日志
# Application → Service Workers → 勾选 "Update on reload"
# Network 面板中,SW 服务的请求显示 "from ServiceWorker"
# 3. 看 IDB 数据
# Application → IndexedDB → 展开数据库 → 展开 Object Store
# 4. 看 Cache Storage 内容
# Application → Cache Storage → 展开缓存名
# 5. 模拟离线
# Network 面板 → 勾选 "Offline"
# 6. 看 localStorage 实时变化
# Application → Local Storage → 点击某个 key,手动编辑
# 7. 强制清除所有存储
# Application → Storage → "Clear site data"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
下一步:浏览器端的存储搞定了。JS 的另一主场——服务端 Node.js,它的运行模型完全不同。进入 12.Node.js 运行时与流式编程。
📌 本文与 C++ 专栏的关系:C++ 专栏第 01 篇讲的是"数据在虚拟内存中住在哪一段",本文讲的是"数据在 Web 平台上的五条持久化路径"。读完后你会看到:Cookie 的本质是"在 HTTP 请求头里夹带的一段字符串";localStorage 的本质是"浏览器内置 SQLite 的一个 KV 表上的同步封装";IndexedDB 的本质是"一个暴露给 JS 的结构化事务存储,底层是 LevelDB/SQLite"。理解 Web 存储的五种形态,就是理解浏览器如何在无状态的 HTTP 之上构建有状态的用户会话。