04 · 浏览器与网络
本篇解决什么问题: 这是前端最不易过时的基本功。框架会变,但浏览器渲染流程、HTTP 缓存、TCP/TLS 这些 十年后依然有效。本篇聚焦"原理 + 排查思路",而不是背 API。
📌 与
flutter/区的呼应(本区核心写法):
前端 Flutter 对应 参考 浏览器渲染流水线 Flutter 三棵树 + 渲染流水线 Flutter 03 篇 重排 Reflow / 重绘 Repaint relayout / repaint Flutter 03 篇 合成层 / GPU 加速 RepaintBoundary/ 合成层Flutter 22 篇 HTTP 缓存 无对应(App 自己管缓存) 本篇第三节 60fps / 16.67ms 帧预算 同样的 16.67ms 帧预算 Flutter 22 篇 ⚠️ 易变信息:HTTP/3 普及度、浏览器实现细节、具体头部字段名会演进, 按本库约定以 MDN 为准;本篇讲稳定的原理与排查框架。
开篇:前端 ↔ Flutter 概念对照
| 前端 | Flutter | 说明 |
|---|---|---|
| DOM 树 | Widget 树(配置/蓝图) | 都是"描述结构" |
| CSSOM + 样式计算 | Element 树 | 都是"中间层" |
| 渲染树(Render Tree) | RenderObject 树 | 真正负责布局与绘制 |
| 重排 Reflow | relayout | 重新计算布局 |
| 重绘 Repaint | repaint | 重新绘制像素 |
| 合成 Composite | 合成 / 光栅化 | GPU 处理 |
合成层(will-change) | RepaintBoundary | 隔离重绘范围 |
| 事件循环(Event Loop) | 事件循环 + Isolate | 都是单线程模型 |
| 微任务 / 宏任务 | microtask / event queue | 几乎同名 |
| 60fps / 16.67ms 预算 | 同样的 16.67ms 预算 | 数学完全一样 |
最重要的一条:
前端和 Flutter 用的是同一套渲染流水线模型(布局 → 绘制 → 合成), 以及同一套 16.67ms 帧预算。学会一边,另一边能直接迁移。
一、渲染流水线:从 HTML 到像素
1.1 五个阶段
① 解析(Parse)
HTML → DOM 树
CSS → CSSOM 树
② 样式计算(Style / Recalc Style)
DOM + CSSOM → 带计算样式的渲染树
(哪个元素最终是什么颜色、多大字号)
③ 布局(Layout / Reflow) ← 最贵
计算每个元素的**位置和尺寸**(几何信息)
④ 绘制(Paint / Repaint)
把元素画成一个个"绘制指令"(分图层)
⑤ 合成(Composite) ← 最便宜
把图层交给 GPU 合成并显示1.2 关键:不同操作触发不同阶段(性能核心)
| 你改了什么 | 触发哪些阶段 | 开销 |
|---|---|---|
width / height / padding / margin / font-size | ③ → ④ → ⑤ | 最贵(重排) |
color / background / box-shadow / visibility | ④ → ⑤ | 中(重绘) |
transform / opacity | ⑤ only | 最便宜(只合成) |
💡 这就是"动画要用 transform/opacity"的根本原因—— 它们跳过布局和绘制,直接走合成,通常在 GPU 上完成。
完全对应 Flutter 29 篇 的口诀 "动 Transform,不动布局属性"。两边是同一个道理。
1.3 强制同步布局(Layout Thrashing)
这是前端最经典的性能杀手之一:
// ❌ 灾难:读写交替,每次读都强制浏览器重新布局
for (let i = 0; i < items.length; i++) {
items[i].style.width = '100px'; // 写(让布局失效)
const h = items[i].offsetHeight; // 读(强制立即重新布局!)
console.log(h);
}
// → 触发 N 次强制同步布局// ✅ 正确:先批量读,再批量写
const heights = items.map(el => el.offsetHeight); // 先全部读完
items.forEach(el => { el.style.width = '100px'; }); // 再统一写
// → 只触发 1 次布局原因:浏览器本可以"攒着"多次改动一次性布局, 但一旦你在改完之后立刻读取几何属性(offsetHeight、getBoundingClientRect()、scrollTop), 浏览器被迫立即执行布局以返回正确值。
排查方法:DevTools Performance 面板里出现大量 "Layout" 紫色块,或控制台警告 Forced reflow while executing JavaScript。
1.4 合成层与 will-change
/* 提前提示浏览器:这个元素要动,单独提一层 */
.animated {
will-change: transform;
/* 或 transform: translateZ(0); 老写法 */
}| 优点 | 代价 |
|---|---|
| 该元素动画时不触发父级重绘 | 每个层都占显存 |
⚠️ 不要滥用:
will-change加太多会耗尽显存反而更慢。 只在确实要做动画的元素上加,动画结束后移除。这完全对应 Flutter 的
RepaintBoundary——同样是"隔离重绘范围,但有成本"。
二、事件循环:微任务与宏任务
2.1 模型
┌─────────────────────────────────────────┐
│ 1. 执行一个宏任务(如 script、setTimeout 回调) │
│ 2. 清空**全部**微任务队列 │
│ 3. 必要时渲染(rAF → 布局 → 绘制) │
│ 4. 取下一个宏任务,回到 1 │
└─────────────────────────────────────────┘关键差异:
| 宏任务(Macro Task) | 微任务(Micro Task) | |
|---|---|---|
| 例子 | setTimeout / setInterval / I/O / UI 事件 | Promise.then / queueMicrotask / MutationObserver |
| 每轮执行 | 一个 | 全部(清空为止) |
| 优先级 | 低 | 高 |
2.2 经典输出顺序题
console.log('1'); // 同步
setTimeout(() => console.log('2')); // 宏任务
Promise.resolve().then(() => {
console.log('3'); // 微任务
});
console.log('4'); // 同步
// 输出:1 → 4 → 3 → 2推理过程:
- 同步代码先跑:
1、4 - 同步跑完 → 清空微任务队列 →
3 - 微任务清空 → 取下一个宏任务 →
2
2.3 为什么这很重要(不止是面试题)
| 场景 | 影响 |
|---|---|
| 微任务里死循环 | 会永久阻塞渲染(微任务不清空就不渲染) |
| 想在 DOM 更新后做事 | Promise.then 在渲染前;要渲染后用 requestAnimationFrame |
| 拆分长任务 | 用 setTimeout(宏任务)让出主线程,给渲染机会 |
// ❌ 微任务里递归 → 页面卡死,永远不渲染
function loop() { Promise.resolve().then(loop); }
// ✅ 宏任务递归 → 每帧之间浏览器有机会渲染
function loop() { setTimeout(loop, 0); }2.4 与 Flutter 的对照
| 前端 | Flutter | |
|---|---|---|
| 模型 | 单线程 + 事件循环 | 单线程(Isolate)+ 事件循环 |
| 队列 | 宏任务 / 微任务 | event queue / microtask queue(几乎同名) |
| 卡住主线程 | 页面卡死、掉帧 | UI 掉帧(jank) |
| 解决方案 | Web Worker | Isolate(或 compute) |
见 Flutter 06 篇。 心智模型完全一致——差异只在"要多开一个线程时用什么 API"。
三、HTTP 缓存:最容易配置错的地方
3.1 两种缓存策略
| 策略 | 头部 | 行为 | 适用 |
|---|---|---|---|
| 强缓存 | Cache-Control: max-age=3600 | 有效期内不发请求,直接用本地 | 带 hash 的静态资源 |
| 协商缓存 | ETag / Last-Modified | 发请求问服务端,没变则返回 304 | HTML、可能变的资源 |
3.2 决策流程
浏览器要请求一个资源
│
├─ 有强缓存且未过期? ── 是 ──→ 直接用(200 from cache,**不发请求**)
│
└─ 否 → 发请求,带上 If-None-Match(ETag)
│
├─ 服务端说"没变"(304)──→ 用本地副本(省了传输,但**有一次往返**)
└─ 服务端说"变了"(200)──→ 下载新资源3.3 常见配置(照抄这三档)
| 资源类型 | 配置 | 理由 |
|---|---|---|
带 hash 的 JS/CSS(如 app.a3f2.js) | Cache-Control: max-age=31536000, immutable | 内容变了文件名就变,可以永久缓存 |
| HTML 入口 | Cache-Control: no-cache | ⚠️ 注意:no-cache 不是"不缓存",而是"每次都要协商" |
| 图片/字体(不带 hash) | Cache-Control: max-age=86400 + ETag | 短期强缓存 + 协商 |
3.4 三个高频误区
| 误区 | 正解 |
|---|---|
"no-cache = 不缓存" | ❌ no-cache = 每次都协商(还会存);真正不缓存是 no-store |
"max-age=0 等于不缓存" | ❌ 同 no-cache,仍会协商 |
| 静态资源不加 hash 就设长缓存 | ❌ 致命:内容更新了但浏览器还用旧的。必须先做文件名 hash |
经典事故:
错误配置:index.html 和 app.js 都设了 max-age=31536000
结果:发新版后,用户浏览器一直用旧的 index.html → 引用的还是旧 app.js
→ 用户永远看不到新版本(除非手动清缓存)
正确做法:index.html 用 no-cache(每次协商),app.[hash].js 用长缓存3.5 Flutter 里没有这些
| 前端 | Flutter | |
|---|---|---|
| HTTP 缓存 | 浏览器自动处理 | ❌ 必须自己实现(Dio 拦截器 + 本地存储) |
| 静态资源 | 从 CDN 加载,有缓存 | 打包进 App,随包发布 |
| "发新版用户看不到" | 缓存配置错误导致 | 不存在(但不能热更新,见 01 篇) |
💡 这是前端转 Flutter 的一个认知落差: 浏览器帮你做了很多事(缓存、资源加载),Flutter 里全都要自己来。
四、网络协议要点(够用版)
4.1 HTTP 版本演进(理解趋势即可)
| 版本 | 关键改进 | 解决的问题 |
|---|---|---|
| HTTP/1.1 | 持久连接、管道化 | 减少连接建立开销 |
| HTTP/2 | 多路复用、头部压缩、服务端推送 | 解决队头阻塞(应用层) |
| HTTP/3 | 基于 QUIC(UDP) | 进一步解决传输层队头阻塞,连接迁移更快 |
HTTP/2 的核心价值:一个连接上并发多个请求,不用像 1.1 那样开 6 个连接。
⚠️ 实践影响:HTTP/2 下,"合并小文件"、"雪碧图"这类 HTTP/1.1 时代的优化 反而不一定有效(甚至有害,因为破坏了缓存粒度)。
4.2 HTTPS / TLS 握手(知道流程)
1. ClientHello 客户端支持的加密套件、随机数
2. ServerHello 服务端选定套件、随机数、下发证书
3. 验证证书 客户端校验证书链、域名、有效期
4. 密钥交换 生成会话密钥(ECDHE)
5. 加密通信 后续用对称加密要点:
- 用非对称加密交换密钥,之后用对称加密传数据(性能好)
- 证书验证失败 = 中间人攻击风险,不要忽略证书错误
- TLS 1.3 将握手从 2-RTT 降到 1-RTT(更快)
4.3 TCP 三次握手(为什么会慢)
客户端 →SYN→ 服务端
客户端 ←SYN+ACK← 服务端
客户端 →ACK→ 服务端
(1 个 RTT 后才能发数据)为什么重要:每次新连接都要付这个 RTT。 所以要用连接复用(HTTP/1.1 keep-alive、HTTP/2 多路复用)。
4.4 跨域(CORS)排查清单
CORS 是前端最常遇到的"网络问题",但它是浏览器的安全策略,不是网络故障。
排查顺序:
| 现象 | 原因 | 解法 |
|---|---|---|
报 No 'Access-Control-Allow-Origin' | 服务端没返回该头 | 服务端加上(前端改不了) |
| 带 Cookie 时报跨域 | 需要 credentials: 'include' + 服务端 Allow-Credentials: true | ⚠️ 此时 Allow-Origin 不能是 *,必须具体域名 |
发 PUT/DELETE 或自定义头时失败 | 触发预检(OPTIONS),服务端没处理 | 服务端要响应 OPTIONS 请求 |
| 本地开发跨域 | 开发服务器代理 | Vite 用 server.proxy(见下) |
// Vite 开发代理(仅开发环境有效,生产要 nginx 或服务端配)
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
}⚠️ 关键认知:CORS 只能由服务端解决。 前端的 proxy 只是开发时绕过,生产环境必须服务端配置。
五、性能排查:从现象到原因
5.1 核心指标(Core Web Vitals)
| 指标 | 衡量什么 | 常见原因 | 优化方向 |
|---|---|---|---|
| LCP(最大内容绘制) | 加载速度 | 大图、阻塞的 JS/CSS、慢接口 | 压缩图片、关键 CSS 内联、预加载关键资源 |
| INP(交互到下次绘制) | 响应流畅度 | 主线程长任务、大量 JS 执行 | 拆分长任务、减少 JS 体积 |
| CLS(累积布局偏移) | 视觉稳定性 | 图片/广告没有尺寸、动态插入内容 | 给图片设 width/height、预留空间 |
CLS 最容易被忽略——图片没设尺寸,加载完页面"跳一下",体验很差:
<!-- ❌ 会导致 CLS -->
<img src="a.jpg">
<!-- ✅ 预留空间,避免跳动 -->
<img src="a.jpg" width="800" height="600">
<!-- 或用 CSS aspect-ratio -->
<img src="a.jpg" style="aspect-ratio: 4/3">5.2 排查方法论(先测量,再优化)
1. 测量(找出瓶颈,不要猜)
├─ DevTools → Performance 面板(运行时性能)
├─ DevTools → Network 面板(加载性能:瀑布图)
└─ Lighthouse(综合评分 + 具体建议)
2. 定位瓶颈在哪一阶段
├─ 加载慢? → Network 瀑布图,看是哪个资源/接口
├─ 交互卡? → Performance 面板,看长任务(Long Task)
└─ 布局跳? → Performance 里的 Layout Shift 标记
3. 针对性优化
4. 再测量,验证是否真的变快⚠️ 最常见的错误:凭感觉优化。 这一步和 02 篇 的"先测量再优化"是同一个原则。
5.3 长任务(Long Task)
定义:主线程上执行超过 50ms 的任务(会阻塞响应)。
为什么是 50ms:
帧预算 16.67ms(60fps)
但用户能感知的"响应及时"阈值约 100ms
→ 留出余量,定义 50ms 为"长任务"拆分长任务:
// ❌ 一个大循环,阻塞主线程 500ms
for (let i = 0; i < 100000; i++) process(items[i]);
// ✅ 分片,每帧处理一部分(用 requestAnimationFrame 或 scheduler)
function processChunk(start) {
const end = Math.min(start + 1000, items.length);
for (let i = start; i < end; i++) process(items[i]);
if (end < items.length) {
requestAnimationFrame(() => processChunk(end)); // 让出主线程
}
}5.4 加载优化清单
| 手段 | 作用 |
|---|---|
preload | 提前加载当前页关键资源(字体、首屏图) |
prefetch | 提前加载下一页可能用的资源 |
async / defer | 不阻塞 HTML 解析 |
| 代码分割 | 减小首屏 JS 体积(见 02 篇) |
| 图片优化 | WebP/AVIF、srcset 响应式、懒加载 |
| CDN | 就近访问,减少 RTT |
<link rel="preload" href="/font.woff2" as="font" crossorigin>
<script src="app.js" defer></script> <!-- defer:HTML 解析完再执行 -->
<script src="analytics.js" async></script> <!-- async:下载完立即执行(顺序不定) -->六、自检清单
学完本文,你应该能够:
- [ ] 说出渲染流水线的五个阶段
- [ ] 判断改某个 CSS 属性会触发哪些阶段(重排/重绘/只合成)
- [ ] 解释为什么动画该用
transform/opacity - [ ] 说出什么是 Layout Thrashing,以及如何避免(批量读再批量写)
- [ ] 说明
will-change的代价,以及为什么不能滥用 - [ ] 说出微任务与宏任务的区别,并推导输出顺序
- [ ] 解释为什么"微任务里死循环会导致页面永远不渲染"
- [ ] 区分强缓存与协商缓存
- [ ] 说明
no-cache与no-store的区别(高频陷阱) - [ ] 解释"HTML 和 JS 都设长缓存"会导致什么事故
- [ ] 说出 HTTP/2 的核心改进,以及它如何让"合并小文件"失效
- [ ] 简述 TLS 握手流程,知道"非对称交换密钥 + 对称传数据"
- [ ] 按 CORS 排查清单定位跨域问题,并知道它只能服务端解决
- [ ] 说出三个 Core Web Vitals 指标及各自常见原因
- [ ] 说明为什么图片不设尺寸会导致 CLS
- [ ] 解释什么是长任务(50ms)以及如何拆分
- [ ] 用"测量 → 定位 → 优化 → 再测量"的方法排查性能问题
七、常见误区
| 误区 | 正解 |
|---|---|
"no-cache 就是不缓存" | ❌ 是"每次都协商";不缓存是 no-store |
"动画改 width/height 没问题" | ❌ 触发重排,最贵。用 transform |
"多设几个 will-change 更快" | ❌ 每个层占显存,滥用反而更慢 |
| "微任务和宏任务差不多" | ❌ 微任务每轮清空全部,优先级远高于宏任务 |
| "CORS 是前端问题" | ❌ 只能服务端解决,前端 proxy 仅开发时绕过 |
| "HTTP/2 下还要合并小文件" | ❌ 多路复用下合并可能有害(破坏缓存粒度) |
| "HTTPS 很慢" | ⚠️ 现代 TLS 1.3 握手开销很小;且 HTTP/2+ 基本都要 HTTPS |
| "缓存时间设越长越好" | ❌ 不带 hash 的资源设长缓存 = 用户看不到新版 |
"性能优化就是加 async/defer" | ❌ 先测量,瓶颈可能根本不在 JS |
| "APP 里也一样有 HTTP 缓存" | ❌ Flutter 没有浏览器那套自动缓存,要自己实现 |
八、动手练习
练习 A:输出顺序推导
不看答案,推导出下面代码的输出顺序并说明推理过程:
console.log('a');
setTimeout(() => console.log('b'), 0);
Promise.resolve()
.then(() => console.log('c'))
.then(() => console.log('d'));
requestAnimationFrame(() => console.log('e'));
console.log('f');思考:requestAnimationFrame 属于宏任务还是微任务?它在渲染前还是渲染后执行?
练习 B:用 DevTools 找一个真实瓶颈(推荐)
挑一个你熟悉的前端页面:
- 打开 DevTools → Performance,录制一次页面加载
- 找出耗时最长的 three 个阶段(Loading / Scripting / Rendering / Painting)
- 有没有出现长任务(灰色带红角的 Long Task)?在做什么?
- 打开 Network 瀑布图:哪个资源最慢?它的缓存头是什么?是否合理?
- 跑一次 Lighthouse,看 CLS 是否 > 0?原因是什么(多半是图片没尺寸)
输出:三行结论——瓶颈在哪、为什么、准备怎么改。
练习 C:缓存头审查
检查一个项目的静态资源配置:
- JS/CSS 文件名带 hash 吗?
- 带 hash 的是否设了长缓存(
max-age=31536000, immutable)? index.html用的是no-cache吗?- 有没有"不带 hash 却设了长缓存"的资源?(这是定时炸弹)
相关文档
本区
- frontend README —— 前端沉淀区索引
- 01 · 跨端方案横向对比 —— 热更新与产物下发
- 02 · 前端工程化 —— 构建优化与"先测量再优化"
- 03 · TypeScript 进阶与 Dart 对照 —— 类型系统对照
Flutter 区(双向对照)
- Flutter 03 篇 · 渲染原理与生命周期 —— 三棵树、rebuild/repaint/relayout(本篇 1.2 的对应物)
- Flutter 06 篇 · Dart 异步并发与进阶语法 —— 事件循环 / microtask / Isolate(本篇第二节的对应物)
- Flutter 22 篇 · 性能分析与优化实战 —— 帧预算与性能排查
- Flutter 29 篇 · 动画进阶与动效工程化 —— "动 Transform 不动布局属性"(本篇 1.2 的同源结论)
- Flutter 09 篇 · 数据层链路 —— Dio 拦截器(Flutter 里要自己实现缓存)