外观
浏览器原理、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 等官方资料整理。
目录
- 为什么前端架构师必须懂这些
- 浏览器从输入 URL 到页面展示发生了什么
- DOM、CSSOM、渲染树、布局、绘制、合成
- JavaScript 事件循环、宏任务、微任务
- 浏览器存储:Cookie、localStorage、IndexedDB、Cache API
- HTTP 基础:请求、响应、方法、状态码、Header
- HTTP 缓存:强缓存与协商缓存
- CDN 原理与缓存策略
- Service Worker 与离线缓存
- BFF 是什么,解决什么问题
- BFF、API Gateway、SSR Server 的区别
- BFF 在 Vue/React/Nuxt/小程序中的落地
- Node/Nest/Express BFF 实现示例
- 常见问题排查
- 学习路线与练习
- 官方参考资料
1. 为什么前端架构师必须懂这些
很多前端问题表面看是框架问题,本质是浏览器、网络或缓存问题。
例如:
- 首屏慢:可能是 TTFB、LCP 图片、CSS 阻塞、JS 长任务。
- 白屏:可能是 JS 404、base 路径错误、浏览器不兼容、缓存错配。
- 页面跳动:可能是图片未设尺寸、字体加载导致 CLS。
- 按钮点击卡顿:可能是主线程长任务导致 INP 差。
- 新版本上线后用户仍看到旧页面:可能是 HTML 缓存策略错误。
- 接口很慢:可能是请求瀑布、跨区域网络、BFF 缺失、缓存缺失。
- 小程序弱网体验差:可能是接口数量多、包体积大、缓存策略差。
前端架构师要能从“代码层”一路追到“浏览器层、网络层、服务层、缓存层”。
2. 浏览器从输入 URL 到页面展示发生了什么
2.1 总流程
当用户输入 URL 后,大致发生:
- URL 解析。
- DNS 查询。
- TCP 连接。
- TLS 握手。
- 发送 HTTP 请求。
- 服务器返回 HTML。
- 浏览器解析 HTML。
- 下载 CSS、JS、图片、字体等子资源。
- 构建 DOM。
- 构建 CSSOM。
- 合成渲染树。
- 布局。
- 绘制。
- 合成。
- 执行 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
pJavaScript 可以操作 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“渲染网络关键路径”):
- HTML 解析是流式的、渐进的:浏览器不等整页下载,边收字节边建 DOM。这是首屏能逐步显示的基础。
- 同步
<script>阻塞解析器:解析到没有defer/async/type="module"的脚本时,解析器暂停 → 等下载 → 等执行完 → 才继续解析后面的 HTML。因为脚本可能document.write改写剩余文档结构。 - 脚本执行前必须等 CSSOM 就绪:JS 里任何
getComputedStyle都可能读样式,所以浏览器会阻塞脚本执行直到前面的<link rel=stylesheet>解析完。这就是“CSS 在前、JS 在后”的排版顺序会真正拖慢渲染的原因。 - 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.log4.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原因:
- 同步代码先执行。
- 当前宏任务结束后清空微任务队列。
- 再执行下一个宏任务。
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 的“宏-微”模型之外,架构层面还需要两条精确规则:
- 微任务在帧内也会被清空:每个宏任务结束后清空微任务队列;rAF 回调、ResizeObserver 等渲染相关工作之后也会再清一次微任务。所以“
Promise.resolve().then()一定在下帧前执行”成立,但“微任务之间不渲染”也成立——任何微任务风暴(递归 then)都会饿死渲染,效果等同长任务,且setTimeout切分才是让出渲染的正确工具(4.6 示例的深层原因)。 - 一次任务里的 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
5.1 Cookie
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/json6.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-cacheJS/CSS/图片:
http
Cache-Control: public, max-age=31536000, immutableAPI:
按业务决定:
- 用户信息:短缓存或不缓存。
- 字典配置:可缓存。
- 权限信息:切换用户或租户必须失效。
- 列表数据:前端缓存要有失效策略。
7.6 为什么上线后用户还看到旧页面
常见原因:
- HTML 被长期缓存。
- CDN 没刷新。
- Service Worker 仍缓存旧资源。
- JS 文件名没带 hash。
- 发布时 HTML 先更新,但 JS 还没上传完成。
推荐发布顺序:
- 上传新 hash 静态资源。
- 确认资源可访问。
- 更新 HTML。
- 必要时刷新 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)对使用者的三个推论:
- CDN 返回
Age: 3600+max-age=3600时,浏览器算得 current_age ≈ 3600 → 已 stale → 立刻条件请求。Age 是缓存间传递年龄的信使。 - 客户端/服务器时钟偏差被 apparent_age 部分吸收,但协商缓存的 max-age 判定不依赖你的本地时钟精确——这是 max-age 优于绝对时间 Expires 的原因之一(Expires 会被用户改系统时钟直接绕过)。
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=N | stale 后 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.css8.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)要点:
- 双版本并存:新版本在 waiting 状态时,当前页面仍被旧 SW 的 fetch handler 控制——“发布了新版本但用户还是旧的”是设计使然,不是 bug。
- 更新触发时机有限:浏览器在导航请求时对比 SW 脚本字节(软刷新不会触发更新检查,硬刷新/DevTools “Update on reload” 会),且规范保证即使无人访问也至少每 24 小时检查一次;
Cache-Control头也作用于sw.js本身的网络请求——给sw.js配长强缓存等于把更新周期拉长到分钟级以上。 - 常用的
skipWaiting + clients.claim()让新版本尽快接管,但风险是老页面里已加载的 JS 可能依赖旧 hash 的资源清单,接管后缓存被清 → 动态 import 失败。SPA 异步 chunk 项目必须配import()失败自动 reload 兜底。 - 注销路径要提前设计:
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 没有事务能力,放这里必然出现半成功 |
两条硬规则:
- 纵深防御:BFF 的裁剪/过滤只能是性能与体验优化;即使有人绕过 BFF 直连后端(内网调用、脚本),后端返回的数据也必须是该调用方可见的。“前端看不看得到”和“接口给不给”是两回事。
- 多端复用率是预警线:同一段 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/homeBFF 返回:
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 基础阶段
- 用 DevTools Network 观察 HTML、JS、CSS、图片请求。
- 区分强缓存和协商缓存。
- 写一个事件循环输出顺序题。
- 理解 DOM、CSSOM、渲染树。
15.2 进阶阶段
- 给 Vite 项目配置正确静态资源缓存。
- 用 Nginx 模拟 HTML no-cache、JS 长缓存。
- 分析一次 CDN 回源。
- 写一个 Express BFF 聚合接口。
15.3 高级阶段
- 设计一个小程序首页 BFF。
- 设计 SSR 页面缓存策略。
- 接入 Service Worker 并设计更新策略。
- 用 trace id 串联前端请求和 BFF 日志。
16. 官方参考资料
- MDN Critical rendering path:https://developer.mozilla.org/en-US/docs/Web/Performance/Critical_rendering_path
- MDN Event loop:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop
- MDN HTTP:https://developer.mozilla.org/en-US/docs/Web/HTTP
- MDN HTTP caching:https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching
- MDN Cache-Control:https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
- MDN ETag:https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
- MDN CORS:https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
- MDN Service Worker:https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
- MDN Cache API:https://developer.mozilla.org/en-US/docs/Web/API/Cache
- web.dev Learn Performance:https://web.dev/learn/performance
- Chrome DevTools Network:https://developer.chrome.com/docs/devtools/network
- Cloudflare Cache:https://developers.cloudflare.com/cache/
- Azure BFF Pattern:https://learn.microsoft.com/en-us/azure/architecture/patterns/backends-for-frontends
- Express:https://expressjs.com/
- NestJS:https://docs.nestjs.com/
- Nuxt Server Routes:https://nuxt.com/docs/guide/directory-structure/server