Skip to content

浏览器原理、HTTP / CDN / 缓存与 BFF 系统学习文档 ​

适合人群:希望从“会写页面”进阶到“理解页面为什么慢、为什么白屏、为什么缓存不生效、为什么需要 BFF”的前端和全栈开发者。

学习目标:系统掌握浏览器渲染、事件循环、网络请求、HTTP 缓存、CDN 缓存、Service Worker、BFF 架构,并能把这些知识落地到 Vue、React、Nuxt、小程序、Node/Nest/Express 项目。

核对时间:2026-06-30。本文参考 MDN、web.dev、Chrome DevTools、Cloudflare CDN Cache、Azure Architecture Center BFF Pattern 等官方资料整理。


目录 ​

  1. 为什么前端架构师必须懂这些
  2. 浏览器从输入 URL 到页面展示发生了什么
  3. DOM、CSSOM、渲染树、布局、绘制、合成
  4. JavaScript 事件循环、宏任务、微任务
  5. 浏览器存储:Cookie、localStorage、IndexedDB、Cache API
  6. HTTP 基础:请求、响应、方法、状态码、Header
  7. HTTP 缓存:强缓存与协商缓存
  8. CDN 原理与缓存策略
  9. Service Worker 与离线缓存
  10. BFF 是什么,解决什么问题
  11. BFF、API Gateway、SSR Server 的区别
  12. BFF 在 Vue/React/Nuxt/小程序中的落地
  13. Node/Nest/Express BFF 实现示例
  14. 常见问题排查
  15. 学习路线与练习
  16. 官方参考资料

1. 为什么前端架构师必须懂这些 ​

很多前端问题表面看是框架问题,本质是浏览器、网络或缓存问题。

例如:

  • 首屏慢:可能是 TTFB、LCP 图片、CSS 阻塞、JS 长任务。
  • 白屏:可能是 JS 404、base 路径错误、浏览器不兼容、缓存错配。
  • 页面跳动:可能是图片未设尺寸、字体加载导致 CLS。
  • 按钮点击卡顿:可能是主线程长任务导致 INP 差。
  • 新版本上线后用户仍看到旧页面:可能是 HTML 缓存策略错误。
  • 接口很慢:可能是请求瀑布、跨区域网络、BFF 缺失、缓存缺失。
  • 小程序弱网体验差:可能是接口数量多、包体积大、缓存策略差。

前端架构师要能从“代码层”一路追到“浏览器层、网络层、服务层、缓存层”。


2. 浏览器从输入 URL 到页面展示发生了什么 ​

2.1 总流程 ​

当用户输入 URL 后,大致发生:

  1. URL 解析。
  2. DNS 查询。
  3. TCP 连接。
  4. TLS 握手。
  5. 发送 HTTP 请求。
  6. 服务器返回 HTML。
  7. 浏览器解析 HTML。
  8. 下载 CSS、JS、图片、字体等子资源。
  9. 构建 DOM。
  10. 构建 CSSOM。
  11. 合成渲染树。
  12. 布局。
  13. 绘制。
  14. 合成。
  15. 执行 JavaScript,绑定交互。

2.2 DNS ​

DNS:Domain Name System,把域名解析为 IP。

例子:

text
www.example.com -> 203.0.113.10

优化方式:

html
<link rel="dns-prefetch" href="https://cdn.example.com">

2.3 TCP 和 TLS ​

TCP 建立连接,TLS 建立安全加密通道。

HTTPS 请求通常要经历:

text
TCP handshake -> TLS handshake -> HTTP request

优化方式:

html
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

preconnect 会提前建立连接,适合首屏关键第三方域名。

2.4 TTFB ​

TTFB:Time to First Byte,从请求开始到收到第一个字节的时间。

TTFB 高可能是:

  • DNS 慢。
  • 连接慢。
  • 服务器处理慢。
  • SSR 渲染慢。
  • 数据库慢。
  • CDN 未命中。
  • 跨区域访问。

优化 TTFB 不是前端单独能完成的,常常需要服务端、CDN、缓存一起处理。

2.5 连接复用与“6 连接”限制 ​

HTTP/1.1 下浏览器对同一来源的并发 TCP 连接数有限制(Chrome 等传统值为 6)。所有 XHR/fetch/SSE/长轮询共享这 6 个槽位:

text
HTTP/1.1 同域并发:
  6 个连接全被慢接口/长连接占住
    -> 后面的图片、JS 请求排队
    -> 页面看起来"卡住不动"

HTTP/2 单连接多路复用:
  1 个连接内 N 个 stream(常见上限 100),不再互相抢占

排查方法:DevTools Network 的 Queueing 列——如果请求在排队而不是真的慢,先数长连接。缓解手段按优先级:升级 HTTP/2(治本)> 减少长连接数量(SSE 全局共享一条)> 拆域名(老办法,牺牲连接复用,HTTP/2 后不推荐)。


3. DOM、CSSOM、渲染树、布局、绘制、合成 ​

3.1 DOM ​

DOM:Document Object Model,HTML 被浏览器解析后的对象树。

HTML:

html
<main>
  <h1>Hello</h1>
  <p>Welcome</p>
</main>

DOM 近似:

text
Document
  main
    h1
    p

JavaScript 可以操作 DOM:

js
document.querySelector("h1").textContent = "Hi";

3.2 CSSOM ​

CSSOM:CSS Object Model,CSS 被解析后的样式规则树。

CSS:

css
h1 {
  color: red;
}

浏览器需要 CSSOM 才能知道元素如何显示。

3.3 渲染树 ​

渲染树由 DOM 和 CSSOM 合成。

不是所有 DOM 节点都会进入渲染树。

例如:

css
.hidden {
  display: none;
}

display: none 的元素不参与渲染树。

3.4 布局 ​

布局也叫 Layout 或 Reflow。

浏览器计算每个元素的位置和尺寸:

  • 宽。
  • 高。
  • 坐标。
  • 换行。
  • 父子尺寸关系。

会触发布局的操作:

  • 改 width / height。
  • 改 top / left。
  • 插入 DOM。
  • 删除 DOM。
  • 读取 offsetWidth 后又修改样式。

3.5 绘制 ​

绘制 Paint 是把元素画成像素。

昂贵绘制常见来源:

  • 大阴影。
  • 复杂渐变。
  • filter。
  • backdrop-filter。
  • 大面积重绘。

3.6 合成 ​

合成 Composite 是把多个图层组合到屏幕。

动画优先使用:

css
transform: translateX(10px);
opacity: 0.5;

因为它们通常更容易由合成层处理,减少布局和绘制。

3.7 示例:为什么改 transform 比改 left 更好 ​

低效:

css
.box {
  left: 100px;
}

可能触发布局。

更好:

css
.box {
  transform: translateX(100px);
}

通常只影响合成。

3.8 内部机制:HTML 解析器如何被脚本与 CSS 卡住 ​

2.1 节把“解析 HTML → 构建 DOM”写成一步,实际解析器的工作方式决定了大量首屏优化手段的存在理由。关键规则(HTML 标准 + MDN“渲染网络关键路径”):

  1. HTML 解析是流式的、渐进的:浏览器不等整页下载,边收字节边建 DOM。这是首屏能逐步显示的基础。
  2. 同步 <script> 阻塞解析器:解析到没有 defer/async/type="module" 的脚本时,解析器暂停 → 等下载 → 等执行完 → 才继续解析后面的 HTML。因为脚本可能 document.write 改写剩余文档结构。
  3. 脚本执行前必须等 CSSOM 就绪:JS 里任何 getComputedStyle 都可能读样式,所以浏览器会阻塞脚本执行直到前面的 <link rel=stylesheet> 解析完。这就是“CSS 在前、JS 在后”的排版顺序会真正拖慢渲染的原因。
  4. CSS 阻塞渲染但不阻塞解析:样式表未就绪时不画像素,但 HTML 解析器可以继续建 DOM。
text
时间线 →
HTML:  ============================
CSS:        ████████                ← <link> 越早越好
JS(sync):        ┃████┃             ← 阻塞解析器:███ 期间 HTML 不动
渲染:                  ▲ 首帧

对应手段:

手段机制
<script defer>并行下载,解析完成后、DOMContentLoaded 前按顺序执行
<script async>并行下载,谁先下载完谁先执行,顺序不保证;用于埋点等独立性脚本
内联首屏关键 CSS消掉一个阻塞渲染的请求往返
非首屏 CSS media="print" onload 技巧 / rel=preload 换 rel不阻塞首次渲染
字体 font-display: swap文字先用回退字体画出来,避免 FOIT 白屏空白

⚠️ async 与 defer 同时写时 async 优先;内联 <script> 的 async/defer 无效(没有可提前下载的资源)。模块脚本(type="module")天然按 defer 处理。

3.9 内部机制:分层(Layerization)与合成——为什么会有“神秘卡顿” ​

渲染管线在“绘制”之后还有两步常被忽略:分层与合成。

text
渲染树 → Layout → Paint(记录绘制指令) → Layerize(分图层)
       → 每层光栅化 Raster(画成纹理,可 offscreen/他线程)
       → Composite(GPU 把纹理按层级拼成屏幕帧)

元素会被提升为独立合成层的常见条件:

  • 3D/透视 transform、will-change: transform/opacity。
  • <video>、<canvas>、WebGL、iframe。
  • 动画中的 transform/opacity 元素(Chrome 自动提升)。
  • position: fixed、某些滚动容器(overflow: scroll/auto 的可滚动内容)。
  • 被提升层的层叠后代。

合成器线程(Compositor Thread)的独立价值:滚动和 transform/opacity 动画可以在不等主线程的情况下由 GPU 出帧——这就是 3.6 说 transform 动画流畅的底层原因。

反过来,合成层的代价是显存与光栅化成本:

text
1 个 1000×1000 的图层 ≈ 4MB 纹理(× 设备像素比平方)
100 个小 div 各加 will-change → 数百层 → 显存爆 → 移动端闪退/白屏

⚠️ will-change 是“提前告知分层意图”的刀:用完必须撤掉(只挂在 hover/动画态上),全局滥用比不用更糟。

💡 诊断工具:DevTools → Rendering → Layer borders / Layers 面板,能看到每层边界与层数;Performance 面板里紫色(Layout)与绿色(Paint/Raster)条占比直接告诉你这次卡顿是布局、绘制还是合成问题。

3.10 内部机制:帧节奏与“一帧里能做完多少事” ​

60Hz 屏幕一帧 16.7ms(120Hz 是 8.3ms)。浏览器每个帧周期的内部顺序:

text
输入事件处理 → rAF 回调 → Style → Layout → Paint → Composite
   ↑主线程                        ↑不可压缩超预算 → 丢帧(掉帧)

由此得到几条工程铁律:

  • requestAnimationFrame 回调在布局之前:在 rAF 里读 offsetHeight 拿到的是本帧最新值。
  • setTimeout/微任务里读布局几何,容易撞上“样式脏了”触发同步 reflow。
  • 主线程被 JS 长任务占住时,rAF 和合成动画都停摆 → 表现为 INP 差、动画卡。
  • 每帧只做“变化部分”的失效(dirty)重算,所以改 1 个列表项 ≠ 重新布局整页;但读取强制同步布局(forced layout / layout thrashing)例外,见 3.4 的“读取 offsetWidth 后又修改样式”循环:
javascript
// 反例:交替读写 → N 次强制同步布局
for (const el of items) {
  el.style.height = el.offsetTop + 10 + "px"; // 每次读都逼浏览器立刻 layout
}
// 正解:批量读 → 批量写
const heights = items.map((el) => el.offsetTop);
items.forEach((el, i) => { el.style.height = heights[i] + 10 + "px"; });

💡 需要跳过屏幕外大列表的布局/绘制成本时,用虚拟滚动,或 content-visibility: auto + contain-intrinsic-size(让浏览器推迟离屏元素的渲染工作,web.dev 实测能显著降低初始渲染耗时)。


4. JavaScript 事件循环、宏任务、微任务 ​

4.1 为什么要懂事件循环 ​

事件循环影响:

  • Promise 执行顺序。
  • setTimeout 时机。
  • UI 渲染时机。
  • 点击为什么没响应。
  • 长任务为什么卡页面。

4.2 调用栈 ​

JavaScript 在主线程上执行,同一时间只能执行一个同步任务。

js
function a() {
  b();
}

function b() {
  console.log("b");
}

a();

调用栈:

text
a -> b -> console.log

4.3 宏任务 ​

常见宏任务:

  • script。
  • setTimeout。
  • setInterval。
  • 用户交互事件。
  • 网络回调。

4.4 微任务 ​

常见微任务:

  • Promise.then。
  • queueMicrotask。
  • MutationObserver。

4.5 执行顺序示例 ​

js
console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

Promise.resolve().then(() => {
  console.log("3");
});

console.log("4");

输出:

text
1
4
3
2

原因:

  1. 同步代码先执行。
  2. 当前宏任务结束后清空微任务队列。
  3. 再执行下一个宏任务。

4.6 长任务 ​

超过 50ms 的主线程任务通常被称为 Long Task。

长任务会阻塞:

  • 点击。
  • 输入。
  • 渲染。
  • 动画。

优化:

js
async function processLargeList(list) {
  for (let i = 0; i < list.length; i += 100) {
    processChunk(list.slice(i, i + 100));
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
}

这不是让总工作量变少,而是让浏览器有机会处理中间的输入和渲染。

4.7 内部机制:微任务与渲染时机的边界 ​

4.5 的“宏-微”模型之外,架构层面还需要两条精确规则:

  1. 微任务在帧内也会被清空:每个宏任务结束后清空微任务队列;rAF 回调、ResizeObserver 等渲染相关工作之后也会再清一次微任务。所以“Promise.resolve().then() 一定在下帧前执行”成立,但“微任务之间不渲染”也成立——任何微任务风暴(递归 then)都会饿死渲染,效果等同长任务,且 setTimeout 切分才是让出渲染的正确工具(4.6 示例的深层原因)。
  2. 一次任务里的 DOM 修改只产生一次渲染机会:同一任务内连续 el.style.x / classList 修改不会各触发一次重排——失效被攒起来,到帧边界统一 Style→Layout→Paint。所以“批量改 DOM”不是性能建议而是实现事实;真正贵的是读操作强制提前结算(见 3.10 forced layout)。

补充两个易混队列:queueMicrotask 与 Promise.then 同队;requestIdleCallback(Chrome/Firefox 支持,Safari 长期缺位)是“帧内剩余时间”回调,适合埋点/预计算等可延迟工作,deadline 超时后必须降级为 setTimeout。


5. 浏览器存储:Cookie、localStorage、IndexedDB、Cache API ​

Cookie 会随请求发送到服务器。

适合:

  • session id。
  • 服务端鉴权上下文。
  • 小型状态。

关键属性:

http
Set-Cookie: token=xxx; HttpOnly; Secure; SameSite=Lax

含义:

  • HttpOnly:JS 不能读取,降低 XSS 窃取风险。
  • Secure:只在 HTTPS 发送。
  • SameSite:降低 CSRF 风险。

5.2 localStorage ​

特点:

  • 同源持久存储。
  • JS 可读写。
  • 容量通常比 Cookie 大。
  • 不会自动随请求发送。

风险:

  • XSS 后可被读取。

适合:

  • 主题。
  • 非敏感偏好设置。
  • 上传断点元信息。

不适合:

  • 高敏感 token。

5.3 sessionStorage ​

和 localStorage 类似,但生命周期通常是当前标签页会话。

适合:

  • 页面临时状态。
  • 流程中间数据。

5.4 IndexedDB ​

IndexedDB 是浏览器中的结构化存储。

适合:

  • 大量数据。
  • 离线缓存。
  • 草稿。
  • 大文件上传元信息。
  • 本地数据库。

5.5 Cache API ​

Cache API 常与 Service Worker 一起使用,用于缓存请求和响应。

适合:

  • PWA 静态资源。
  • 离线页面。
  • 可缓存 API 响应。

6. HTTP 基础:请求、响应、方法、状态码、Header ​

6.1 HTTP 请求 ​

一个请求包含:

http
GET /api/users?page=1 HTTP/1.1
Host: example.com
Authorization: Bearer token
Accept: application/json

6.2 HTTP 响应 ​

http
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-cache

{"list":[]}

6.3 常见方法 ​

方法含义
GET获取资源
POST创建资源或提交操作
PUT整体替换
PATCH局部更新
DELETE删除
OPTIONS预检或能力查询

6.4 幂等性 ​

幂等:执行一次和多次效果相同。

通常:

  • GET 幂等。
  • PUT 幂等。
  • DELETE 通常幂等。
  • POST 通常不幂等。

大文件 chunk 上传建议设计成幂等:

text
PUT /uploads/{uploadId}/chunks/{index}

同一个 chunk 重复上传应覆盖或返回成功。

6.5 状态码 ​

状态码含义
200成功
201创建成功
204成功但无响应体
301 / 308永久重定向
302 / 307临时重定向
304缓存协商命中
400请求参数错误
401未登录或 token 无效
403已登录但无权限
404资源不存在
409冲突
429请求过多
500服务端错误
502 / 503 / 504网关或上游服务问题

6.6 CORS ​

CORS:Cross-Origin Resource Sharing,跨源资源共享。

跨源由协议、域名、端口决定。

例子:

text
https://a.example.com
https://b.example.com

这是跨源。

服务端允许跨源:

http
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

注意:

  • 带 cookie 的跨域请求不能使用 Access-Control-Allow-Origin: *。
  • 非简单请求会先发 OPTIONS 预检。

7. HTTP 缓存:强缓存与协商缓存 ​

7.1 强缓存 ​

强缓存命中时,浏览器不请求服务器。

关键 Header:

http
Cache-Control: max-age=31536000, immutable

含义:

  • max-age=31536000:缓存一年。
  • immutable:资源内容不会变。

适合:

text
app.8f3a1c2.js
style.9a7b1c.css
logo.aabbcc.png

前提是文件名带 hash。

7.2 协商缓存 ​

协商缓存会请求服务器确认资源是否变化。

常见 Header:

http
ETag: "abc123"
If-None-Match: "abc123"

如果未变化,服务器返回:

http
304 Not Modified

浏览器使用本地缓存。

7.3 Last-Modified ​

http
Last-Modified: Tue, 30 Jun 2026 01:00:00 GMT
If-Modified-Since: Tue, 30 Jun 2026 01:00:00 GMT

缺点:

  • 精度通常是秒。
  • 内容没变但修改时间变了也可能失效。

ETag 更精确,但需要服务器生成标识。

7.4 no-cache 与 no-store ​

no-cache:

http
Cache-Control: no-cache

可以缓存,但每次使用前必须向服务器验证。

no-store:

http
Cache-Control: no-store

完全不存储。

适合敏感数据。

7.5 前端项目推荐缓存策略 ​

HTML:

http
Cache-Control: no-cache

JS/CSS/图片:

http
Cache-Control: public, max-age=31536000, immutable

API:

按业务决定:

  • 用户信息:短缓存或不缓存。
  • 字典配置:可缓存。
  • 权限信息:切换用户或租户必须失效。
  • 列表数据:前端缓存要有失效策略。

7.6 为什么上线后用户还看到旧页面 ​

常见原因:

  • HTML 被长期缓存。
  • CDN 没刷新。
  • Service Worker 仍缓存旧资源。
  • JS 文件名没带 hash。
  • 发布时 HTML 先更新,但 JS 还没上传完成。

推荐发布顺序:

  1. 上传新 hash 静态资源。
  2. 确认资源可访问。
  3. 更新 HTML。
  4. 必要时刷新 CDN HTML 缓存。

7.7 内部机制:缓存决策流程图(RFC 9111 视角) ​

浏览器/CDN 对每个请求实际执行的判定顺序如下,curl -sv + DevTools 的 Size 列(from disk cache / from memory cache / 304)就是这张图的运行时投影:

text
请求发出
  │ 1. 能存吗?  no-store(请求或响应) → 完全不存,直接回源
  │             Authorization 头存在且无 public/s-maxage → 共享缓存默认不存
  ▼
 2. 命中已存响应?(缓存键: URL + Vary 指定的请求头)
  │否 → 回源 200,按响应指令存储
  │是
  ▼
 3. 新鲜吗?  freshness_lifetime = s-maxage(共享缓存) ?? max-age
  │          ?? Expires ?? 启发式; 当前 age < 期限 → fresh
  │是 → 直接复用(200, 0 网络请求)  ← "强缓存命中"
  │否(stale)
  ▼
 4. 有校验器?  ETag → If-None-Match(强校验器优先)
  │            Last-Modified → If-Modified-Since
  │            都没有 → 直接当 miss 回源拉全量
  ▼
 5. 发条件请求(这仍是真实网络请求,只是响应无 body)
  │ 304 → 复用已存 body,新鲜度按新响应重置   ← "协商缓存命中"
  │ 200 → 用新 body 替换旧缓存
  │ 网络错误 → 见 stale-if-error;must-revalidate 下不允许用 stale,报 504

三个易错点:

  • no-cache ≠ 不缓存:它可以存,但第 3 步永远判 stale、必须走 4/5 校验(所以 7.5 里 HTML 配 no-cache + ETag,改版前大多命中 304,只花一个 RTT 不花带宽)。
  • 同时带 If-None-Match 和 If-Modified-Since 时,RFC 9110 规定服务器以 If-None-Match 为准,Last-Modified 只是配角。
  • max-age 与 Expires 并存时 max-age 优先(RFC 9111),写 Expires 只为远古兼容。

7.8 内部机制:Age、Date 与时间修正 ​

age 不是“存了多久”,RFC 9111 §4.2.3 的定义考虑了响应在上游缓存和网络上已经老化的时间:

text
apparent_age          = max(0, response_time − date_value)
response_delay        = response_time − request_time
corrected_age_value   = age_value(收到的Age头) + response_delay
corrected_initial_age = max(apparent_age, corrected_age_value)
current_age           = corrected_initial_age + (now − response_time)

对使用者的三个推论:

  1. CDN 返回 Age: 3600 + max-age=3600 时,浏览器算得 current_age ≈ 3600 → 已 stale → 立刻条件请求。Age 是缓存间传递年龄的信使。
  2. 客户端/服务器时钟偏差被 apparent_age 部分吸收,但协商缓存的 max-age 判定不依赖你的本地时钟精确——这是 max-age 优于绝对时间 Expires 的原因之一(Expires 会被用户改系统时钟直接绕过)。
  3. must-revalidate 的严格性体现在第 5 步末尾:过期后源站不可达必须报错,不得喂 stale;而默认(无 must-revalidate、无 no-cache 的普通 max-age 响应)浏览器允许在源站故障时按启发式提供 stale(stale-while-revalidate/stale-if-error 见 7.9)。

7.9 怎么用:stale-while-revalidate 与 s-maxage(RFC 5861 / 9111) ​

http
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=30, stale-if-error=3600
指令行为典型用途
stale-while-revalidate=Nstale 后 N 秒内:先立即返回旧副本,同时后台回源刷新(SWR)。用户永远不等待,代价是可能看到最多 max-age+N 秒旧的数据列表页、配置接口、SSR 页面边缘缓存
stale-if-error=N回源失败(5xx/超时)时 N 秒内允许继续喂 stale大促/故障期间的可用性兜底
s-maxage=600只作用于共享缓存(CDN),覆盖 max-age;proxy-revalidate 同理“浏览器 1 分钟一验证、CDN 替全站挡 10 分钟”的标准写法

💡 这两个指令让“缓存命中”与“数据最新”第一次可以解耦:源站挂了页面照开(stale-if-error),用户点开时旧数据先出、新数据下次进(SWR)。Nuxt/Vercel/Cloudflare 的 ISR、edge cache 底层就是这套语义。

7.10 注意事项:Vary、内存缓存与 no-cache 家族补充 ​

  • Vary 是缓存键的一部分:Vary: Accept-Encoding 意味着 gzip/br 各存一份;Vary: Cookie/Authorization 会让几乎每个用户都 miss(等价于废掉共享缓存),个性化响应应改用 Cache-Control: private。
  • 浏览器还有 memory cache(页面存活期内复用,常为同页重复请求/内联小资源)与 preload 缓存,它们不受 HTTP 缓存头精确控制——DevTools 里“明明 no-store 却还是 from memory cache”不是 bug,测试 HTTP 缓存行为要新标签页或 disable cache + 硬刷新。
  • 双缓存键陷阱:If-Modified-Since 与 If-None-Match 都发时服务器只认后者;ETag 若被中间层改写(某些反代压缩后重算)会导致校验永远失败——排查 304 不生效时对比 curl -I 源站与 CDN 的 ETag 是否一致。
  • private 响应只能进浏览器缓存;企业网络里 MITM 代理(装根证书)仍可能看见内容,涉密响应唯一可靠手段是 no-store 且评估合规场景。

8. CDN 原理与缓存策略 ​

8.1 CDN 是什么 ​

CDN:Content Delivery Network,内容分发网络。

它把资源缓存到离用户更近的边缘节点。

请求流程:

text
用户 -> CDN 边缘节点 -> 源站

如果边缘节点有缓存:

text
用户 -> CDN 边缘节点 -> 返回资源

这叫缓存命中。

如果没有:

text
用户 -> CDN -> 源站 -> CDN 缓存 -> 用户

这叫回源。

8.2 CDN 缓存命中率 ​

命中率高说明更多请求由 CDN 直接响应,源站压力低,用户访问快。

命中率低可能是:

  • 资源 URL 不稳定。
  • Cache-Control 不合理。
  • 查询参数过多。
  • 频繁刷新缓存。
  • 动态接口错误地走 CDN。

8.3 CDN 回源 ​

回源就是 CDN 没有缓存或缓存过期时请求源站。

回源多会导致:

  • 源站压力上升。
  • 用户响应变慢。
  • TTFB 增高。

8.4 CDN 缓存键 ​

缓存键通常由:

  • 协议。
  • 域名。
  • 路径。
  • 查询参数。
  • 部分 Header。

决定。

例如:

text
/app.js?v=1
/app.js?v=2

可能是两个缓存对象。

8.5 CDN 和 HTTP 缓存的关系 ​

浏览器缓存发生在用户设备。

CDN 缓存发生在边缘节点。

它们可以同时存在。

text
浏览器缓存 -> CDN 缓存 -> 源站

8.6 前端静态资源 CDN 策略 ​

推荐:

  • hash 文件名。
  • 长缓存。
  • HTML 短缓存。
  • 资源不可变。
  • 避免覆盖同名 JS。

示例:

text
/assets/app.8f3a1c2.js
/assets/vendor.91ab23.css

8.7 内部机制:域名解析到哪里、边缘节点怎么找到 ​

CDN 的“就近”首先发生在 DNS 层:

text
浏览器解析 www.example.com
  -> Local DNS -> 权威 DNS 发现 CNAME: www.example.com.cdn.net
  -> 再解析 cdn.net,由 CDN 的调度 DNS(GSLB) 根据
     本地 DNS 出口 IP / EDNS Client Subnet 推断用户位置
  -> 返回“当前认为最优”的边缘节点 IP

这意味着:用户实际连到哪个节点由 LDNS 出口决定而不是用户真实 IP——代理、VPN、公共 DNS(8.8.8.8)都可能把你调度到很远或拥塞的节点。排查“部分地区用户慢”先怀疑调度,而不是源站。

命中判定的完整链路(边缘节点本质是一个共享 HTTP 缓存,遵守第 7 章全部规则):

text
请求到边缘节点
  ├ 缓存键 = scheme + host + path + 配置的query/header(= 8.4)
  ├ 命中且新鲜 → 直接返回(+Age头, 不回源)
  ├ 命中但过期 → 向源站发 If-None-Match/If-Modified-Since 条件回源
  │     源站 304 → 边缘复用副本返回(部分CDN透传304,部分返回200,行为看厂商)
  ├ MISS → 回源取全量并按源站 Cache-Control 决定存不存、存多久
  └ 源站 5xx → 按 stale-if-error 决定喂旧或透传错误

⚠️ 常见事故:源站没发 Cache-Control 时,不少 CDN 会按自己的默认策略缓存(有的默认忽略 Set-Cookie 后仍缓存 HTML),表现为“部分用户看到别人的页面”。永远显式声明 Cache-Control,并确认 CDN 的“遵循源站”开关与规则优先级。

💡 观察点:响应头里的 cf-cache-status: HIT/MISS(Cloudflare)、X-Cache(Fastly/AWS CloudFront)、Age——上线后用 curl -sI 对比两次请求就能验证边缘命中,不必等产品日志。

8.8 怎么用:缓存刷新、预热与版本化发布 ​

刷新(purge)与预取(prefetch)是 managed cache 提供的能力(HTTP 标准本身没有定义删除缓存的机制):

操作机制注意
URL 刷新API 按精确 URL 删除边缘副本全球节点异步生效(秒~分钟级),刷新完成 ≠ 所有节点完成;用户浏览器侧缓存不受影响
目录/标签刷新按前缀或 cache-tag 批量失效cache-tag 需在响应头声明,粒度设计是架构决策
预热 prefetch让边缘节点提前回源缓存指定 URL大版本发布前预热 hash 资源与新 HTML
版本化(推荐默认)文件名 hash + 长缓存,天然无需刷新只有无法改 URL 的资源(HTML、manifest、/favicon.ico)才真正需要刷新

9. Service Worker 与离线缓存 ​

9.1 Service Worker 是什么 ​

Service Worker 是运行在浏览器后台的脚本,可以拦截网络请求、管理缓存、支持离线访问。

它不运行在页面主线程里。

9.2 能做什么 ​

  • 缓存静态资源。
  • 离线页面。
  • 后台同步。
  • 推送通知。
  • 请求代理。

9.3 风险 ​

Service Worker 很强,也容易制造缓存事故。

常见问题:

  • 用户一直拿到旧 JS。
  • 新版本无法更新。
  • 离线缓存污染。
  • 回滚困难。

9.4 简化缓存示例 ​

js
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open("app-v1").then((cache) =>
      cache.addAll(["/", "/offline.html"])
    )
  );
});

self.addEventListener("fetch", (event) => {
  event.respondWith(
    fetch(event.request).catch(() => caches.match("/offline.html"))
  );
});

生产建议使用成熟库或框架插件,并设计更新策略。

9.5 内部机制:Service Worker 生命周期与“为什么旧版本赖着不走” ​

SW 的事故几乎都源于没吃透它的状态机:

text
install →(缓存预置, waitUntil)→ installed
        → activate →(清理旧 cache, 接管客户端)→ activated
                                     │
新版本 SW 下载成功                    │  但不会立刻生效!
waiting ────────────────────────────┘  直到旧 SW 控制的所有页面关闭
     │ skipWaiting() 主动跳过(或客户端自管)
     ▼
  activated(控制后续所有 fetch)

要点:

  1. 双版本并存:新版本在 waiting 状态时,当前页面仍被旧 SW 的 fetch handler 控制——“发布了新版本但用户还是旧的”是设计使然,不是 bug。
  2. 更新触发时机有限:浏览器在导航请求时对比 SW 脚本字节(软刷新不会触发更新检查,硬刷新/DevTools “Update on reload” 会),且规范保证即使无人访问也至少每 24 小时检查一次;Cache-Control 头也作用于 sw.js 本身的网络请求——给 sw.js 配长强缓存等于把更新周期拉长到分钟级以上。
  3. 常用的 skipWaiting + clients.claim() 让新版本尽快接管,但风险是老页面里已加载的 JS 可能依赖旧 hash 的资源清单,接管后缓存被清 → 动态 import 失败。SPA 异步 chunk 项目必须配 import() 失败自动 reload 兜底。
  4. 注销路径要提前设计:registration.unregister() + 主动清 Cache Storage + 一次性全量 reload;否则“卸载 SW”比“装上 SW”更难干净。

💡 页面侧感知更新的标准做法:注册后监听 registration.waiting,弹“发现新版本,点击刷新”,用户确认后再向 waiting SW 发 SKIP_WAITING 消息——把更新时机交还给用户,而不是静默替换。

9.6 注意事项:SW 缓存与 HTTP 缓存的关系 ​

两者是独立的两层:SW 的 fetch(event.request) 仍会正常命中浏览器 HTTP 缓存;caches.match 查的才是 Cache API。常见误区:

  • SW 里 networkFirst 策略绕过不了 HTTP 缓存里“新鲜的 HTML”——要先 fetch(url, { cache: "no-store" })。
  • Cache API 的 Response 一次只能读一个 body,多路用要 clone() 或 tee()(与流式处理文档 6.3 节的 tee 积压问题同源)。
  • 所有 SW 作用域内请求都过 handler——忘记排除 /api/ 的 SW 缓存了登录态接口,就是跨用户数据泄露。规则集必须是显式白名单。

10. BFF 是什么,解决什么问题 ​

10.1 BFF 定义 ​

BFF:Backend For Frontend,为特定前端体验服务的后端。

它不是通用后端,而是面向某个前端应用、某类终端或某种页面体验的适配层。

10.2 BFF 解决的问题 ​

接口聚合 ​

没有 BFF:

text
页面 -> /user
页面 -> /orders
页面 -> /notices
页面 -> /permissions

有 BFF:

text
页面 -> /dashboard
BFF -> 并发请求 user/orders/notices/permissions

减少请求瀑布。

数据裁剪 ​

后端通用接口返回很多字段,前端只需要一部分。

BFF 可以裁剪:

ts
return {
  id: user.id,
  name: user.name,
  avatar: user.avatar,
};

多端适配 ​

Web 需要大屏数据。

小程序需要轻量数据。

管理后台需要权限信息。

同一个底层服务不一定适合所有端直接使用。

鉴权适配 ​

BFF 可以处理:

  • Cookie。
  • token refresh。
  • session。
  • CSRF token。
  • server-side auth。

10.3 BFF 不该做什么 ​

BFF 不应该:

  • 绕过后端权限。
  • 承担核心领域逻辑。
  • 成为所有业务逻辑堆积层。
  • 无测试、无监控、无错误处理。
  • 只是机械转发。

10.4 职责边界判定表:这段逻辑放哪一层 ​

“该不该放 BFF”的判据不是代码量,而是三个问题:换个端还需要吗?数据归属谁?出错谁负责?

逻辑归属理由
判断用户能否看某条数据(权限)领域服务(后端)数据归属方必须执行最终权限判定;BFF 过滤是优化不是防线
订单金额计算、库存扣减领域服务核心领域规则,任何端都不能绕
把 5 个接口拼成 1 个页面聚合BFF页面体验相关,换端可能换聚合方式
字段裁剪、文案格式化(时间→相对时间)、枚举翻译BFF展示逻辑,属于端
Cookie/Session/CSRF/token refresh 适配BFF面向端的鉴权形态差异
缓存策略(SWR、字典缓存)BFF 或 API Gateway看是否所有端共享同一份
前端组件状态、路由守卫前端不属于任何服务端
跨服务事务编排(下单扣库存发通知)领域服务/流程引擎BFF 没有事务能力,放这里必然出现半成功

两条硬规则:

  1. 纵深防御:BFF 的裁剪/过滤只能是性能与体验优化;即使有人绕过 BFF 直连后端(内网调用、脚本),后端返回的数据也必须是该调用方可见的。“前端看不看得到”和“接口给不给”是两回事。
  2. 多端复用率是预警线:同一段 BFF 逻辑被 3 个以上端复用,说明它其实是领域能力,应下沉到后端服务;BFF 保持“薄、快、面向体验”。

10.5 注意事项:BFF 的隐性成本 ​

上 BFF 前先承认这些代价:

  • 多一跳延迟:BFF 与后端服务不同区/不同 VPC 时,每个聚合请求 +1~3ms RTT × N 个上游。BFF 应贴近后端部署,而不是贴近 CDN。
  • 多一套运维面:独立发布流水线、容量规划(长连接/聚合请求的 CPU 画像与 CRUD 服务完全不同)、日志链路要带前端 trace id 贯通。
  • 状态同步错觉:BFF 缓存了用户信息,后端权限变更后 BFF 还在吐旧数据——所有 BFF 缓存都要有明确的失效协议(事件驱动 > TTL)。
  • 组织耦合:前端团队拥有服务端代码意味着要背值班、安全扫描、依赖升级。评估时把“谁 on-call BFF”写进方案,否则默认变成后端团队多养一层。

💡 BFF 适合作为“前端体验的最后一公里”:SSR 数据预取、小程序聚合、多端裁剪。如果它开始拥有业务表、写数据库、跑定时任务——它已经不是 BFF,是没人认领的第 N+1 个后端服务。


11. BFF、API Gateway、SSR Server 的区别 ​

11.1 API Gateway ​

API Gateway 面向所有客户端,常负责:

  • 路由转发。
  • 鉴权。
  • 限流。
  • 熔断。
  • 负载均衡。
  • 协议转换。

它更偏基础设施。

11.2 BFF ​

BFF 面向特定前端,常负责:

  • 页面数据聚合。
  • 前端数据适配。
  • 多端差异。
  • SSR 数据准备。
  • 前端体验优化。

它更偏业务体验适配。

11.3 SSR Server ​

SSR Server 负责服务端渲染 HTML。

它可能同时承担 BFF,但不是必然。

Nuxt / Next 项目中:

  • server route 可以作为 BFF。
  • SSR 渲染可以调用 BFF。
  • 也可以单独部署独立 BFF。

11.4 区别总结 ​

名称面向对象主要职责
API Gateway所有客户端和服务流量入口、鉴权、限流、路由
BFF某个前端应用/终端聚合、裁剪、适配、体验优化
SSR Server页面渲染生成 HTML、流式渲染、SEO

12. BFF 在 Vue/React/Nuxt/小程序中的落地 ​

12.1 Vue / React SPA ​

SPA 中 BFF 通常是独立 Node 服务:

text
Browser -> BFF -> Backend Services

前端请求:

ts
const dashboard = await fetch("/bff/dashboard").then((res) => res.json());

BFF 聚合:

ts
const [user, stats, notices] = await Promise.all([
  userClient.me(token),
  statsClient.summary(token),
  noticeClient.latest(token),
]);

12.2 Nuxt ​

Nuxt 可以用 server/api 做轻量 BFF:

ts
export default defineEventHandler(async (event) => {
  const token = getCookie(event, "token");

  const [user, stats] = await Promise.all([
    $fetch("https://api.example.com/me", {
      headers: { Authorization: `Bearer ${token}` },
    }),
    $fetch("https://api.example.com/stats", {
      headers: { Authorization: `Bearer ${token}` },
    }),
  ]);

  return { user, stats };
});

适合:

  • SSR 页面数据。
  • 简单聚合。
  • 轻量鉴权适配。

复杂 BFF 仍建议独立服务。

12.3 小程序 ​

小程序非常适合 BFF:

  • 减少请求数量。
  • 裁剪字段。
  • 减小响应体。
  • 适配小程序登录。
  • 隐藏后端服务结构。
text
Mini Program -> BFF -> Backend Services

例如首页只请求:

text
GET /mini/home

BFF 返回:

json
{
  "user": {},
  "banners": [],
  "menus": [],
  "notices": []
}

13. Node/Nest/Express BFF 实现示例 ​

13.1 Express BFF ​

ts
app.get("/bff/dashboard", authMiddleware, async (req, res, next) => {
  try {
    const token = req.user.token;

    const [user, stats, notices] = await Promise.all([
      userClient.getMe(token),
      statsClient.getSummary(token),
      noticeClient.getLatest(token),
    ]);

    res.json({
      user: {
        id: user.id,
        name: user.name,
      },
      stats,
      notices: notices.slice(0, 5),
    });
  } catch (error) {
    next(error);
  }
});

13.2 NestJS BFF ​

ts
@Controller("bff")
export class DashboardBffController {
  constructor(private readonly dashboardService: DashboardBffService) {}

  @Get("dashboard")
  @UseGuards(AuthGuard)
  getDashboard(@Req() req: Request) {
    return this.dashboardService.getDashboard(req.user);
  }
}

Service:

ts
@Injectable()
export class DashboardBffService {
  async getDashboard(user: CurrentUser) {
    const [profile, stats, notices] = await Promise.all([
      this.userClient.getProfile(user.token),
      this.statsClient.getStats(user.token),
      this.noticeClient.getNotices(user.token),
    ]);

    return {
      profile,
      stats,
      notices: notices.slice(0, 5),
    };
  }
}

13.3 BFF 必须有的工程能力 ​

  • 超时控制。
  • 错误兜底。
  • 日志。
  • trace id。
  • 鉴权。
  • 限流。
  • 缓存。
  • 参数校验。
  • OpenAPI。
  • 单元测试。
  • 监控告警。

13.4 BFF 缓存示例 ​

适合缓存:

  • 字典。
  • 配置。
  • 首页公共信息。
  • 权限较稳定的数据。

不适合缓存:

  • 高敏感个人数据。
  • 实时订单状态。
  • 金额变动信息。

示例:

ts
const cacheKey = `dict:${tenantId}`;
const cached = await redis.get(cacheKey);

if (cached) {
  return JSON.parse(cached);
}

const dict = await dictClient.getAll();
await redis.set(cacheKey, JSON.stringify(dict), "EX", 300);
return dict;

14. 常见问题排查 ​

14.1 页面白屏 ​

检查:

  • HTML 是否返回 200。
  • JS/CSS 是否 404。
  • JS 是否语法不兼容。
  • CDN 是否返回旧资源。
  • Service Worker 是否缓存旧版本。
  • 控制台是否有运行时错误。

14.2 缓存不生效 ​

检查:

  • Cache-Control。
  • ETag。
  • CDN 是否尊重源站 Header。
  • URL 是否每次变化。
  • 是否带了 no-store。
  • DevTools 是否勾选 Disable cache。

14.3 上线后资源 404 ​

检查:

  • base 路径。
  • CDN 上传是否完整。
  • HTML 是否引用新 hash 文件。
  • 发布顺序是否错误。
  • 路由 history fallback 是否配置。

14.4 BFF 慢 ​

检查:

  • 是否串行请求。
  • 上游接口慢。
  • 是否缺少缓存。
  • 是否跨区域调用。
  • 是否没有超时。
  • 是否重复请求。

14.5 小程序首屏慢 ​

检查:

  • 主包体积。
  • 分包。
  • 首页接口数量。
  • 图片大小。
  • setData 数据量。
  • BFF 是否聚合接口。

15. 学习路线与练习 ​

15.1 基础阶段 ​

  1. 用 DevTools Network 观察 HTML、JS、CSS、图片请求。
  2. 区分强缓存和协商缓存。
  3. 写一个事件循环输出顺序题。
  4. 理解 DOM、CSSOM、渲染树。

15.2 进阶阶段 ​

  1. 给 Vite 项目配置正确静态资源缓存。
  2. 用 Nginx 模拟 HTML no-cache、JS 长缓存。
  3. 分析一次 CDN 回源。
  4. 写一个 Express BFF 聚合接口。

15.3 高级阶段 ​

  1. 设计一个小程序首页 BFF。
  2. 设计 SSR 页面缓存策略。
  3. 接入 Service Worker 并设计更新策略。
  4. 用 trace id 串联前端请求和 BFF 日志。

16. 官方参考资料 ​

基于 VitePress 构建的个人学习文档知识库