浏览器渲染管线

一、先建立全局视角:谁在干活

很多人以为渲染是"一个线程从头干到尾",其实不是。渲染进程里至少有三个关键角色:

code
渲染进程 (Renderer Process)├── 主线程 (Main Thread)      ← JS、样式、布局、绘制指令都在这里├── 合成线程 (Compositor)     ← 独立线程!负责图层合成└── 光栅线程 (Raster Threads) ← 把绘制指令变成位图GPU 进程 (GPU Process)        ← 真正往屏幕上画

关键认知:主线程和合成线程是并行工作的。 这就是为什么有些动画在主线程卡死的时候依然流畅——因为它们根本不需要主线程参与。


二、完整管线:从字节到像素

Chromium 官方把整条管线拆成 13 个阶段,我按逻辑分组讲:

阶段 1-2:解析(Parse)

code
HTML 字节流 → 字符 → Token → DOM 节点 → DOM 树CSS 字节流  → 字符 → Token → CSSOM 树

几个要点:

  • HTML 解析是增量的,不需要等整个文件下载完。遇到 <script> 会暂停解析去执行 JS(除非有 async/defer

  • CSS 不阻塞 DOM 解析,但阻塞渲染——CSSOM 没建好之前,浏览器不会进行后面的步骤(因为怕算出错误的样式导致闪烁)

  • CSS 也阻塞 JS 执行,因为 JS 可能要读 getComputedStyle

阶段 3:样式计算(Style)

DOM + CSSOM → Render Tree(渲染树)

这一步干的事:对每个节点,跑一遍选择器匹配、级联、继承,算出最终的计算样式(computed style)。

注意:display: none 的节点不会进渲染树,但 visibility: hidden 会(它占位置)。

阶段 4:布局(Layout)—— 最贵的一步

计算每个节点的精确几何信息:位置、大小。

这一步为什么贵?因为布局是全局关联的:

  • 一个 width: 50% 要等父容器宽度确定

  • Flex/Grid 的子项分布要综合所有兄弟节点

  • 文字换行要逐个测量文本宽度

  • 百分比、automin/max 约束全部在这一步解析

所以改一个元素的 width,可能触发它自己、父级、兄弟、甚至整个文档的重新布局——这就是重排(Reflow)。浏览器做了大量优化(脏标记、增量布局),但复杂页面上依然是大头。

阶段 5:分层(Layering)

浏览器不是把整个页面当一张画布。它会把页面拆成多个图层(Layer),形成图层树。

哪些元素会独立成层?

  • transform: translateZ(0) / will-change: transform(经典的"硬件加速"hack)

  • position: fixed

  • <video><canvas><iframe>

  • CSS 动画/过渡中的 transformopacity

  • 有 3D 变换、滤镜的元素

阶段 6:绘制(Paint)

注意:这一步不产生像素! 它产生的是绘制指令列表(Display List / Paint Records),类似:

code
"移动到 (10, 20)""画一个 100×50 的圆角矩形,填充 #ff6b35""在 (30, 40) 绘制文本 'Hello'"

可以理解成录制了一段"画画的操作录像",而不是画本身。

阶段 7-8:分块 + 光栅化(Tiling + Rasterize)

这一步才真正产生像素:

  • 分块:每个图层被切成小块(tile,通常 256×256)。为什么?因为视口外的内容没必要立刻画,优先光栅化视口附近的块

  • 光栅化:把绘制指令执行成位图,交给 GPU 的光栅线程并行处理(这就是 chrome://gpu 里那个 “Rasterization: Hardware accelerated”)

阶段 9-11:合成 + 绘制四边形 + 显示

  • 合成线程为每个 tile 生成 Draw Quad(“把这个 tile 画在屏幕这个位置,应用这个变换”)

  • 打包成 CompositorFrame 提交给 GPU 进程

  • GPU 进程按 VSync 信号把帧提交给显示器


三、整条管线的"快车道"和"慢车道"

这是理解性能优化的核心。你改一个 CSS 属性,浏览器走的路线完全不同:

code
┌─────────────────────────────────────────────────────────┐│  改了 width / margin / top / font-size / padding ...    ││  → Style → Layout → Paint → Raster → Composite          ││  (全程主线程,最慢,触发重排)                            │├─────────────────────────────────────────────────────────┤│  改了 color / background / box-shadow ...               ││  → Style → Paint → Raster → Composite                   ││  (跳过 Layout,但仍需主线程重新生成绘制指令)              │├─────────────────────────────────────────────────────────┤│  改了 transform / opacity / filter ...                  ││  → Composite                                            ││  (主线程完全不参与!合成线程直接挪动已有图层的位图)        │└─────────────────────────────────────────────────────────┘

第三条就是"合成器快车道":元素的位图早就光栅化好了,transform: translateX(100px) 只是让 GPU 把这张位图挪个位置,成本几乎为零。


四、回到光标问题

以页面自定义光标举例:

自定义光标方案(假设是 mousemove 里改 left/top 或者 transform):

code
鼠标移动 → mousemove 事件进主线程任务队列        → JS 回调执行(改样式)        → Style → (Layout) → Paint/Composite        → 下一帧显示

问题出在哪?

  1. 事件和渲染不同步mousemove 的触发时机和 VSync 帧边界不对齐,事件可能在帧中间到达,也可能一帧内来好几个(被合并)

  2. 主线程是单行道:JS 回调、样式计算、布局全挤在主线程上,博客页面加载时随便一个脚本执行 50ms,光标就冻住 3 帧

  3. CSS transition 的真相transition 作用在 transform 上时,插值动画确实由合成线程跑(所以"看起来"平滑);但目标值的更新依然要等主线程处理下一个 mousemove。主线程一忙,目标值就断供,合成线程只能干等着——这就是你感受到的"表面丝滑、实际一卡一卡"

而系统原生光标为什么不卡? 因为它根本不走网页的渲染管线——光标是操作系统合成器(Windows 的 DWM / macOS 的 WindowServer)直接画的,独立于浏览器进程,你的 JS 再卡也影响不到它。


五、帧预算:一切优化的度量衡

code
60Hz 屏幕 → 每帧 16.6ms120Hz 屏幕 → 每帧 8.3ms一帧内主线程要完成:JS 执行 → Style → Layout → Paint → Composite└────────── 全部加起来不能超过 16.6ms ──────────┘超了 = 掉帧 = 你看到的卡顿

一帧的典型时间线:

code
VSync ──┬── rAF 回调 ── Style ── Layout ── Paint ── Composite ──┬── 显示        │                                                        │        └────────────── 16.6ms 预算 ─────────────────────────────┘

顺带说清两个常被混淆的概念:

  • 重排(Reflow) = 重新 Layout,几何变了

  • 重绘(Repaint) = 重新 Paint,外观变了但位置没变

  • 重排一定导致重绘,重绘不一定导致重排


六、如何亲眼验证

两个 DevTools 工具:

  1. Performance 面板:录制一段,紫色条 = Style/Layout,绿色条 = Paint。紫色条超过 50ms 基本就是布局卡住了主线程

  2. Rendering 面板Cmd/Ctrl+Shift+P 搜 “Rendering”):

    • 勾选 Paint flashing:页面重绘的区域会闪绿光

    • 勾选 Layer borders:看页面被切成了哪些图层

以移动光标举例:移动自定义光标时,如果看到光标周围一直在闪绿光,说明它在走"重绘"路线;如果改成 transform 并且元素独立成层,绿光就消失了。