我不知道的浏览器(04)— 渲染流水线:从 HTML 到屏幕的八个步骤
很多人以为修改一个 CSS 属性会触发"浏览器重新绘制页面",但实际上——改 transform 只需要合成线程处理,改 width 要从布局步骤重新计算,改 background-color 跳过布局但仍需重绘。这三种操作的性能开销可以差 10 倍以上。
很多人以为修改一个 CSS 属性会触发”浏览器重新绘制页面”,但实际上——改 transform 只需要合成线程处理,改 width 要从布局步骤重新计算,改 background-color 跳过布局但仍需重绘。这三种操作的性能开销可以差 10 倍以上。
理解渲染流水线的每个步骤,才能准确判断某个 CSS 或 JS 操作会触发哪条性能路径。
一、流水线全貌
渲染进程把 HTML、CSS、JS 转换成屏幕上的像素,要经过八个步骤,分别由主线程和合成线程负责不同阶段:
主线程:
解析 HTML → 样式计算 → 布局 → 分层 → 绘制指令
合成线程 + GPU 进程:
分块 → 光栅化 → 合成显示
步骤 1-5 在主线程执行,步骤 6-8 在合成线程和 GPU 进程执行。 这个分工意味着,如果操作只影响步骤 6-8,就不需要”打扰”主线程。
二、主线程的五个步骤
步骤 1:解析 HTML,构建 DOM 树
主线程把 HTML 字节流解析成 DOM 树。这个过程中有一个很多人会忽略的细节——<script> 标签(非 async/defer)会暂停 HTML 解析。原因是 JS 可以修改 DOM,浏览器无法在 JS 执行之前”预判”后续 DOM 结构,所以只能停下来等。
浏览器通过”预解析线程”缓解这个问题:主线程解析 HTML 的同时,另一个线程会扫描后续 HTML,提前发起 CSS、JS、图片等外部资源的下载请求。
步骤 2:样式计算,构建 CSSOM
主线程解析所有 CSS(外部样式表、<style> 标签、内联样式),构建 CSSOM 树,然后遍历 DOM 树计算每个节点的最终 computed style——处理继承、层叠、单位换算。
一个常见误解:CSS 不阻塞 DOM 解析,但阻塞渲染。 DOM 树和 CSSOM 树的构建可以并行进行,但合并成渲染树(步骤 3)必须等 CSSOM 就绪。这就是 CSS 要放 <head> 的原因——越早加载,越早解除渲染阻塞。
步骤 3:布局(Layout / Reflow)
有了 DOM 和 computed style,主线程计算每个节点的精确位置和尺寸,输出布局树(Layout Tree)。
布局树和 DOM 树不完全对应:display: none 的节点不进入布局树;::before、::after 伪元素虽然不在 DOM 里,但会出现在布局树中。
布局是最昂贵的步骤之一。 修改任何影响位置或尺寸的属性(width、height、padding、margin、font-size 等),都会触发从步骤 3 重新计算。
步骤 4:分层(Layer Tree Update)
主线程遍历布局树,识别出需要独立绘制的区域,把它们提升为单独的图层(Layer)。常见的图层提升条件:
position: fixed或position: stickytransform不为noneopacity小于 1filter不为nonewill-change明确指定的属性
图层提升的代价是额外的内存占用,并非越多越好。
步骤 5:绘制(Paint)
主线程为每个图层生成绘制指令列表——“在坐标 (x, y) 画背景色 #fff,宽 100px 高 50px”等。这些指令不是直接输出像素,而是记录”怎么画”的操作序列。
绘制复杂度取决于样式:border-radius、box-shadow、filter 等会增加绘制指令数量。
三、合成线程接管:步骤 6-8
步骤 5 完成后,主线程的工作告一段落,接下来由合成线程负责。
步骤 6:分块(Tiling)
合成线程把每个图层切分成 256×256 或 512×512 像素大小的图块(Tiles)。这样做是为了后续并行光栅化,并优先处理视口(viewport)附近的图块——滚动时,先渲染用户能看到的部分。
步骤 7:光栅化(Rasterization)
合成线程把图块信息(包含绘制指令)发给 GPU 进程,GPU 的光栅化线程把绘制指令转换为实际的位图像素。GPU 的并行计算能力在这里发挥作用,速度远快于 CPU 单线程光栅化。
步骤 8:合成与显示(Compositing)
合成线程收集所有图块的位图,根据图层树结构、视口位置、滚动偏移、transform、opacity 等,计算每个图块在屏幕上的最终位置,生成合成帧(Compositor Frame),提交给操作系统显示。
这是 transform 和 opacity 性能优的根本原因——它们在步骤 8 由合成线程直接处理,完全绕过了主线程的布局(步骤 3)和绘制(步骤 5)。改变 transform 不需要重新计算布局,不需要重新生成绘制指令,只需要合成线程用新的变换矩阵重新生成合成帧。
四、三种性能路径:回流、重绘、合成
不同的样式修改会触发不同的流水线起点:
路径 1:回流(Reflow),触发步骤 3 重新执行,步骤 3-8 全部重来。代价最高。
修改几何属性会触发:width、height、padding、margin、border-width、font-size、line-height、position、display,以及 JS 读取 offsetTop、getBoundingClientRect() 等触发强制同步布局的 API。
路径 2:重绘(Repaint),跳过步骤 3(布局不变),从步骤 5 重新绘制。代价中等。
只改视觉样式时触发:background-color、color、border-color、visibility、text-decoration 等。
路径 3:合成(Compositing),跳过主线程步骤 3-5,只在合成线程处理。代价最低。
只有 transform 和 opacity(作用在合成层上时)走这条路径。这是 CSS 动画性能优化的核心原则——把需要高频更新的属性动画限制在 transform 和 opacity。
五、布局抖动:最容易被忽视的性能陷阱
下面这段代码,能看出它的性能问题在哪吗?
// 有性能问题的写法
const boxes = document.querySelectorAll('.box');
for (let i = 0; i < boxes.length; i++) {
// 写:触发布局标记为"脏"
boxes[i].style.width = boxes[i].offsetWidth + 10 + 'px';
// 读 offsetWidth:浏览器必须立即重新布局才能返回准确值
// 下次循环再写,再强制布局……
}
这段代码在每次循环里先修改 width(标记布局为”脏”),再读取 offsetWidth(强制浏览器立即同步执行布局以返回最新值)。循环 100 次,就触发了 100 次强制同步布局(Forced Synchronous Layout),俗称”布局抖动(Layout Thrashing)”。
正确写法是把读操作和写操作分离:
// 正确写法:先批量读,再批量写
const boxes = document.querySelectorAll('.box');
const widths = Array.from(boxes).map((box) => box.offsetWidth); // 一次性读取
for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = widths[i] + 10 + 'px'; // 批量写,不再触发强制布局
}
说白了,强制同步布局的本质是”在浏览器还没来得及批处理布局时,强行要求它立即给出布局结果”——这打断了浏览器的异步批处理优化。
六、总结
渲染流水线的八个步骤分两段:主线程负责从 HTML 到绘制指令的生成(步骤 1-5),合成线程和 GPU 负责把指令转换为屏幕像素(步骤 6-8)。
性能优化的核心逻辑是尽量减少触发流水线的入口步骤:回流 > 重绘 > 合成,开销从高到低。用 transform 替代 top/left 做位移动画,用 opacity 替代 visibility 做淡出效果,把动画限制在合成层——这些不是”经验技巧”,是对渲染流水线工作机制的直接应用。
本系列其他文章:
相关主题:
- 合成层分层的底层实现可参考:渲染线程为什么是单线程的
- CSS
transform触发的内联缓存机制:内联缓存:属性访问如何变成 O(1) 直接读