Skip to content

04 · 浏览器与网络 ​

本篇解决什么问题: 这是前端最不易过时的基本功。框架会变,但浏览器渲染流程、HTTP 缓存、TCP/TLS 这些 十年后依然有效。本篇聚焦"原理 + 排查思路",而不是背 API。

📌 与 flutter/ 区的呼应(本区核心写法):

前端Flutter 对应参考
浏览器渲染流水线Flutter 三棵树 + 渲染流水线Flutter 03 篇
重排 Reflow / 重绘 Repaintrelayout / repaintFlutter 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 树真正负责布局与绘制
重排 Reflowrelayout重新计算布局
重绘 Repaintrepaint重新绘制像素
合成 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) ​

这是前端最经典的性能杀手之一:

js
// ❌ 灾难:读写交替,每次读都强制浏览器重新布局
for (let i = 0; i < items.length; i++) {
  items[i].style.width = '100px';        // 写(让布局失效)
  const h = items[i].offsetHeight;       // 读(强制立即重新布局!)
  console.log(h);
}
// → 触发 N 次强制同步布局
js
// ✅ 正确:先批量读,再批量写
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 ​

css
/* 提前提示浏览器:这个元素要动,单独提一层 */
.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 经典输出顺序题 ​

js
console.log('1');                    // 同步

setTimeout(() => console.log('2'));  // 宏任务

Promise.resolve().then(() => {
  console.log('3');                  // 微任务
});

console.log('4');                    // 同步

// 输出:1 → 4 → 3 → 2

推理过程:

  1. 同步代码先跑:1、4
  2. 同步跑完 → 清空微任务队列 → 3
  3. 微任务清空 → 取下一个宏任务 → 2

2.3 为什么这很重要(不止是面试题) ​

场景影响
微任务里死循环会永久阻塞渲染(微任务不清空就不渲染)
想在 DOM 更新后做事Promise.then 在渲染前;要渲染后用 requestAnimationFrame
拆分长任务用 setTimeout(宏任务)让出主线程,给渲染机会
js
// ❌ 微任务里递归 → 页面卡死,永远不渲染
function loop() { Promise.resolve().then(loop); }

// ✅ 宏任务递归 → 每帧之间浏览器有机会渲染
function loop() { setTimeout(loop, 0); }

2.4 与 Flutter 的对照 ​

前端Flutter
模型单线程 + 事件循环单线程(Isolate)+ 事件循环
队列宏任务 / 微任务event queue / microtask queue(几乎同名)
卡住主线程页面卡死、掉帧UI 掉帧(jank)
解决方案Web WorkerIsolate(或 compute)

见 Flutter 06 篇。 心智模型完全一致——差异只在"要多开一个线程时用什么 API"。


三、HTTP 缓存:最容易配置错的地方 ​

3.1 两种缓存策略 ​

策略头部行为适用
强缓存Cache-Control: max-age=3600有效期内不发请求,直接用本地带 hash 的静态资源
协商缓存ETag / Last-Modified发请求问服务端,没变则返回 304HTML、可能变的资源

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(见下)
js
// 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 最容易被忽略——图片没设尺寸,加载完页面"跳一下",体验很差:

html
<!-- ❌ 会导致 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 为"长任务"

拆分长任务:

js
// ❌ 一个大循环,阻塞主线程 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
html
<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:输出顺序推导 ​

不看答案,推导出下面代码的输出顺序并说明推理过程:

js
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 找一个真实瓶颈(推荐) ​

挑一个你熟悉的前端页面:

  1. 打开 DevTools → Performance,录制一次页面加载
  2. 找出耗时最长的 three 个阶段(Loading / Scripting / Rendering / Painting)
  3. 有没有出现长任务(灰色带红角的 Long Task)?在做什么?
  4. 打开 Network 瀑布图:哪个资源最慢?它的缓存头是什么?是否合理?
  5. 跑一次 Lighthouse,看 CLS 是否 > 0?原因是什么(多半是图片没尺寸)

输出:三行结论——瓶颈在哪、为什么、准备怎么改。

练习 C:缓存头审查 ​

检查一个项目的静态资源配置:

  1. JS/CSS 文件名带 hash 吗?
  2. 带 hash 的是否设了长缓存(max-age=31536000, immutable)?
  3. index.html 用的是 no-cache 吗?
  4. 有没有"不带 hash 却设了长缓存"的资源?(这是定时炸弹)

相关文档 ​

本区 ​

Flutter 区(双向对照) ​