编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

    • 专栏博客

      • README
      • 引擎解析编译执行
      • 隐藏类与回收机制
      • 类型隐式转换精算
      • 作用域链闭包原理
      • 函数绑定规则组合
      • 原型链语法糖本质
      • 代理与元编程协议
      • 事件循环承诺机制
      • 工作线程并发调度
      • 页面渲染像素原理
      • 网络接口存储架构
        • 1. 案例与疑问引入
          • 1.1 PWA 离线打开
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构全景概览
          • 2.1 网络层(fetch
          • 2.2 数据生命周期
          • 2.3 为什么浏览器的存
        • 3. fetch详解
          • 3.1 Request
          • 3.2 流式读取:get
          • 3.3 AbortCon
          • 3.4 fetch 的反
        • 4. WebSocket
          • 4.1 帧格式:opco
          • 4.2 心跳机制 + 自
          • 4.3 对比 SSE
        • 5. SSE(Serv
          • 5.1 单工推送 + 自
          • 5.2 什么时候 SSE
          • 5.3 SSE 的事件
        • 6. Web存储详解
          • 6.1 Cookie
          • 6.2 localSto
          • 6.3 为什么 loca
        • 7. IndexedDB
          • 7.1 对象仓库 + 索
          • 7.2 为什么 IDB
          • 7.3 复合键 + ID
          • 7.4 IDB 事务的自
        • 8. Cache缓存
          • 8.1 五种缓存策略全景
          • 8.2 每种策略的适用场
          • 8.3 Workbox
        • 9. 存储选型决策矩阵
          • 9.1 五维对比
          • 9.2 四种场景的最佳选择
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 实现「离线优先」
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • 服务端运行时编程
      • 模块系统双轨操作
      • 现代工程链三件套
      • 设计模式函数哲学
      • 跨端架构终局总结
  • CodeX
  • JavaScript入门
  • 专栏博客
杨充
2026-06-11
目录

网络接口存储架构

# 11.Web API 网络与存储架构

📍 上接第 10 篇《浏览器渲染像素之路》。像素怎么上屏已了然。本文回答:上屏的数据从哪来、存哪里——fetch 怎么流式读取?WebSocket 心跳怎么设计?IndexedDB 为什么 API 这么难用?

# 目录介绍

  • 1. 案例与疑问引入
    • 1.1 PWA 离线打开
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构全景概览
    • 2.1 网络层(fetch
    • 2.2 数据生命周期
    • 2.3 为什么浏览器的存
  • 3. fetch详解
    • 3.1 Request
    • 3.2 流式读取:get
    • 3.3 AbortCon
    • 3.4 fetch 的反
  • 4. WebSocket
    • 4.1 帧格式:opco
    • 4.2 心跳机制 + 自
    • 4.3 对比 SSE
  • 5. SSE(Serv
    • 5.1 单工推送 + 自
    • 5.2 什么时候 SSE
    • 5.3 SSE 的事件
  • 6. Web存储详解
    • 6.1 Cookie
    • 6.2 localSto
    • 6.3 为什么 loca
  • 7. IndexedDB
    • 7.1 对象仓库 + 索
    • 7.2 为什么 IDB
    • 7.3 复合键 + ID
    • 7.4 IDB 事务的自
  • 8. Cache缓存
    • 8.1 五种缓存策略全景
    • 8.2 每种策略的适用场
    • 8.3 Workbox
  • 9. 存储选型决策矩阵
    • 9.1 五维对比
    • 9.2 四种场景的最佳选择
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 实现「离线优先」
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 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))
  );
});
1
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 章
1
2
3
4
5
6

本篇路线:

架构总图(第 2 章)
   ↓
网络层 fetch/WS/SSE(第 3~5 章)──→ 解开"数据怎么从服务端到达浏览器"
   ↓
存储层 Cookie/ls/ss/IDB/Cache(第 6~9 章)──→ 解开"五把存储利器,各用在哪"
   ↓
综合案例(第 10 章)──→ 案例彻底剖开 + 离线优先方案 + 哲学回扣
1
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专用 ││
│  │  │请求 │     │  永久     │  │  即清除   │  │ 可索引  │  │可拦截 ││
│  │  └─────┘     └──────────┘  └──────────┘  └─────────┘  └───────┘│
│  │                                                         │  │
│  └─────────────────────────────────────────────────────────┘  │
│                                                               │
└───────────────────────────────────────────────────────────────┘
1
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
   有网络时         总是存在         写入一次          离线时
1
2
3
4
5
6

每一步的生命周期不同、性能代价不同、可靠性要求不同——这就是为什么浏览器提供五种存储方案:没有一种能同时满足"容量大、写入快、自动同步、安全隔离"四个要求。

# 2.3 为什么浏览器的存

疑惑:直接给浏览器一个 SQLite,让开发者自己建表查询不行吗?为什么要搞 Cookie、localStorage、IndexedDB、Cache API 这么多种?

论证:

  1. 安全隔离需要——Cookie 因为自动附带请求的特性,天然被 CSRF/XSS 攻击利用。所以必须有 HttpOnly(JS 不可读)、Secure(仅 HTTPS)、SameSite(禁第三方)这三道防线。把 Cookie 和 localStorage 合二为一,等于让 XSS 能偷到所有本地数据。

  2. 生命周期不同——会话数据(表单草稿)应该随 Tab 关闭而销毁(sessionStorage);用户偏好(主题/语言)应该永久保留(localStorage)。如果只有一种存储,开发者需要在应用层管理"哪些数据该活多久"——历史上已经证明开发者做不到。

  3. 读写模式不同——localStorage 是同步的(每次 setItem 都阻塞主线程),在大数据量时会造成渲染卡顿。IndexedDB 是异步的(所有操作走事件/回调),不阻塞主线程。同步/异步不是实现细节——它决定了这个 API 能在什么场景下使用。

  4. 访问控制不同——Cache API 只能被它所属的 Service Worker 访问——页面 JS 不能直接读写 Cache API。这是一道安全边界:缓存策略逻辑和业务逻辑完全隔离。

  5. 反向验证: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 ✅
1
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 一个新流(不消费原始流)
1
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();
1
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);
1
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;  // 真正的网络错误
  }
}
1
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)
1
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)   │           │               │                  │
└──────────┴──────────┴───────────────┴──────────────────┘
1
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 请求"的字节序列
1
2
3
4
5
6
7

# 4.2 心跳机制 + 自

疑惑:TCP 本身有 keep-alive,为什么 WebSocket 还要额外做心跳?

论证:TCP keep-alive 的默认探测间隔是 2 小时(Linux net.ipv4.tcp_keepalive_time = 7200),且它只在连接空闲时发探测包。关键问题是:

  1. TCP 半开连接——中间某个路由器 NAT 表项过期了,连接"看起来还在"但实际已经断了。TCP keep-alive 发现这个可能要 2 小时 11 分钟
  2. 应用层代理超时——Nginx/ALB 等反向代理有自己的空闲超时(通常 60s~300s),断了之后不会通知浏览器
  3. 移动端网络切换——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');
    }
  }
}
1
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 连接 → 可承受
1
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...');
};
1
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
1
2
3
4
5
6
7
8
9
10
11
12
13

# 5.2 什么时候 SSE

论证——三个维度判断:

  1. 你只需要推送:聊天室用 WS 是因为客户端也要发消息;股票行情用 SSE 是因为客户端只"看"不发
  2. 你不想写重连逻辑:SSE 的自动重连(带 Last-Event-ID)是浏览器内置的,零代码
  3. 你走 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...
    ↓
客户端:无缝衔接,不漏一条
1
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 的作用域名            │
│                                              │
└──────────────────────────────────────────────┘
1
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 不触发
1
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,视磁盘繁忙度)
   └── 返回
1
2
3
4
5

这 1-5ms 的磁盘写入完全阻塞主线程。如果你在 React 的 render 函数里调了 localStorage.setItem,你的渲染帧预算就少了 1-5ms。

为什么不能改成异步的:

  1. 历史包袱——所有网站都假设 getItem 立刻返回。改异步 = 破坏 Web
  2. 调用模式匹配——storage 事件是同步触发的。如果 setItem 是异步的,storage 事件的触发时机在并发场景下会变得不明确
  3. 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);
});
1
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 是结果行数。
1
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);
1
2
3
4
5
6
7
8

为什么会自动提交:IDB 的事务在它的所有同步回调执行完毕后自动提交——即"当前宏任务清空后"。这不是 bug——这是为了避免开发者"开了事务忘了关闭"导致的锁泄露。但这意味着:

  1. 你不能在 setTimeout / fetch.then 里继续用这个事务
  2. 批量操作必须在一个事件循环内全部入队
  3. 你不能显式 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);
  }
};
1
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(已收集的操作全部回滚)
1
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(仅网络)                                           │
│     适用:必须实时数据(支付/身份验证)                                  │
│                                                                    │
└────────────────────────────────────────────────────────────────────┘
1
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' })
);
1
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);
1
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))
    );
  }
});
1
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;
      })
    );
  }
});
1
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;
}
1
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();
1
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 更新)
1
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"
1
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 之上构建有状态的用户会话。

上次更新: 2026/06/16, 12:36:20
页面渲染像素原理
服务端运行时编程

← 页面渲染像素原理 服务端运行时编程→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式