页面渲染像素原理
# 10.浏览器渲染像素之路
📍 上接第 09 篇《Worker 并发与调度时钟》。并发已了然。本文追问:JS 操作的 DOM 最终怎么变成屏幕像素?一行
element.offsetHeight为什么可能让当前帧多出 50ms?浏览器在每一帧的 16.67ms 预算里做了什么?
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. HTML到DOM
- 4. CSS到CSSOM
- 5. 渲染树合拢详解
- 6. 布局与重排详解
- 7. 绘制与合成详解
- 8. 事件系统模型
- 9. 事件委托与 Ob
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 一张卡到 8fp
先看一段在生产环境真实跑过的代码——一个实时更新的监控仪表盘,200 个指标卡片每秒刷新一次。上线第一天流畅,三天后用户投诉:"切换到这个页面,鼠标都拖不动"。
// dashboard.js —— 实时监控仪表盘(简化后的故障版本)
const cards = document.querySelectorAll('.metric-card');
function updateAllMetrics(data) {
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
const value = data[i]; // 新数据
// Step 1:读——取当前宽度,用来算新字体大小
const width = card.offsetWidth; // ← 第一次强制布局
// Step 2:写——根据宽度更新内容
card.textContent = value; // ← 改了 DOM → 布局脏了
// Step 3:读——取更新后的高度,用来调位置
const height = card.offsetHeight; // ← 第二次强制布局!
// Step 4:写——调位置
card.style.marginTop = (height > 80 ? 0 : 12) + 'px'; // ← 布局又脏了
// Step 5:读!——下一个循环第一步又是 offsetWidth
// → 每张卡片:读→强制布局→写→读→强制布局→写 = 2 次 Layout
}
}
// 每秒调用一次
setInterval(() => updateAllMetrics(fetchLatestData()), 1000);
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
现象:
- 200 张卡片 → 每帧 400 次 Layout → 每次 Layout ~0.3ms → 120ms/帧
- 16.67ms 帧预算 vs 120ms 实际耗时 = 帧率从 60fps 暴跌到 8fps
- Performance 面板火焰图:满屏紫色 "Forced Reflow" 标记
直觉怀疑:是不是 setInterval 太频繁?改成 2 秒一次——还是一样卡,因为瓶颈不在调用频率,在单次调用的 Layout 次数。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:是不是
offsetHeight本身太慢?——单独测一下:一次offsetHeight在 Layout 干净时 ~0.001ms(只是读缓存值),在 Layout 脏时 ~0.3ms(触发整个子树的重新计算)。慢的不是读操作,是读之前的强制布局。 - 假设 2:是不是 200 张卡片太多?——把卡片降到 10 张,依然有 20 次 Layout,帧时间从 120ms 降到 6ms——看起来正常了。但这是**"刚好够用"掩盖了设计缺陷**,再增加到 20 张就又崩了。
- 假设 3:为什么"读写分离"能解决?——把所有"读"(offsetWidth/offsetHeight)集中到第一个循环,把 DOM 改到一个"离屏副本"上 或者暂时 detach 父容器,最后一次性写回。读操作全部发生在"布局干净"时(读缓存)、写操作全部攒到最后一次 apply → 1 次 Layout 完成全部 200 张卡片的布局更新。
- 假设 4:那为什么不是浏览器自动做这个优化?——因为浏览器不能替你做"把读和写分开"的决策。浏览器不知道
offsetHeight之后的那行textContent和刚才读的值有没有因果关系——它只能假设有,然后在你下次读之前,老老实实把布局算好。
# 1.3 我们要回答什么
这段代码里至少藏着 7 个原理点:
① offsetHeight 为什么能触发 Layout?哪些属性有同样的效果? → 第 6 章
② 布局是怎么从"读了一个属性"变成"重新计算整棵树的"? → 第 6.2 节
③ 浏览器怎么分层渲染?GPU 层和 CPU 层的边界在哪? → 第 7 章
④ 为什么 transform 比 left 流畅 10 倍?是 GPU 更快吗? → 第 7.2 节
⑤ will-change 为什么不能滥用?每升一层花多少显存? → 第 7.3 节
⑥ passive 事件和布局抖动有什么关系?为什么滚动优化靠的是它? → 第 8.3 节
⑦ MutationObserver / IntersectionObserver 在帧的哪个阶段触发? → 第 9.2 节
2
3
4
5
6
7
本篇路线:
架构总图(第 2 章)
↓
HTML→DOM(第 3 章)──→ 解开"解析器怎么把字符串变成一棵树"
↓
CSS→CSSOM(第 4 章)──→ 解开"选择器为什么从右向左匹配更快"
↓
RenderTree(第 5 章)──→ 解开"哪些元素会被排除、伪元素怎么表示"
↓
Layout(第 6 章)──→ 解开"强制同步布局是怎么被触发的、怎么修"
↓
Paint+Composite(第 7 章)──→ 解开"GPU 层怎么帮你省掉 Layout 和 Paint"
↓
事件系统(第 8 章)──→ 解开"passive 是怎么让滚动不卡的"
↓
Observer 三剑客(第 9 章)──→ 解开"Mutation/Resize/Intersection 分别在什么时机触发"
↓
综合案例(第 10 章)──→ 案例彻底剖开 + 哲学四条 + 速查表
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
📌 本篇定位:这是浏览器专栏的渲染中枢篇。前 09 篇讲 JS 引擎/异步/Worker/内存——代码怎么跑;本篇讲 DOM/CSSOM/Layout/Paint/Composite——代码的"产出物"怎么变成人眼看到的像素。读完本篇后,用 Performance 面板再看任何页面,都能拆出每一帧每一微秒在干什么。
# 2. 架构全景概览
# 2.1 Critical
┌─────────────────────────────────────────────────────────────────────┐
│ Critical Rendering Path(关键渲染路径) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ① Parse HTML ② Parse CSS │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ HTML ─→ Tokenizer │ │ CSS ─→ Parser │ │
│ │ │ │ │ │ │ │ │ │
│ │ ▼ ▼ │ │ ▼ ▼ │ │
│ │ DOM Tree │ │ CSSOM Tree │ │
│ └──────────┬───────────┘ └──────────┬───────────┘ │
│ │ │ │
│ └──────────────┬───────────────────┘ │
│ ▼ │
│ ③ RenderTree(合并 & 排除不可见节点) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 对每个可见 DOM 节点 → attach CSSOM 计算样式 → 生成 │ │
│ │ RenderObject(LayoutObject)→ 链接成 RenderTree │ │
│ └──────────────────────────┬───────────────────────────┘ │
│ ▼ │
│ ④ Layout(计算几何位置) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ RenderTree → LayoutTree │ │
│ │ 每个节点:x, y, width, height, margin, padding, border│ │
│ │ 递归计算:子节点位置取决于父节点的盒模型约束 │ │
│ └──────────────────────────┬───────────────────────────┘ │
│ ▼ │
│ ⑤ Paint / Composite(生成绘制指令 → 交给 GPU 上屏) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ LayoutTree → PaintLayer → GraphicsLayer │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │背景填充 │ │文字绘制 │ │边框绘制 │ ... │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ → 生成 DisplayList(绘制指令序列) │ │
│ │ → Compositor 把各层合并成最终的帧 → 交给 GPU → 屏幕 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
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
每阶段耗时(典型):
| 阶段 | 输入 | 产出 | 典型耗时 | 是否阻塞首帧 |
|---|---|---|---|---|
| Parse HTML | HTML 字节流 | DOM Tree | 10-100ms | —(并行于 CSS 解析) |
| Parse CSS | CSS 文本 | CSSOM Tree | 5-20ms | 是——CSSOM 不全不能进 RenderTree |
| RenderTree | DOM + CSSOM | RenderObject 树 | 5-15ms | 是 |
| Layout | RenderTree | LayoutTree(含几何信息) | 5-50ms | 是 |
| Paint | LayoutTree | DisplayList(绘制指令) | 5-20ms | 是 |
| Composite | 各 GraphicsLayer | GPU 纹理 → 屏幕像素 | 1-5ms | 是(最后一步) |
# 2.2 为什么分五步而不
疑惑:为什么浏览器要把渲染拆成五步?不能像 Canvas 一样,看到一个元素立刻画到屏幕缓冲区里吗?
论证:
流式布局的需要——HTML 元素的大小不是孤立的。一个
<div>的宽度取决于它的父容器、屏幕宽度、CSS 中的百分比/calc/vw 单位——这些东西在解析完那一行之前是不知道的。如果你边解析边画,后面来的一个width: 100%会否定你刚才画的全部内容。布局必须在"所有参与者都到场"之后才能算。CSS 的级联特性——同一个元素的最终样式可能来自:行内 style、id 选择器、类选择器、标签选择器、继承、默认值。这六层样式按**优先级(specificity)**叠加,最终才得到一个元素的
computed style。如果在 CSS 还没解析完时就开始画——后续一条!important会让前面的所有绘制白做。分层渲染的硬件要求——浏览器的 Compositor 把不同元素分成不同的 GPU 层(比如视频在独立的层上,滚动的内容在另一层上)。分层的决策依赖 CSS 的
transform/opacity/will-change等属性——这些信息在 Layout 计算完之后才能确定。分层是一个"后置"决策——你没法在不知道谁会动之前就分好层。绘制指令需要 Layout 的结果——Paint 阶段生成"在 (100, 200) 处画一个宽 300 高 50 的蓝色圆角矩形"这样的指令。括号里的坐标全部来自 Layout——没有 Layout 的结果,Paint 不知道画在哪。
反向验证:如果浏览器真的"边解析边画",会出现什么?想象你打开一个网页,看到文字从左往右一条一条蹦出来、div 从一个位置跳到另一个位置——这就是 FOUC(Flash of Unstyled Content) 和 Layout Shift(CLS) 的本质。浏览器选择"先全部算好,再一次性画",是为了避免让用户看到中间的"半成品"页面。
结论:五阶段不是"为了把简单问题复杂化"——每一步都是一道信息依赖关卡。Parse HTML 不知道 CSS 的值,CSS 不知道 DOM 的结构,Layout 不知道 Paint 的绘制顺序。拆成五步,本质上是把"不同时间到达的信息"分配到"不同的处理阶段"——让每阶段的输入都完整、输出都确定。
# 3. HTML到DOM
# 3.1 HTML 解析器
疑惑:HTML 不是合法的 XML——为什么浏览器还能正确解析并显示 <p>Hello<li>World 这种"错误"写法?
论证:
HTML 解析器不是"要么成功、要么失败"的严格解析器。它是一个带容错树构建(tree construction)的状态机——遇到错误标签时,不报错,而是根据 HTML5 规范 §8.2 的"错误处理"规则静默修复。
HTML 解析器的两个核心阶段(HTML5 Spec §8.2):
① Tokenization(词法分析——字节流 → Token 序列)
"&lt;div class='main'&gt;" → [StartTag('div', {class:'main'})]
"Hello" → [Character('Hello')]
"&lt;/div&gt;" → [EndTag('div')]
② Tree Construction(树构建——Token → DOM 树的插入/修正)
Token 序列 → 状态的树构建器 → DOM 树节点
2
3
4
5
6
7
8
9
容错实例——浏览器怎么修复非法 HTML:
开发者写的 HTML: 浏览器构建的 DOM:
───────────────── ──────────────────
<p>Hello <p>Hello</p> ← 自动闭合 p
<li>item A <li>item A</li> ← 自动闭合 li
<li>item B <li>item B</li>
(浏览器给它们包了一个隐式 <ul>?不会——li 直接挂到父节点)
<table> <table>
<tr><td>A</td> <tbody> ← 自动插入 tbody!
</tr> <tr><td>A</td></tr>
</table> </tbody>
</table>
<select><div>hello</div></select> <select></select> ← div 不能在 select 里
<div>hello</div> ← 浏览器把它踢出 select
2
3
4
5
6
7
8
9
10
11
12
13
14
15
为什么不是 XML 解析器:XML 解析器是"遇错即停"——一个未闭合的标签直接抛 XML Parsing Error。HTML 选择容错的原因是:前端是"野生"的——99.9% 的你在网上看到的页面都包含"不合法但能显示"的 HTML。浏览器必须为这些页面兜底。
结论:HTML 解析器的容错不是"宽容"——它是 Web 平台存活了 30 年的根基。如果浏览器变成 XML 解析器,你每天上网看到的 100 个页面,可能有 80 个会变成白屏。
# 3.2 脚本阻塞解析的精
疑惑:<script>、<script async>、<script defer>、<script type="module"> 对 HTML 解析的阻塞行为为什么不同?
论证——这四种标签不仅在"是否阻塞"上不同,它们在下载时机、执行时机、执行顺序三个维度上都有差异:
┌──────────────────────────────────────────────────────────────────┐
│ 脚本加载策略对比(四个维度的精确行为) │
├──────────────────────────────────────────────────────────────────┤
│ │
│ <script>(无属性) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ HTML 解析 → 遇到 script → 停止解析 → 下载脚本 → 执行脚本 → 恢复解析│ │
│ │ ║ │ │
│ │ 阻塞开始 │ │
│ │ 后果:</body> 之前的 <script> 会卡住整个首屏 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ <script async> │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ HTML 解析 ───────────────────────────────→ 解析完成 │ │
│ │ ↘(并行下载脚本) 下载完成 → 立即执行(暂停解析) │ │
│ │ 后果:执行顺序不保证——谁先下载完谁先跑 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ <script defer> │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ HTML 解析 ───────────────────────────────→ 解析完成 │ │
│ │ ↘(并行下载脚本) ↓ │ │
│ │ 执行脚本(按文档顺序) │ │
│ │ 后果:一定在 </html> 之后、DOMContentLoaded 之前执行 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ <script type="module"> │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 默认行为 = defer(不阻塞解析 + 按文档顺序执行) │ │
│ │ + 可以 import / export │ │
│ │ + 自动 strict mode │ │
│ │ + 同一 URL 只执行一次 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
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
对比表:
| 属性 | 阻塞 HTML 解析 | 执行时机 | 执行顺序 | 可 import/export |
|---|---|---|---|---|
| 无(普通) | ✅ 立即停止解析 | 下载完立刻执行 | 按文档顺序 | ❌ |
| async | ❌(但执行时暂停解析) | 下载完立刻执行 | 谁先下载完谁先跑 | ❌ |
| defer | ❌ | HTML 解析完后、DOMContentLoaded 前 | 按文档顺序 | ❌ |
| type="module" | ❌(默认 defer) | 同 defer | 按文档顺序 | ✅ |
关键边界——CSSOM 与脚本的交叉阻塞:
<script> 执行前,浏览器检查:
① 这个 script 前面的所有 <link rel="stylesheet"> 是否已解析完 CSS?
└─ 没完 → 阻塞此 script 的执行(不是下载,是执行!)
因为 script 里可能读 CSSOM(getComputedStyle / element.style)
② 这个 script 前面的所有 <script defer> 是否已执行完?
└─ 没完 → 等(保证 defer 的执行顺序)
这就是为什么 <link> 放在 <script> 前面也可能阻塞脚本执行——
不是阻塞下载,是阻塞 CSSOM 就绪。
2
3
4
5
6
7
8
9
10
结论:选型口诀——首屏不依赖的脚本用 async(分析/广告等);首屏依赖但不必立刻执行的用 defer(框架/组件库);必须在 DOM 之前操作的用普通 <script>(极少数场景,如防 FOUC 的 CSS 注入)。
# 3.3 Document
疑惑:为什么往 DocumentFragment 里插 1000 个 div 不触发 reflow,但往 document.body 里插 1 个就触发?
论证:
DocumentFragment 的关键特性:它不在"已渲染的 DOM 树"中。
已渲染的 DOM 树(有 LayoutTree 绑定):
document
└─ html
└─ body ← 对这里的任何改动 → 触发 reflow
├─ div#header
└─ div#content
DocumentFragment(无 LayoutTree 绑定):
#document-fragment
└─ div ← 插 1000 个 div 到这里 → 不触发 reflow
└─ div
└─ ...(1000 个)
只有当 documentFragment.appendChild(frag) 时,
frag 里的所有节点一次性被接入已渲染的 DOM 树 → 触发 1 次 reflow。
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 每次 appendChild 都触发一次 Layout
const list = document.getElementById('list');
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
list.appendChild(li); // ← 1000 次 reflow!每次插入 → 浏览器更新 LayoutTree
}
// ✅ DocumentFragment——1000 次插入都在离屏容器中,0 次 reflow
const frag = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
frag.appendChild(li); // ← 不触发 reflow——frag 不在渲染树中
}
list.appendChild(frag); // ← 只触发 1 次 reflow
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
更优方案——display:none 大法:
// 把已存在的容器暂时"移出"渲染树 → 随便改 → 再放回去
list.style.display = 'none'; // 从渲染树移除(不占 Layout 框)
for (let i = 0; i < 1000; i++) {
list.appendChild(createItem(i)); // 1000 次 DOM 操作——0 次 reflow
}
list.style.display = ''; // 重回渲染树 → 1 次 reflow
2
3
4
5
6
结论:DocumentFragment 和 display:none 都是在操作**"没有被 LayoutTree 追踪的 DOM 节点"**——浏览器不会为这些节点分配 Layout 框,自然不需要在每次修改后重新计算布局。离屏操作的本质是"在浏览器看不到的地方干活,干完了一次性告诉它"。
# 4. CSS到CSSOM
# 4.1 CSSOM 的构
疑惑:为什么 CSS 阻塞渲染,但不阻塞 HTML 解析?
论证:
解析时间轴(HTML 和 CSS 并行):
HTML 解析: ████████████████████████████████████████
CSS 解析: ████████████████████ ← 独立线程,不阻塞 HTML 解析
RenderTree 构建: ╔═══ ← 必须等 CSSOM 和 DOM 都就绪!
║
首次 Layout: ╚══════
首次 Paint: ╚══════
关键:CSS 解析不阻塞 DOM 构建,但阻塞 RenderTree 构建。
因为 RenderTree = DOM + CSSOM 的合并——缺一不可。
2
3
4
5
6
7
8
9
10
11
12
CSSOM 的构建过程(CSS Object Model):
┌────────────────────────────────────────────────────┐
│ CSS 文本 │
│ ".box { color: red; font-size: 14px; }" │
│ ".box .item { margin: 0; }" │
│ │
│ → Tokenizer(词法分析:选出 .box / { / color / : / red ...)│
│ → Parser(语法分析:构建选择器-属性-值的规则表) │
│ │
│ CSSOM(JavaScript 可以访问的结构): │
│ stylesheet │
│ └─ rule: ".box" │
│ ├─ declaration: color → red │
│ └─ declaration: font-size → 14px │
│ └─ rule: ".box .item" │
│ └─ declaration: margin → 0 │
│ │
│ JS 访问:document.styleSheets[0].cssRules[0].style.color → "red" │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CSSOM 为什么必须是阻塞的:因为后续的 RenderTree 构建依赖它。如果允许"CSS 还没解析完就开始画",用户会先看到没有样式的裸 HTML(就是 FOUC)。浏览器选择"宁可晚 20ms 显示,也不让用户看到半成品"。
# 4.2 从右向左匹配
疑惑:一个复杂选择器如 #main .list .item span,为什么浏览器从最右边的 span 开始匹配?
论证——这是一个"搜索空间剪枝"问题:
假设 DOM 树有 10000 个节点:
选择器:#main .list .item span
【从左向右匹配——灾难】
1) 找到 #main(1 个节点)
2) 在 #main 的子孙中找 .list → 遍历 #main 下全部子节点(假设 500 个)
3) 在 .list 的子孙中找 .item → 遍历 .list 下全部子节点(假设 200 个)
4) 在 .item 的子孙中找 span → 遍历 .item 下全部子节点(假设 50 个)
总共遍历:1 + 500 + 200 + 50 = ~751 次,且第 2 步起每步都是全量遍历子级
【从右向左匹配——高效】
1) 找到所有 span(10000 个节点中可能有 500 个 span)→ 500 个候选
2) 筛:哪些 span 的父级链上有 .item ?→ 对每个 span,向上查父子链(最多 20 层)
500 个 span × 平均 5 层 = 2500 次比对 → 筛掉 450 个 → 剩 50 个
3) 继续筛:这 50 个的父级链上有 .list ?→ 50 × 5 = 250 次 → 剩 10 个
4) 继续筛:这 10 个的父级链上有 #main ?→ 10 × 5 = 50 次 → 剩 10 个
总共比对:约 2800 次,每步都在"迅速淘汰大量不匹配的候选"
关键区别:从右向左的每一步都在缩小候选集,
从左向右的每一步都在遍历大量无关的子节点。
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
代码验证:
/* 从右向左的威力——浏览器可以快速决定"这个 span 不用匹配" */
#sidebar .widget li a span.icon { color: blue; }
/* 浏览器看到页面上任意一个 span 时:
1) 这个 span 有 .icon 吗? → 没有 → 直接跳过(1 次类名比对)
不需要去检查它的祖先有没有 #sidebar → 省掉了向上遍历的开销 */
2
3
4
5
6
为什么最右边是关键(key selector):
最右边的简单选择器叫 key selector——浏览器用它在 DOM 中做第一轮海选。如果最右边的选择器匹配的节点很少(如
.icon只出现在 10 个 span 上),整个选择器的匹配就极快。如果最右边是*(通配符),那第一轮就命中了 10000 个节点——灾难。
结论:"从右向左"不是浏览器的偏好——是它唯一可行的匹配策略。如果从左向右匹配,一个 5 级选择器在 10000 节点的 DOM 上可能需要遍历上百万次。从右向左把这个数字压到几千次。
# 4.3 CSS 阻塞渲染
┌─────────────────────────────────────────────────────┐
│ CSS 阻塞渲染的精确规则 │
├─────────────────────────────────────────────────────┤
│ │
│ ✅ 阻塞 RenderTree(必须等全完): │
│ <link rel="stylesheet" href="main.css"> │
│ <style> ... </style>(内联样式) │
│ — 浏览器必须等 CSSOM 构建完才能进入 RenderTree 阶段 │
│ │
│ ⚠️ 延迟阻塞(更坏——链式等待): │
│ <style> @import url('other.css'); </style> │
│ — 浏览器发现 @import → 发起新的 CSS 请求 │
│ — 这个新请求会阻塞当前的 CSSOM 构建 │
│ — 现在的 CSSOM 构建又阻塞 RenderTree │
│ — 结果:多一次网络往返 + 整个流程被串行化 │
│ │
│ ❌ 不阻塞(立即恢复解析的例外): │
│ <link rel="stylesheet" media="print"> │
│ — 浏览器知道这个只在打印时用 → 不阻塞屏幕渲染 │
│ <link rel="stylesheet" media="(max-width: 600px)">│
│ — 浏览器会下载它,但在屏幕宽度 > 600px 时不被阻塞 │
│ │
└─────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@import 的性能灾难图解:
<link href="style.css"> ← 浏览器看到这一行,开始下载 style.css
│
style.css 内容: ▼
@import url('theme.css'); ← 下载完 style.css 后才发现还要下载 theme.css
body { ... } ← 必须等 theme.css 下载并解析完才能处理 body 规则!
时间轴:
<link> 下载 ──┐
├─ @import 下载 ──┐
│ ├─ 解析全部 CSS ─→ CSSOM 就绪 ─→ RenderTree
└─ 解析 body ────┘(被迫等!)
对比 <link> × 2:
<link href="theme.css"> 下载 ──┐
<link href="style.css"> 下载 ──┤ (两个并行下载!)
├─ 解析全部 CSS ─→ CSSOM 就绪 ─→ RenderTree
结论:@import 把两个可以并行的请求变成了串行——多一次 RTT。
永远用 <link> 代替 @import。
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 5. 渲染树合拢详解
# 5.1 不可见元素的排除
疑惑:display:none 和 visibility:hidden 都是"看不见",为什么一个完全不在 RenderTree 中,另一个却占位?
论证:
┌────────────────────────────────────────────────────────────┐
│ 三种"不可见"在渲染管线中的不同表现 │
├────────────────────────────────────────────────────────────┤
│ │
│ ① opacity: 0 │
│ ┌──────────────────────────────────────┐ │
│ │ DOM 中:✅ 存在 │ │
│ │ RenderTree:✅ 有 RenderObject │ ← 占 Layout 框 │
│ │ Layout:✅ 计算盒模型(正常占用空间) │ ← 有几何位置 │
│ │ Paint:✅ 生成绘制指令 │ ← 正常绘制 │
│ │ Composite:alpha = 0 → GPU 做透明处理 │ ← 只在最后一步"透明" │
│ │ 代价:Layout + Paint + Composite 全部执行│ │
│ └──────────────────────────────────────┘ │
│ │
│ ② visibility: hidden │
│ ┌──────────────────────────────────────┐ │
│ │ DOM 中:✅ 存在 │ │
│ │ RenderTree:✅ 有 RenderObject │ ← 占 Layout 框 │
│ │ Layout:✅ 计算盒模型(保持占位) │ ← 有几何位置 │
│ │ Paint:❌ 不生成绘制指令 │ ← 省掉了 Paint │
│ │ 代价:Layout 照做,但省了 Paint(比 opacity:0 轻)│ │
│ └──────────────────────────────────────┘ │
│ │
│ ③ display: none │
│ ┌──────────────────────────────────────┐ │
│ │ DOM 中:✅ 存在 │ │
│ │ RenderTree:❌ 没有 RenderObject │ ← 完全不参与 │
│ │ Layout:❌ 不占空间 │ ← 0 开销! │
│ │ Paint:❌ 不存在于 DisplayList │ │
│ │ 代价:0(所有渲染阶段都跳过) │ │
│ └──────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
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
除 display:none 外还有哪些被排除的元素:
| 元素 | 为什么不在 RenderTree 中 |
|---|---|
<head> 及其所有子元素(<meta>/<title>/<link>/<style>) | 这是元数据——不参与视觉渲染 |
<script>(包括内联/外联) | 脚本——不参与视觉渲染 |
display:none 的元素及其所有子孙 | 完全从渲染树移除——祖先是 none → 所有后代都不渲染 |
<template> 的内容 | 文档模板——默认不渲染 |
用 ARIA hidden 的元素 | 辅助树不可见(视觉上如果没 display:none 仍然会渲染) |
结论:三种"不可见"对应三种不同的设计意图。display:none = "这个元素不存在于视觉世界"(渲染 0 开销);visibility:hidden = "这个位置空出来,但内容先不画"(保留布局占位);opacity:0 = "画出来但透明"(完整渲染开销)。选择哪一种,取决于你想要"省渲染开销"还是"保持布局占位"。
# 5.2 伪元素在 Ren
疑惑:::before / ::after 不存在于 DOM 树中——那它们的样式是怎么被计算并渲染的?
论证:
DOM 树: RenderTree:
<div class="quote"> LayoutBlock (div.quote)
"Hello" ├─ LayoutInline (匿名——文字 "Hello")
</div> ├─ LayoutInline (匿名——::before 的 content)
│ 这个节点不存在于 DOM 中!
│ 浏览器在计算样式时为它创建了
│ 一个"匿名 RenderObject"
└─ LayoutInline (::after,同理)
伪元素也被称为"生成内容(generated content)"——
它们的 RenderObject 是在计算 div.quote 的样式时被"凭空创建"的。
2
3
4
5
6
7
8
9
10
11
关键约束:
- 伪元素必须有
content属性才能被渲染(即使是content: ''也需要显式声明——否则没有 RenderObject) - 伪元素的样式完全继承自它的"宿主元素"(
div.quote)——不能独立设置 - 伪元素不能嵌套——
::before::before不存在
# 5.3 RenderOb
在 Blink(Chrome 渲染引擎)中,RenderObject 有三个核心子类,对应不同的布局行为:
RenderObject(抽象基类——所有"需要布局的东西"的根)
│
├─ LayoutBlock(块级元素:div, p, section, h1~h6, ul, li...)
│ └─ BFC 容器、占一整行、子元素从上往下排
│
├─ LayoutInline(行内元素:span, a, em, strong, ::before 的文字内容)
│ └─ 不占整行、在同一行内从左往右排、可能换行
│
└─ LayoutText(纯文本节点:两个标签之间的文字)
└─ 没有子元素、只有文字内容
示例:
<div> → LayoutBlock
<span>text</span> → LayoutInline
裸文字 → LayoutText
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这三种类型的组合构成了整个渲染世界的"几何骨架"——Layout 阶段就是在这棵 LayoutObject 树上遍历并计算每个节点的盒模型。
# 6. 布局与重排详解
# 6.1 包含块 + BF
疑惑:浏览器是怎么确定每个元素"应该在屏幕上的哪个位置"的?
论证——布局算法从根节点(viewport)开始,沿着 RenderTree 递归计算:
┌─────────────────── 布局的四个计算维度 ───────────────────┐
│ │
│ ① 包含块(Containing Block) │
│ 每个元素的位置是"相对于哪个祖先"计算的 │
│ position: static/relative → 最近的块级祖先 │
│ position: absolute → 最近的非 static 祖先 │
│ position: fixed → viewport │
│ │
│ ② 格式化上下文(Formatting Context) │
│ BFC(Block Formatting Context): │
│ div/p/section 的世界——从上往下排、每个占一整行 │
│ 触发 BFC 的条件之一:overflow ≠ visible │
│ IFC(Inline Formatting Context): │
│ span/a/em 的世界——在同一行内从左往右排、可能换行 │
│ │
│ ③ 盒模型(Box Model) │
│ 每个元素 = content + padding + border + margin │
│ box-sizing: content-box → width = content width │
│ box-sizing: border-box → width = content + padding + border│
│ │
│ ④ 正常流(Normal Flow) │
│ 块级元素从上往下、行内元素从左往右——这就是"正常流" │
│ float / position:absolute → 脱离正常流 → 不影响后续元素 │
│ │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
BFC 的隔离效应——为什么 overflow: hidden 能清除浮动:
<div style="border: 1px solid;">
<div style="float: left; width: 100px;">浮动元素</div>
</div>
<!-- 问题:父 div 的高度不会包含浮动子元素(因为 float 脱离正常流) -->
<!-- 父 div 的 border 只包住了一行文字高度,浮动元素"漏"到外面了 -->
<!-- 修复:给父 div 加上 overflow: hidden(触发 BFC) -->
<div style="border: 1px solid; overflow: hidden;">
<div style="float: left; width: 100px;">浮动元素</div>
</div>
<!-- 原因:BFC 容器必须包含它的所有浮动子元素——这是 BFC 的规则之一 -->
2
3
4
5
6
7
8
9
10
11
12
# 6.2 布局脏位(Dir
疑惑:你改了 el.style.width = '200px'——浏览器怎么知道哪些元素需要重新布局?
论证——每个 RenderObject 上挂着一个 "脏位"(dirty bit):
┌──────────────────────────────────────────────────────┐
│ 布局脏位的传播机制 │
├──────────────────────────────────────────────────────┤
│ │
│ 你改了 div#A 的 width(JS: el.style.width = '200px') │
│ │ │
│ ▼ │
│ div#A 被标记为"脏"(needs_layout = true) │
│ │ │
│ ▼ │
│ 脏位向上传播到父级、再父级、直到根节点—— │
│ 因为子元素的宽度变化可能导致父容器的高度变化 │
│ │
│ ▼ │
│ 脏位向下传播——div#A 的所有子元素也需要重算 │
│ 因为父容器变宽了 → 子元素可用空间变了 │
│ │
│ 结果:从根到叶子,一整条链都被标记为脏 │
│ │
│ 浏览器不会立刻布局——它等到"需要输出结果的时候" │
│ (下一帧、或 JS 代码读取了 offsetHeight 等布局属性) │
│ │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
触发 Layout dirty 的操作:
| 操作类型 | 示例 | 范围 |
|---|---|---|
| 修改盒模型相关 CSS | width/height/margin/padding/border/display | 该元素 + 祖先 + 所有子孙 |
| 修改位置相关 CSS | position/top/left/float | 同上 |
| 修改内容 | el.textContent = '...' / el.innerHTML = '...' | 同上 |
| 修改字体 | font-size/font-family | 同上 |
修改 offset/scroll/client 等布局属性 | el.offsetHeight / el.scrollTop | 只读——但如果之前有写操作且布局脏 → 先强制 Layout 再读 |
getComputedStyle / getBoundingClientRect | 同样触发强制同步布局 | 同上 |
不触发 Layout dirty 的操作:
// ✅ 只触发 Composite(不脏 Layout)
el.style.transform = 'translateX(10px)';
el.style.opacity = '0.5';
// ✅ 只触发 Paint(不脏 Layout)
el.style.color = 'red';
el.style.backgroundColor = 'blue';
2
3
4
5
6
7
# 6.3 强制同步布局
疑惑:为什么读 offsetHeight 有时快(0.001ms)、有时慢(5ms)?
论证——快慢取决于"在读到它之前,你有没有写过会脏布局的属性":
场景 A:布局干净——读缓存(极快,~0.001ms)
────────────────────────────────────────────
el.style.color = 'red'; // color 不脏 Layout
console.log(el.offsetHeight); // 读缓存——之前没有脏 Layout → 直接返回 ~0.001ms
场景 B:布局脏了——强制同步布局(慢,~5ms)
────────────────────────────────────────────
el.style.width = '200px'; // width 脏了 Layout!
console.log(el.offsetHeight); // 读→浏览器发现 Layout 脏→被迫现在重新计算→~5ms
这就是"Forced Synchronous Layout"——"同步"的意思是:
在 JS 代码还没执行完的时候(在同步代码块内),
浏览器被迫插入一次"完整的 Layout 重新计算"。
本来 Layout 应该发生在帧结束前的"Layout 阶段"(第 07 章的 rAF 对齐),
现在被提前到了 JS 执行中间——这就是为什么它会"偷走帧预算"。
2
3
4
5
6
7
8
9
10
11
12
13
14
15
触发 FSL 的属性完整清单(常用):
| 读操作 | 写操作(之前写过会强制 Layout) |
|---|---|
el.offsetWidth / Height / Top / Left / Parent | el.style.width / height / margin / padding / border / display / position |
el.clientWidth / Height / Top / Left | el.textContent / el.innerHTML |
el.scrollWidth / Height / Top / Left | el.style.top / left / bottom / right |
window.getComputedStyle(el) | el.classList.add/remove(可能改变 width/height) |
el.getBoundingClientRect() | el.appendChild / el.removeChild |
el.focus()(可能触发滚动) | el.style.font-size |
所有会引起回流的操作表:https://gist.github.com/paulirish/5d52fb081b3570c81e3a
Chrome DevTools 精确定位 FSL:
Performance 面板 → 录制 → 在火焰图中查找标红的 "Forced Reflow" 标记:
┌────────────────────────────────────────────────┐
│ ═══ Function Call ═══════════════════ 2.3ms │ ← JS 执行
│ ├─ updateDashboard() │
│ ├─ 🔴 Forced Reflow ───────── 4.8ms 🔴 │ ← 强制同步布局!
│ ├─ card.offsetHeight │ ← 触发原因(读了布局属性)
│ └─ card.style.marginTop = ... │ ← 之前的写操作脏了布局
└────────────────────────────────────────────────┘
点击 Forced Reflow → Sources 面板定位到触发代码行。
2
3
4
5
6
7
8
9
10
11
修复矩阵——按场景选方案:
| 场景 | 问题 | 修复 |
|---|---|---|
| 循环内交替读写 | 每次循环触发 Force Layout × N | 读写分离:先批量读(数组收集)→ 再批量写(一个循环) |
| 一次性大量 DOM 写入 | 每次 appendChild 触发 1 次 Layout | DocumentFragment 或 display:none 离线操作 |
| 动画需要频繁读写 | 60fps 动画中读 offsetHeight → 每帧触发 FSL | 把读操作移到 requestAnimationFrame 开头,写操作放在末尾 |
| 第三方库触发的 FSL | jQuery $el.height() 等封装了读写 | Chrome → Rendering 面板 → 勾选 "Paint Flashing" 和 "Layout Shift Regions" 观察 |
| 每次 render 都读 DOM | React/Vue 的 render 函数中调 getBoundingClientRect | 用 ResizeObserver / IntersectionObserver 代替手动读布局属性 |
# 7. 绘制与合成详解
# 7.1 PaintLay
疑惑:浏览器什么时候把一个元素提升为独立的 GPU 合成层(GraphicsLayer)?每一层花多少显存?
论证——分层不是一步到位的,而是两次决策:
┌─────────────────────────────────────────────────────────────┐
│ 分层决策的两步升级路径 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 第一步:RenderObject → PaintLayer(软件分层) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 大多数 RenderObject 共享父级的 PaintLayer。 │ │
│ │ 满足以下条件之一 → 创建独立的 PaintLayer: │ │
│ │ ✓ 根元素(document) │ │
│ │ ✓ position ≠ static(且 z-index ≠ auto) │ │
│ │ ✓ CSS opacity < 1 │ │
│ │ ✓ CSS filter ≠ none │ │
│ │ ✓ overflow: scroll / auto │ │
│ │ ✓ 有 3D transform 或 perspective(z 轴参与) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 第二步:PaintLayer → GraphicsLayer(GPU 层,合成器接管) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 不是所有 PaintLayer 都能升 GPU 层。 │ │
│ │ 满足以下条件之一 → 创建独立的 GraphicsLayer(GPU 显存中): │ │
│ │ ✓ 3D transform(translateZ ≠ 0 / rotateX/Y / scale3d) │ │
│ │ ✓ <video> / <canvas> / <iframe> 元素 │ │
│ │ ✓ CSS 动画/过渡作用于 transform 或 opacity │ │
│ │ ✓ will-change: transform / opacity / filter │ │
│ │ ✓ position: fixed(某些条件下) │ │
│ │ ✓ z-index 在重叠时需要独立合成(浏览器自动决定) │ │
│ │ │ │
│ │ 一个 GraphicsLayer 的内存开销 = │ │
│ │ 元素显示尺寸 × 4(RGBA 每像素 4 字节)× 额外的 mipmap │ │
│ │ 一个 300×200 的元素 = 300×200×4 ≈ 240KB │ │
│ │ + GPU 纹理的额外开销(对齐到 2^n 尺寸、mipmap 层级) │ │
│ │ → 实际 ~300-500KB/层 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
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
Chrome DevTools 查看分层——Layers 面板:
Chrome DevTools → 右上角三个点 → More tools → Layers
你会看到每一层的:
- 序号(#1, #2, ...)
- 尺寸(宽×高)
- 内存占用(~240KB 起步)
- 为什么被提升(Compositing Reasons,如 "has 3D transform")
- 层与层之间的重叠关系(3D 视图可旋转)
2
3
4
5
6
7
8
# 7.2 仅触发 Comp
疑惑:el.style.left = x + 'px' 和 el.style.transform = 'translateX(' + x + 'px)' ——都是把元素从 A 移到 B,为什么后者流畅那么多?
论证——两种移动方式在渲染管线中走的是完全不同的路径:
┌────────────────────────────────────────────────────────────┐
│ left 动画 vs transform 动画——每一帧的完整路径 │
├────────────────────────────────────────────────────────────┤
│ │
│ left 动画(Layout 路径——最贵): │
│ ┌──────────────────────────────────────────────────┐ │
│ │ JS: el.style.left = newX + 'px' │ │
│ │ ↓ │ │
│ │ Style Recalc: 重新计算样式(~2ms) │ │
│ │ ↓ │ │
│ │ Layout 🔴: 位置变了 → 整个子树重新计算盒模型(~20ms) │ │
│ │ 可能影响兄弟节点 → 祖先也要重算 → 连锁反应 │ │
│ │ ↓ │ │
│ │ Paint: 重新生成绘制指令(~5ms) │ │
│ │ ↓ │ │
│ │ Composite: GPU 合成(~2ms) │ │
│ │ │ │
│ │ 总计:~29ms/帧 → 34fps 而非 60fps │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ transform 动画(Composite-only 路径——最快): │
│ ┌──────────────────────────────────────────────────┐ │
│ │ JS: el.style.transform = 'translateX('+newX+'px)' │ │
│ │ ↓ │ │
│ │ Composite 🟢: GPU 直接移动 Layer 的矩阵坐标(~1ms) │ │
│ │ GPU 在做矩阵乘法——只是把之前画好的像素挪个位置 │ │
│ │ ↓ │ │
│ │ 屏幕刷新:新位置已生效 │ │
│ │ │ │
│ │ 总计:~1ms/帧 → 60fps ✅ │ │
│ │ 不需要 Style Recalc、不需要 Layout、不需要 Paint! │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────┘
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
Composite-only 属性完整清单:
| 属性 | 触发阶段 | 说明 |
|---|---|---|
transform | 仅 Composite | GPU 层矩阵变换——平移/旋转/缩放/倾斜 |
opacity | 仅 Composite | GPU 层 alpha 通道——透明度变化 |
filter | 仅 Composite | 如 blur(5px) / brightness(1.5),GPU 直接在层上做后处理 |
backdrop-filter | 仅 Composite | 对层"背后"的内容做滤镜——同样 GPU 后处理 |
为什么 GPU 做矩阵乘法这么快:
CPU 做一次
translate(x, y):读取屏幕缓冲区中每一个像素的 RGB 值,加上 x/y 偏移,写回新建的缓冲区。一个 1920×1080 的缓冲区有 2,073,600 个像素 × 4 字节 = 8.3MB。CPU 串行处理每一行 → 可能需要几十 ms。GPU 做同样的操作:把"整个帧缓冲区"作为一个纹理(texture),把
translate(x, y)作为一个矩阵变换。GPU 的着色器对每个像素并行执行这个矩阵乘法——200 万个像素同时算,耗时 = 一次 GPU 绘制调用的开销(~1ms)。GPU 的优势不是"比 CPU 快一点"——而是在并行处理上"快一万倍"。一个 2MB 像素的平移在 CPU 上是 O(n) 的,在 GPU 上是 O(1) 的。
# 7.3 will-cha
疑惑:will-change: transform 不是"提前提升为 GPU 层"吗?为什么不能给所有元素都加上?
论证——每一个 GPU 层都是有代价的:
一个 600×400 的元素设置 will-change: transform:
基础纹理:600 × 400 × 4 bytes(RGBA) = 960 KB
+ GPU 内存对齐(向上取整到 2^n) ≈ 1024 KB
+ mipmap 层级(GPU 优化纹理远距离采样的分级) ≈ 340 KB
+ GPU 纹理描述符 + 变换矩阵 ≈ 1 KB
─────────────────────────────────────────────────
一个层的实际显存占用 ≈ 1.4 MB
如果你给 100 个元素都加了:100 × 1.4 MB = 140 MB
如果你给所有元素都加了(* { will-change: transform; }):
一个中等页面 500 个元素 → 500 × 1.4 MB = 700 MB
→ GPU 显存被撑爆 → Compositor 被迫频繁"层回退"
→ 原本的"加速"变成了"频繁创建/销毁 GPU 层"的额外开销
→ 比不加还慢!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
正确使用 will-change 的三种模式:
/* 模式 1:交互前提前设置,交互后移除(推荐) */
.element { will-change: auto; } /* 默认不升层 */
.element:hover { will-change: transform; } /* hover 时升层——变流畅 */
/* 模式 2:在 JS 中在动画前设置,动画后移除 */
el.style.willChange = 'transform'; // 动画前:提前升层
el.addEventListener('transitionend', () => {
el.style.willChange = 'auto'; // 动画后:降层、释放显存
});
/* 模式 3:极少数"这个元素一直在动"的场景(如无限滚动) */
.scroll-container { will-change: transform; } /* 允许永久升层 */
2
3
4
5
6
7
8
9
10
11
12
结论:will-change 是一笔"显存预付款"——你提前告诉浏览器"我马上要用 GPU 层",浏览器提前分配了显存。如果滥用(全局用),就是"给所有元素付了预付款,但只有 2 个元素真的会动"——多余的显存被锁死,其他真正需要 GPU 加速的元素反而没空间了。
# 8. 事件系统模型
# 8.1 捕获 → 目标
┌────────────────────────────────────────────────────┐
│ 用户点击 <button> 的事件传播全过程 │
├────────────────────────────────────────────────────┤
│ │
│ ① 浏览器从 OS 拿到 (click, x=100, y=200) │
│ → Hit Test(命中测试):x=100,y=200 落在哪个元素上? │
│ → 结果是 <button> │
│ │
│ ② 构建事件传播路径(Event Path): │
│ window → document → <html> → <body> │
│ → <div#container> → <button> │
│ │
│ ③ 捕获阶段(Capture Phase)——从 window 向下到目标: │
│ ┌─────────────────────────────────────────────┐ │
│ │ 遍历 Event Path,对每个节点: │ │
│ │ 执行该节点上 { capture: true } 的监听器 │ │
│ │ e.stopPropagation() → 停止继续向下 │ │
│ │ │ │
│ │ 触发顺序: │ │
│ │ 1. window (capture → 有就执行) │ │
│ │ 2. document (capture) │ │
│ │ 3. <html> (capture) │ │
│ │ 4. <body> (capture) │ │
│ │ 5. div#container (capture) ← 最后一站 │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ④ 目标阶段(Target Phase): │
│ ┌─────────────────────────────────────────────┐ │
│ │ 在目标元素 <button> 上: │ │
│ │ 执行所有注册的监听器(不区分 capture/bubble) │ │
│ │ 按注册顺序执行 │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ⑤ 冒泡阶段(Bubble Phase)——从目标向上到 window: │
│ ┌─────────────────────────────────────────────┐ │
│ │ 反向遍历 Event Path,对每个节点: │ │
│ │ 执行该节点上 { capture: false } 的监听器(默认) │ │
│ │ e.stopPropagation() → 停止继续向上 │ │
│ │ │ │
│ │ 触发顺序: │ │
│ │ 1. <button> (bubble) │ │
│ │ 2. div#container (bubble) │ │
│ │ 3. <body> (bubble) │ │
│ │ 4. <html> (bubble) │ │
│ │ 5. document (bubble) │ │
│ │ 6. window (bubble) │ │
│ └─────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────┘
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
# 8.2 addEvent
| 选项 | 类型 | 作用 | 何时触发 | 典型用法 |
|---|---|---|---|---|
capture | boolean | true=捕获阶段触发,默认 false=冒泡阶段 | 取决于值 | 父容器要在子元素收到事件前拦截 |
once | boolean | 触发一次后自动 removeEventListener | 下次注册时 | 一次性初始化逻辑、单次确认对话框 |
passive | boolean | 承诺回调内不调用 preventDefault()——浏览器不必等 JS 执行完就能执行默认行为(滚动) | 滚动/触摸事件 | 所有 touchstart/wheel 监听器都应该设 passive |
signal | AbortSignal | 传入 AbortController.signal——controller.abort() 一键移除 | abort 时 | 批量解除事件监听:controller.abort() 移除全部关联的监听器 |
signal 的实战用法:
const controller = new AbortController();
const { signal } = controller;
el.addEventListener('click', handler1, { signal });
el.addEventListener('mousemove', handler2, { signal });
el.addEventListener('keydown', handler3, { signal });
// 一键全部移除——不需要记住每个 handler 的引用
controller.abort();
// 三个监听器全部被移除!
2
3
4
5
6
7
8
9
10
# 8.3 passive
疑惑:{ passive: true } 和滚动性能有什么关系?怎么就"快"了?
论证——这不是 JS 执行速度的问题,是**浏览器能不能"不等 JS 执行完就滚动"**的问题:
┌──────────────────────────────────────────────────────────┐
│ 没有 passive(passive=false,旧默认) │
├──────────────────────────────────────────────────────────┤
│ │
│ 用户手指触摸屏幕,开始滑动 │
│ │ │
│ ▼ │
│ 浏览器触发 touchstart 事件 │
│ │ │
│ ├── 事件被派发给 JS 主线程 │
│ │ ├── 所有 touchstart 监听器排队执行 │
│ │ └── 浏览器在等——因为 JS 可能调用 preventDefault() │
│ │ preventDefault → 阻止滚动 │
│ │ │
│ ├── JS 执行完毕 → 没有调用 preventDefault │
│ │ │
│ └── 浏览器开始滚动 │
│ │
│ 延迟 = JS 执行时间(可能 50-200ms) │
│ 用户感觉:手指动了,屏幕没反应 → "卡" │
│ │
├──────────────────────────────────────────────────────────┤
│ 有 passive(passive=true,Chrome 56+ 默认) │
├──────────────────────────────────────────────────────────┤
│ │
│ 用户手指触摸屏幕,开始滑动 │
│ │ │
│ ├── 浏览器触发 touchstart 事件 │
│ │ └── 派发给 JS 主线程(异步——不等待!) │
│ │ │
│ └── 浏览器直接开始滚动! ← 不等 JS 执行完 │
│ 因为 passive=true 承诺了"JS 不会 preventDefault" │
│ │
│ 延迟 = 0ms │
│ 用户感觉:手指动 ↔ 屏幕动同步 → 流畅 │
│ │
└──────────────────────────────────────────────────────────┘
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
Chrome 56 的 breaking change:
从 Chrome 56 开始,
document级别的touchstart和touchmove事件监听器默认passive: true。如果你需要preventDefault()(如"在 touch 事件中阻止页面滚动,实现自己的拖拽逻辑"),你必须显式声明{ passive: false }。
// Chrome 56+:这个监听器默认 passive=true → preventDefault() 无效!
document.addEventListener('touchstart', handler);
// ✅ 必须显式声明 passive=false
document.addEventListener('touchstart', handler, { passive: false });
// ↑ 但如果你这样做,就失去了 passive 的滚动性能优化
// → 更好的方案:把 drag 逻辑挂在具体的拖拽元素上(不是 document 级别)
2
3
4
5
6
7
结论:passive 事件不是让 JS 跑得更快——而是让浏览器不等 JS 就跑。它的设计前提是:大多数 touchstart/touchmove 监听器不需要 preventDefault()(你只是想"知道"触发了 touch,而不是"阻止默认行为")。passive 让浏览器在这个"大多数情况下"异步启动滚动,零延迟——这是移动端"滑动跟手"的基础。
# 9. 事件委托与 Ob
# 9.1 事件委托原理
疑惑:2000 个按钮,每个绑 addEventListener 会慢吗?事件委托是怎么靠冒泡来"统一管理"的?
论证——事件委托依赖冒泡:
// ❌ 每个按钮单独绑——2000 个监听器 = 2000 个内存分配 + 2000 次注册
document.querySelectorAll('.btn').forEach(btn => {
btn.addEventListener('click', handler);
});
// ✅ 事件委托——1 个监听器管理全部 2000 个按钮
document.getElementById('list').addEventListener('click', e => {
const btn = e.target.closest('.btn'); // ← 找实际被点击的按钮(向上找最近的 .btn)
if (btn) {
const id = btn.dataset.id;
handler(id);
}
});
// 2000 个按钮的点击 → 全部冒泡到父容器 → 1 个 handler 统一分发
2
3
4
5
6
7
8
9
10
11
12
13
14
e.target vs e.currentTarget 的区别:
document.getElementById('list').addEventListener('click', e => {
e.target; // 实际被点击的元素——可能是 .btn 也可能是 .btn 内部的 <span>
e.currentTarget; // 监听器挂载的元素——始终是 #list
});
2
3
4
不适合事件委托的事件:
| 事件 | 原因 |
|---|---|
focus / blur | 不冒泡——事件不会传到父级 |
scroll | 高频触发(每帧多次),委托反而增加匹配开销 |
mousemove | 高频触发,委托让每一像素的移动都做一次 closest('.btn') 判断 → 比单独绑更慢 |
mouseenter / mouseleave | 不冒泡(对应冒泡的是 mouseover/mouseout) |
# 9.2 Mutation
疑惑:三个 Observer 都在"观察变化",但它们的回调触发时机为什么不同?
论证——它们在事件循环中的调度位置不同:
┌────────────────────────────────────────────────────────────────┐
│ 三个 Observer 在帧内的调度时机 │
├────────────────────────────────────────────────────────────────┤
│ │
│ 一帧(16.67ms @ 60fps) │
│ │
│ ═══ JS 执行 ═══════════════════════════════ │
│ │ 你修改了 DOM │
│ │ el.textContent = 'new'; │
│ │ el.classList.add('active'); │
│ │ │
│ ├── 微任务清空(此时 MutationObserver 回调被加入回调队列) │
│ │ 但还没执行——它在等当前宏任务执行完 │
│ │ │
│ └── 宏任务结束 │
│ │ │
│ ▼ │
│ ═══ 微任务检查点 ═════════════════════════════ │
│ └── MutationObserver 回调在这里执行! │
│ (Microtask——在所有微任务中) │
│ │
│ ═══ ResizeObserver ════════════════════════════ │
│ └── 在 Layout 之后、Paint 之前触发 │
│ "这个元素的尺寸变了" → 你可以在这里做响应 │
│ 但不能再改 DOM 的尺寸(会导致循环!) │
│ │
│ ═══ IntersectionObserver ═══════════════════════ │
│ └── 在 Layout 之后触发 │
│ "这个元素进入了视口" → 懒加载图片/懒渲染组件 │
│ 异步回调(不阻塞当前帧的合成) │
│ │
└────────────────────────────────────────────────────────────────┘
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
对比表:
| Observer | 监听什么 | 回调时机 | 调度类型 | 典型场景 |
|---|---|---|---|---|
| MutationObserver | DOM 增删/属性变化/文本变化 | 当前宏任务结束后、微任务阶段 | 微任务 | 监控 DOM 变化、主题切换响应、第三方代码注入检测 |
| ResizeObserver | 元素 content-box 或 border-box 尺寸变化 | 帧结束前、Layout 之后 | 帧内回调 | 响应式组件内部尺寸监听、代替 resize 事件 |
| IntersectionObserver | 元素进入/离开视口(或指定祖先的可见区域) | Layout 之后(异步) | 异步 | 懒加载图片/组件、无限滚动、广告曝光统计 |
// MutationObserver 用例:监听 DOM 注入
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeName === 'SCRIPT' && !node.src.startsWith('https://trusted-cdn.com')) {
node.remove(); // 阻止不可信脚本注入
}
}
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
// ResizeObserver 用例:替代 window.resize 做元素级尺寸监听
const ro = new ResizeObserver(entries => {
for (const e of entries) {
console.log(`Element ${e.target.tagName} resized to ${e.contentRect.width}x${e.contentRect.height}`);
}
});
ro.observe(document.querySelector('.sidebar'));
// IntersectionObserver 用例:懒加载
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
e.target.src = e.target.dataset.src; // 真正加载图片
io.unobserve(e.target); // 只加载一次
}
});
}, { rootMargin: '200px' }); // 提前 200px 开始加载(用户快滚到了)
document.querySelectorAll('img[data-src]').forEach(img => io.observe(img));
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
# 9.3 批量 DOM 操
| 策略 | 做法 | 适用场景 | Layout 次数 |
|---|---|---|---|
| 离屏操作 | DocumentFragment / display:none / cloneNode | 大量新建 DOM 节点 | 1 次(最后接入时) |
| 读写分离 | 先批量读→再批量写(两个循环) | 需要读取旧值再决定新值的场景 | 2 次(读 0 次 + 写 1 次) |
| 合成优化 | 用 transform / opacity 代替 width / left 做动画 | 动画和过渡 | 0 次(跳过 Layout + Paint) |
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的仪表盘——七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
① offsetHeight 为什么能触发 Layout?哪些属性同样危险? | 第 6.3 节:因为任何写操作(style.width/textContent/appendChild)会标记 Layout dirty。下一次读 offset*/client*/scroll*/getBoundingClientRect 时,浏览器发现 dirty → 强制同步 Layout → 计算整条脏链。完整的触发属性清单见 §6.3 |
| ② 布局是怎么从"读了一个属性"变成"重新计算整棵树的"? | 第 6.2 节:每个 RenderObject 挂有 needs_layout 脏位。写操作把元素标记为脏 → 脏位向上传播(祖先)和向下传播(子孙)→ 整棵子树需要重算 |
| ③ 浏览器怎么分层?GPU 层和 CPU 层的边界在哪? | 第 7.1 节:RenderObject → PaintLayer(软件层)→ GraphicsLayer(GPU 层)。3D transform / <video> / CSS 动画 / will-change 触发升 GPU 层。每升一层花费约 300-500KB 显存 |
④ 为什么 transform 比 left 流畅 10 倍? | 第 7.2 节:left 走 Layout 路径(Layout→Paint→Composite,~29ms);transform 走 Composite-only 路径(只需 GPU 矩阵变换,~1ms)。GPU 的并行着色器天然比 CPU 串行快一万倍 |
⑤ will-change 为什么不能滥用? | 第 7.3 节:每个 will-change 提前升 GPU 层 → 每个层 ~1.4MB 显存 → 100 个元素 = 140MB → 显存撑爆 → 性能反而恶化 |
| ⑥ passive 事件和布局抖动有什么关系? | 第 8.3 节:passive 不是解决 Layout 问题的——它解决的是"浏览器必须等 JS 执行完才能滚动"的延迟。触摸事件默认 passive: true 让浏览器异步启动滚动,零延迟 |
| ⑦ Observer 三剑客分别在帧的什么位置触发? | 第 9.2 节:MutationObserver → 微任务;ResizeObserver → Layout 之后/Paint 之前;IntersectionObserver → Layout 之后/异步 |
修复方案(按代价从小到大):
方案 A:读写分离(最小改动)
function updateAllMetrics(data) {
// Step 1:批量读(不写 → 0 次 Layout)
const widths = [], heights = [];
for (let i = 0; i < cards.length; i++) {
widths.push(cards[i].offsetWidth);
}
// Step 2:批量改 DOM(只写 → 1 次 Layout)
for (let i = 0; i < cards.length; i++) {
cards[i].textContent = data[i];
}
// Step 3:批量写样式(只写 → 1 次 Layout,且和 Step 2 合并为 1 次)
for (let i = 0; i < cards.length; i++) {
const height = cards[i].offsetHeight; // ← 之前已经在步骤 2 触发了唯一一次 Layout
cards[i].style.fontSize = calculateFontSize(widths[i], data[i]) + 'px';
cards[i].style.marginTop = (height > 80 ? 0 : 12) + 'px';
}
// 200 张卡片 → 总共 2 次 Layout(步骤 2/3 合并为 1 次)
// 帧时间:从 120ms 降到 ~5ms → 从 8fps 恢复到 60fps ✅
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
方案 B:用 requestAnimationFrame 把写操作推迟到帧末尾
function updateAllMetrics(data) {
// 读操作在当前帧执行
const widths = Array.from(cards, c => c.offsetWidth);
// 写操作推迟到下一帧的 "Layout 之前"
requestAnimationFrame(() => {
for (let i = 0; i < cards.length; i++) {
cards[i].textContent = data[i];
cards[i].style.fontSize = calculateFontSize(widths[i], data[i]) + 'px';
}
// 这一帧里只 Layout 一次——rAF 回调在帧开始时执行,后续 Layout 在帧末尾
});
}
2
3
4
5
6
7
8
9
10
11
12
13
代价:数据更新延迟了最多 16ms(一帧)——对于监控仪表盘可接受。
方案 C:用 CSS 变量代替 JS 手写样式(终极方案)
.card {
--card-width: 200px;
font-size: calc(var(--card-width) / 10);
transition: margin-top 0.3s;
}
2
3
4
5
JS 只负责更新数据 card.textContent = data[i],样式全在 CSS 里——读写分离做到零 JS 读布局属性。这是最彻底的方案。
# 10.2 用 Perfor
打开 Chrome DevTools → Performance → 录制 → 点击按钮 → 停止录制。在火焰图中找到颜色的对应关系:
| 颜色 | 阶段 | 含义 |
|---|---|---|
| 🟡 黄色 | Scripting | JS 执行(你的事件回调 + 框架的 reconciler) |
| 🟣 紫色 | Rendering | Recalculate Style + Layout |
| 🟢 绿色 | Painting | Paint(生成绘制指令 + 光栅化到 GPU 纹理) |
| ⬜ 灰色 | System | Composite Layers / 其他系统任务 |
| 🔴 红色(标记) | Forced Reflow | 强制同步布局——在 JS 执行期间被触发 |
理想帧的时间线(只改了 transform):
══ JS(黄色)══ ══ Composite(灰色)══
~2ms ~1ms
↓
transform 动画
跳过紫色(Layout)和绿色(Paint)!
总计:~3ms ≪ 16.67ms 帧预算 → 60fps ✅
2
3
4
5
6
7
抖动帧的时间线(不小心读了 offsetHeight):
══ JS ══ ══ Forced Layout(红标)══ ══ Layout(紫色)══ ══ Paint(绿色)══ ══ Composite ══
~3ms ~25ms(每卡片 0.3ms × 100) ~10ms ~5ms ~2ms
总计:~45ms > 16.67ms 帧预算 → 22fps 🔴
2
3
4
# 10.3 设计哲学回扣
哲学一·「浏览器是保守的投机者——但你一读布局属性它就破功」
浏览器不是每改一次 DOM 就立刻 Layout——它攒一批变更到下一帧的 Layout 阶段统一处理(第 09 篇的 rAF 对齐)。但当你去读 offsetHeight 时,浏览器被迫打破这种批处理——它必须为这一次读交出最新的布局结果。Layout Thrashing 的本质是"迫使浏览器放弃批处理优化,在同步代码中插入多次本应在帧末尾发生的 Layout"。
浏览器为什么不能"不管你有没有读,都先不 Layout"?因为在同步代码块中,offsetHeight 的返回值可能会被接下来的代码使用——如果返回一个过期的值,后续基于这个值的逻辑就全错了。浏览器的"保守"在于:它不能假设你不会用这个返回值——它只能假设你会用,然后老老实实把布局先算好。
哲学二·「分层就是花钱买速度——GPU 显存是有限且昂贵的」
把元素提升为 GPU 层需要显存——一个 300×200 的小层 ≈ 1.4 MB。100 个这样的层 = 140 MB。如果滥用 will-change 或者不小心造成"隐式层爆炸"(每个动画元素都独立成层),显存会被迅速撑爆,浏览器被迫频繁"层回退"——本来为"加速"而分配的资源,反而成了"减速"的瓶颈。
分层的正确哲学是"用空间换时间,但要精算空间"——只给"真的会动、且在关键路径上"的元素分层。will-change 不是"让它快"的咒语,是一笔你主动支付的"显存预付款"——无条件提前支付给不动的元素,就是亏本生意。
哲学三·「冒泡是分布式的事件总线——收敛与发散的矛盾」
事件委托的强大不在于省了几个 addEventListener——而在于把 N 个分散的监听器收敛到一个中心化的事件总线。这和 React/Vue 的"状态提升"异曲同工:分散的管理(每个按钮有自己的 handler)→ 难以追踪和修改;中心化的管理(父容器统一分发)→ 可控、可观测。
但中心化的代价是:高频事件(mousemove/scroll)如果走委托,每一次触发都要做一次 e.target.closest('.btn') 做"反收敛"——找到具体的目标。这个"发散"操作在高频下反而比"分散式"更慢。事件委托的取舍:当子元素数量大到监听器内存成为瓶颈时用委托;当事件频率高到匹配开销成为瓶颈时用直接绑定。
哲学四·「合成不是免费的午餐——它是浏览器在三个渲染路径中选的"最优解",但前提是你给了它选的机会」
浏览器有三种渲染路径:Layout 路径(width/left)、Paint 路径(color/background)、Composite-only 路径(transform/opacity)。这三条路径不是"越来越快"的简单升级——它们是三个完全不同的渲染子系统:
- Layout 路径:从头算到脚——最慢但最完整
- Paint 路径:跳过"位置计算",但依然要"画图"——中等
- Composite 路径:跳过一切,只做 GPU 层的排列组合——最快但限制最多(只有 4 个属性支持)
选择哪条路径,不是"哪个快就用哪个"——而是"你的动画到底只需要改变什么"。 一个改变 left 的动画,浏览器没有选择——它必须重算 Layout(因为 left 的改变可能影响兄弟节点的布局)。你改成 transform,等于告诉浏览器"我只需要在 GPU 上挪一下这层的位置"——浏览器才敢跳过 Layout 和 Paint。
好的渲染性能,不是靠优化得来的——是靠"不触发"得来的。不触发 Layout、不触发 Paint、只触 Composite——这就是 Web 动画做到 60fps 的全部秘密。 这和数据库里的"减少锁竞争"、操作系统里的"减少上下文切换"是同一哲学——在大规模系统中,最快的代码是没有被执行的代码。
# 10.4 速查表
三种渲染更新路径:
| 路径 | 触发属性 | 触发阶段 | 每帧耗时(相对) | 何时用 |
|---|---|---|---|---|
| Layout 路径 | width/height/left/right/top/bottom/margin/padding/border/display/position/float/font-size | Layout→Paint→Composite | 最贵 (~30-50ms) | 不可避免时(如响应式布局变化) |
| Paint 路径 | color/background/background-color/box-shadow/border-color/outline/visibility | Paint→Composite | 中等 (~5-10ms) | 纯视觉变化、不改变盒子大小 |
| Composite-only | transform/opacity/filter/backdrop-filter | Composite | 最便宜 (~1-2ms) | 动画首选 |
事件三阶段触发顺序:
| 阶段 | 方向 | addEventListener 参数 | 如何停止传播 |
|---|---|---|---|
| 捕获(Capture) | window → 目标 | 第三个参数 true 或 { capture: true } | e.stopPropagation() 或 e.stopImmediatePropagation() |
| 目标(Target) | 在被点击的元素上 | 默认行为(捕获/冒泡都会触发) | 同上 |
| 冒泡(Bubble) | 目标 → window | 默认行为(第三个参数不设 / false) | 同上 |
Observer 三剑客调度时序:
| Observer | 监听什么 | 调度类型 | 在帧中的位置 |
|---|---|---|---|
| MutationObserver | DOM 增删/属性/文本 | 微任务 | 当前宏任务结束后立即执行 |
| ResizeObserver | 元素尺寸变化 | 帧内回调 | Layout 之后、Paint 之前 |
| IntersectionObserver | 进入/离开视口 | 异步 | Layout 之后(不阻塞帧) |
60 秒诊断命令清单:
Chrome DevTools 快速诊断渲染性能:
1. Performance 面板 → 录制 → 找紫色 "Forced Reflow" 红色标记
→ 点击 → Sources 面板定位触发代码行
2. Rendering 面板(右上角三个点 → More tools → Rendering)
├─ Paint Flashing → 勾选 → 绿色的闪烁 = 这里有 Paint 发生
├─ Layout Shift Regions → 勾选 → 蓝色的闪烁 = CLS 布局偏移
├─ Layer Borders → 勾选 → 橙色的边框 = 这里有 GPU 合成层
└─ FPS Meter → 勾选 → 实时帧率显示
3. Layers 面板(More tools → Layers)
→ 看每个 GraphicsLayer 的尺寸和内存占用
→ "Compositing Reasons" 告诉你为什么这一层被提升
4. Coverage 面板(More tools → Coverage)
→ 录制 → 看 CSS 代码的使用率
→ 未使用的 CSS 可以按需加载(减少 CSSOM 构建时间)
5. Lighthouse → Performance 审计
→ "Avoid large layout shifts" → CLS 检测
→ "Avoid enormous network payloads" → 资源体积
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
布局抖动调试代码:
// 在 Console 中粘贴——实时监控 Layout 触发次数
let layoutCount = 0;
const observer = new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (entry.name === 'layout' || entry.entryType === 'layout-shift') {
layoutCount++;
console.warn(`Layout #${layoutCount} at ${entry.startTime.toFixed(1)}ms`,
entry.duration ? `(${entry.duration.toFixed(1)}ms)` : '');
}
}
});
observer.observe({ entryTypes: ['layout-shift', 'element', 'event'] });
// 执行你的代码 → 在 Console 中看到所有 Layout 的触发时机和耗时
2
3
4
5
6
7
8
9
10
11
12
13
下一步:渲染搞定了。但页面的数据从哪来、存哪里?进入 11.Web API 网络与存储架构。