编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

    • 专栏博客

      • README
      • 引擎解析编译执行
      • 隐藏类与回收机制
      • 类型隐式转换精算
      • 作用域链闭包原理
      • 函数绑定规则组合
      • 原型链语法糖本质
      • 代理与元编程协议
      • 事件循环承诺机制
      • 工作线程并发调度
      • 页面渲染像素原理
        • 1. 案例与疑问引入
          • 1.1 一张卡到 8fp
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构全景概览
          • 2.1 Critical
          • 2.2 为什么分五步而不
        • 3. HTML到DOM
          • 3.1 HTML 解析器
          • 3.2 脚本阻塞解析的精
          • 3.3 Document
        • 4. CSS到CSSOM
          • 4.1 CSSOM 的构
          • 4.2 从右向左匹配
          • 4.3 CSS 阻塞渲染
        • 5. 渲染树合拢详解
          • 5.1 不可见元素的排除
          • 5.2 伪元素在 Ren
          • 5.3 RenderOb
        • 6. 布局与重排详解
          • 6.1 包含块 + BF
          • 6.2 布局脏位(Dir
          • 6.3 强制同步布局
        • 7. 绘制与合成详解
          • 7.1 PaintLay
          • 7.2 仅触发 Comp
          • 7.3 will-cha
        • 8. 事件系统模型
          • 8.1 捕获 → 目标
          • 8.2 addEvent
          • 8.3 passive
        • 9. 事件委托与 Ob
          • 9.1 事件委托原理
          • 9.2 Mutation
          • 9.3 批量 DOM 操
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 用 Perfor
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • 网络接口存储架构
      • 服务端运行时编程
      • 模块系统双轨操作
      • 现代工程链三件套
      • 设计模式函数哲学
      • 跨端架构终局总结
  • CodeX
  • JavaScript入门
  • 专栏博客
杨充
2026-06-11
目录

页面渲染像素原理

# 10.浏览器渲染像素之路

📍 上接第 09 篇《Worker 并发与调度时钟》。并发已了然。本文追问:JS 操作的 DOM 最终怎么变成屏幕像素?一行 element.offsetHeight 为什么可能让当前帧多出 50ms?浏览器在每一帧的 16.67ms 预算里做了什么?

# 目录介绍

  • 1. 案例与疑问引入
    • 1.1 一张卡到 8fp
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构全景概览
    • 2.1 Critical
    • 2.2 为什么分五步而不
  • 3. HTML到DOM
    • 3.1 HTML 解析器
    • 3.2 脚本阻塞解析的精
    • 3.3 Document
  • 4. CSS到CSSOM
    • 4.1 CSSOM 的构
    • 4.2 从右向左匹配
    • 4.3 CSS 阻塞渲染
  • 5. 渲染树合拢详解
    • 5.1 不可见元素的排除
    • 5.2 伪元素在 Ren
    • 5.3 RenderOb
  • 6. 布局与重排详解
    • 6.1 包含块 + BF
    • 6.2 布局脏位(Dir
    • 6.3 强制同步布局
  • 7. 绘制与合成详解
    • 7.1 PaintLay
    • 7.2 仅触发 Comp
    • 7.3 will-cha
  • 8. 事件系统模型
    • 8.1 捕获 → 目标
    • 8.2 addEvent
    • 8.3 passive
  • 9. 事件委托与 Ob
    • 9.1 事件委托原理
    • 9.2 Mutation
    • 9.3 批量 DOM 操
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 用 Perfor
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 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);
1
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 节
1
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 章)──→ 案例彻底剖开 + 哲学四条 + 速查表
1
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(合并 &amp; 排除不可见节点)                                 │
│  ┌──────────────────────────────────────────────────────┐          │
│  │  对每个可见 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 → 屏幕   │          │
│  └──────────────────────────────────────────────────────┘          │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
1
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 一样,看到一个元素立刻画到屏幕缓冲区里吗?

论证:

  1. 流式布局的需要——HTML 元素的大小不是孤立的。一个 <div> 的宽度取决于它的父容器、屏幕宽度、CSS 中的百分比/calc/vw 单位——这些东西在解析完那一行之前是不知道的。如果你边解析边画,后面来的一个 width: 100% 会否定你刚才画的全部内容。布局必须在"所有参与者都到场"之后才能算。

  2. CSS 的级联特性——同一个元素的最终样式可能来自:行内 style、id 选择器、类选择器、标签选择器、继承、默认值。这六层样式按**优先级(specificity)**叠加,最终才得到一个元素的 computed style。如果在 CSS 还没解析完时就开始画——后续一条 !important 会让前面的所有绘制白做。

  3. 分层渲染的硬件要求——浏览器的 Compositor 把不同元素分成不同的 GPU 层(比如视频在独立的层上,滚动的内容在另一层上)。分层的决策依赖 CSS 的 transform/opacity/will-change 等属性——这些信息在 Layout 计算完之后才能确定。分层是一个"后置"决策——你没法在不知道谁会动之前就分好层。

  4. 绘制指令需要 Layout 的结果——Paint 阶段生成"在 (100, 200) 处画一个宽 300 高 50 的蓝色圆角矩形"这样的指令。括号里的坐标全部来自 Layout——没有 Layout 的结果,Paint 不知道画在哪。

  5. 反向验证:如果浏览器真的"边解析边画",会出现什么?想象你打开一个网页,看到文字从左往右一条一条蹦出来、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 序列)
   "&amp;lt;div class='main'&amp;gt;"  →  [StartTag('div', {class:'main'})]
   "Hello"                    →  [Character('Hello')]
   "&amp;lt;/div&amp;gt;"              →  [EndTag('div')]

② Tree Construction(树构建——Token → DOM 树的插入/修正)
   Token 序列 → 状态的树构建器 → DOM 树节点
1
2
3
4
5
6
7
8
9

容错实例——浏览器怎么修复非法 HTML:

开发者写的 HTML:                  浏览器构建的 DOM:
─────────────────                 ──────────────────
&lt;p>Hello                           &lt;p>Hello&lt;/p>              ← 自动闭合 p
&lt;li>item A                         &lt;li>item A&lt;/li>           ← 自动闭合 li
&lt;li>item B                         &lt;li>item B&lt;/li>
                                   (浏览器给它们包了一个隐式 &lt;ul>?不会——li 直接挂到父节点)

&lt;table>                            &lt;table>
  &lt;tr>&lt;td>A&lt;/td>                       &lt;tbody>               ← 自动插入 tbody!
    &lt;/tr>                                &lt;tr>&lt;td>A&lt;/td>&lt;/tr>
&lt;/table>                               &lt;/tbody>
                                   &lt;/table>

&lt;select>&lt;div>hello&lt;/div>&lt;/select>   &lt;select>&lt;/select>        ← div 不能在 select 里
                                   &lt;div>hello&lt;/div>         ← 浏览器把它踢出 select
1
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 解析的阻塞行为为什么不同?

论证——这四种标签不仅在"是否阻塞"上不同,它们在下载时机、执行时机、执行顺序三个维度上都有差异:

┌──────────────────────────────────────────────────────────────────┐
│              脚本加载策略对比(四个维度的精确行为)                    │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  &lt;script>(无属性)                                                │
│  ┌─────────────────────────────────────────────────────────┐     │
│  │ HTML 解析 → 遇到 script → 停止解析 → 下载脚本 → 执行脚本 → 恢复解析│     │
│  │                     ║                                      │     │
│  │               阻塞开始                                     │     │
│  │  后果:&lt;/body> 之前的 &lt;script> 会卡住整个首屏                │     │
│  └─────────────────────────────────────────────────────────┘     │
│                                                                  │
│  &lt;script async>                                                  │
│  ┌─────────────────────────────────────────────────────────┐     │
│  │ HTML 解析 ───────────────────────────────→ 解析完成         │     │
│  │        ↘(并行下载脚本) 下载完成 → 立即执行(暂停解析)          │     │
│  │  后果:执行顺序不保证——谁先下载完谁先跑                         │     │
│  └─────────────────────────────────────────────────────────┘     │
│                                                                  │
│  &lt;script defer>                                                  │
│  ┌─────────────────────────────────────────────────────────┐     │
│  │ HTML 解析 ───────────────────────────────→ 解析完成         │     │
│  │        ↘(并行下载脚本)                       ↓             │     │
│  │                                    执行脚本(按文档顺序)     │     │
│  │  后果:一定在 &lt;/html> 之后、DOMContentLoaded 之前执行          │     │
│  └─────────────────────────────────────────────────────────┘     │
│                                                                  │
│  &lt;script type="module">                                          │
│  ┌─────────────────────────────────────────────────────────┐     │
│  │ 默认行为 = defer(不阻塞解析 + 按文档顺序执行)                  │     │
│  │ + 可以 import / export                                   │     │
│  │ + 自动 strict mode                                       │     │
│  │ + 同一 URL 只执行一次                                      │     │
│  └─────────────────────────────────────────────────────────┘     │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘
1
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 与脚本的交叉阻塞:

&lt;script> 执行前,浏览器检查:
  ① 这个 script 前面的所有 &lt;link rel="stylesheet"> 是否已解析完 CSS?
     └─ 没完 → 阻塞此 script 的执行(不是下载,是执行!)
     因为 script 里可能读 CSSOM(getComputedStyle / element.style)

  ② 这个 script 前面的所有 &lt;script defer> 是否已执行完?
     └─ 没完 → 等(保证 defer 的执行顺序)

这就是为什么 &lt;link> 放在 &lt;script> 前面也可能阻塞脚本执行——
不是阻塞下载,是阻塞 CSSOM 就绪。
1
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。
1
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
1
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
1
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 的合并——缺一不可。
1
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" │
└────────────────────────────────────────────────────┘
1
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 次,每步都在"迅速淘汰大量不匹配的候选"

关键区别:从右向左的每一步都在缩小候选集,
从左向右的每一步都在遍历大量无关的子节点。
1
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 → 省掉了向上遍历的开销 */
1
2
3
4
5
6

为什么最右边是关键(key selector):

最右边的简单选择器叫 key selector——浏览器用它在 DOM 中做第一轮海选。如果最右边的选择器匹配的节点很少(如 .icon 只出现在 10 个 span 上),整个选择器的匹配就极快。如果最右边是 *(通配符),那第一轮就命中了 10000 个节点——灾难。

结论:"从右向左"不是浏览器的偏好——是它唯一可行的匹配策略。如果从左向右匹配,一个 5 级选择器在 10000 节点的 DOM 上可能需要遍历上百万次。从右向左把这个数字压到几千次。

# 4.3 CSS 阻塞渲染

┌─────────────────────────────────────────────────────┐
│  CSS 阻塞渲染的精确规则                                │
├─────────────────────────────────────────────────────┤
│                                                     │
│  ✅ 阻塞 RenderTree(必须等全完):                     │
│     &lt;link rel="stylesheet" href="main.css">          │
│     &lt;style> ... &lt;/style>(内联样式)                   │
│     — 浏览器必须等 CSSOM 构建完才能进入 RenderTree 阶段   │
│                                                     │
│  ⚠️ 延迟阻塞(更坏——链式等待):                        │
│     &lt;style> @import url('other.css'); &lt;/style>       │
│     — 浏览器发现 @import → 发起新的 CSS 请求            │
│     — 这个新请求会阻塞当前的 CSSOM 构建                  │
│     — 现在的 CSSOM 构建又阻塞 RenderTree               │
│     — 结果:多一次网络往返 + 整个流程被串行化             │
│                                                     │
│  ❌ 不阻塞(立即恢复解析的例外):                        │
│     &lt;link rel="stylesheet" media="print">            │
│     — 浏览器知道这个只在打印时用 → 不阻塞屏幕渲染        │
│     &lt;link rel="stylesheet" media="(max-width: 600px)">│
│     — 浏览器会下载它,但在屏幕宽度 > 600px 时不被阻塞      │
│                                                     │
└─────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

@import 的性能灾难图解:

&lt;link href="style.css">     ← 浏览器看到这一行,开始下载 style.css
                                  │
     style.css 内容:              ▼
     @import url('theme.css');  ← 下载完 style.css 后才发现还要下载 theme.css
     body { ... }              ← 必须等 theme.css 下载并解析完才能处理 body 规则!

时间轴:
&lt;link> 下载 ──┐
              ├─ @import 下载 ──┐
              │                 ├─ 解析全部 CSS ─→ CSSOM 就绪 ─→ RenderTree
              └─ 解析 body ────┘(被迫等!)

对比 &lt;link> × 2:
&lt;link href="theme.css"> 下载 ──┐
&lt;link href="style.css"> 下载 ──┤ (两个并行下载!)
                                ├─ 解析全部 CSS ─→ CSSOM 就绪 ─→ RenderTree

结论:@import 把两个可以并行的请求变成了串行——多一次 RTT。
永远用 &lt;link> 代替 @import。
1
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(所有渲染阶段都跳过)             │                │
│   └──────────────────────────────────────┘                │
│                                                            │
└────────────────────────────────────────────────────────────┘
1
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:
&lt;div class="quote">               LayoutBlock (div.quote)
  "Hello"                           ├─ LayoutInline (匿名——文字 "Hello")
&lt;/div>                              ├─ LayoutInline (匿名——::before 的 content)
                                    │   这个节点不存在于 DOM 中!
                                    │   浏览器在计算样式时为它创建了
                                    │   一个"匿名 RenderObject"
                                    └─ LayoutInline (::after,同理)

伪元素也被称为"生成内容(generated content)"——
它们的 RenderObject 是在计算 div.quote 的样式时被"凭空创建"的。
1
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(纯文本节点:两个标签之间的文字)
        └─ 没有子元素、只有文字内容

示例:
&lt;div>                          → LayoutBlock
  &lt;span>text&lt;/span>            → LayoutInline
  裸文字                        → LayoutText
&lt;/div>
1
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 → 脱离正常流 → 不影响后续元素     │
│                                                          │
└──────────────────────────────────────────────────────────┘
1
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 的规则之一 -->
1
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 等布局属性)      │
│                                                      │
└──────────────────────────────────────────────────────┘
1
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';
1
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 执行中间——这就是为什么它会"偷走帧预算"。
1
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 面板定位到触发代码行。
1
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 &lt; 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) │  │
│  │   ✓ &lt;video> / &lt;canvas> / &lt;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/层                                   │  │
│  └───────────────────────────────────────────────────────┘  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
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 视图可旋转)
1
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!    │      │
│  └──────────────────────────────────────────────────┘      │
│                                                            │
└────────────────────────────────────────────────────────────┘
1
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 层"的额外开销
  → 比不加还慢!
1
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; }  /* 允许永久升层 */
1
2
3
4
5
6
7
8
9
10
11
12

结论:will-change 是一笔"显存预付款"——你提前告诉浏览器"我马上要用 GPU 层",浏览器提前分配了显存。如果滥用(全局用),就是"给所有元素付了预付款,但只有 2 个元素真的会动"——多余的显存被锁死,其他真正需要 GPU 加速的元素反而没空间了。

# 8. 事件系统模型

# 8.1 捕获 → 目标

┌────────────────────────────────────────────────────┐
│  用户点击 &lt;button> 的事件传播全过程                     │
├────────────────────────────────────────────────────┤
│                                                    │
│  ① 浏览器从 OS 拿到 (click, x=100, y=200)            │
│     → Hit Test(命中测试):x=100,y=200 落在哪个元素上? │
│     → 结果是 &lt;button>                                │
│                                                    │
│  ② 构建事件传播路径(Event Path):                      │
│     window → document → &lt;html> → &lt;body>              │
│       → &lt;div#container> → &lt;button>                   │
│                                                    │
│  ③ 捕获阶段(Capture Phase)——从 window 向下到目标:    │
│     ┌─────────────────────────────────────────────┐ │
│     │ 遍历 Event Path,对每个节点:                    │ │
│     │   执行该节点上 { capture: true } 的监听器         │ │
│     │   e.stopPropagation() → 停止继续向下            │ │
│     │                                              │ │
│     │ 触发顺序:                                      │ │
│     │ 1. window (capture → 有就执行)                  │ │
│     │ 2. document (capture)                          │ │
│     │ 3. &lt;html> (capture)                            │ │
│     │ 4. &lt;body> (capture)                            │ │
│     │ 5. div#container (capture) ← 最后一站            │ │
│     └─────────────────────────────────────────────┘ │
│                                                    │
│  ④ 目标阶段(Target Phase):                          │
│     ┌─────────────────────────────────────────────┐ │
│     │ 在目标元素 &lt;button> 上:                         │ │
│     │   执行所有注册的监听器(不区分 capture/bubble)     │ │
│     │   按注册顺序执行                                 │ │
│     └─────────────────────────────────────────────┘ │
│                                                    │
│  ⑤ 冒泡阶段(Bubble Phase)——从目标向上到 window:       │
│     ┌─────────────────────────────────────────────┐ │
│     │ 反向遍历 Event Path,对每个节点:                 │ │
│     │   执行该节点上 { capture: false } 的监听器(默认)  │ │
│     │   e.stopPropagation() → 停止继续向上            │ │
│     │                                              │ │
│     │ 触发顺序:                                      │ │
│     │ 1. &lt;button> (bubble)                          │ │
│     │ 2. div#container (bubble)                     │ │
│     │ 3. &lt;body> (bubble)                            │ │
│     │ 4. &lt;html> (bubble)                            │ │
│     │ 5. document (bubble)                          │ │
│     │ 6. window (bubble)                            │ │
│     └─────────────────────────────────────────────┘ │
│                                                    │
└────────────────────────────────────────────────────┘
1
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();
// 三个监听器全部被移除!
1
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                                               │
│  用户感觉:手指动 ↔ 屏幕动同步 → 流畅                          │
│                                                          │
└──────────────────────────────────────────────────────────┘
1
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 级别)
1
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 统一分发
1
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
});
1
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 之后触发                                     │
│          "这个元素进入了视口" → 懒加载图片/懒渲染组件               │
│          异步回调(不阻塞当前帧的合成)                             │
│                                                                │
└────────────────────────────────────────────────────────────────┘
1
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));
1
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 ✅
}
1
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 在帧末尾
  });
}
1
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;
}
1
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 ✅
1
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 🔴
1
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" → 资源体积
1
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 的触发时机和耗时
1
2
3
4
5
6
7
8
9
10
11
12
13

下一步:渲染搞定了。但页面的数据从哪来、存哪里?进入 11.Web API 网络与存储架构。

上次更新: 2026/06/16, 12:36:20
工作线程并发调度
网络接口存储架构

← 工作线程并发调度 网络接口存储架构→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式