一、先建立全局视角:谁在干活
很多人以为渲染是"一个线程从头干到尾",其实不是。渲染进程里至少有三个关键角色:
渲染进程 (Renderer Process)├── 主线程 (Main Thread) ← JS、样式、布局、绘制指令都在这里├── 合成线程 (Compositor) ← 独立线程!负责图层合成└── 光栅线程 (Raster Threads) ← 把绘制指令变成位图GPU 进程 (GPU Process) ← 真正往屏幕上画
关键认知:主线程和合成线程是并行工作的。 这就是为什么有些动画在主线程卡死的时候依然流畅——因为它们根本不需要主线程参与。
二、完整管线:从字节到像素
Chromium 官方把整条管线拆成 13 个阶段,我按逻辑分组讲:
阶段 1-2:解析(Parse)
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 的子项分布要综合所有兄弟节点
-
文字换行要逐个测量文本宽度
-
百分比、
auto、min/max约束全部在这一步解析
所以改一个元素的 width,可能触发它自己、父级、兄弟、甚至整个文档的重新布局——这就是重排(Reflow)。浏览器做了大量优化(脏标记、增量布局),但复杂页面上依然是大头。
阶段 5:分层(Layering)
浏览器不是把整个页面当一张画布。它会把页面拆成多个图层(Layer),形成图层树。
哪些元素会独立成层?
-
transform: translateZ(0)/will-change: transform(经典的"硬件加速"hack) -
position: fixed -
<video>、<canvas>、<iframe> -
CSS 动画/过渡中的
transform、opacity -
有 3D 变换、滤镜的元素
阶段 6:绘制(Paint)
注意:这一步不产生像素! 它产生的是绘制指令列表(Display List / Paint Records),类似:
"移动到 (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 属性,浏览器走的路线完全不同:
┌─────────────────────────────────────────────────────────┐│ 改了 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):
鼠标移动 → mousemove 事件进主线程任务队列 → JS 回调执行(改样式) → Style → (Layout) → Paint/Composite → 下一帧显示
问题出在哪?
-
事件和渲染不同步:
mousemove的触发时机和 VSync 帧边界不对齐,事件可能在帧中间到达,也可能一帧内来好几个(被合并) -
主线程是单行道:JS 回调、样式计算、布局全挤在主线程上,博客页面加载时随便一个脚本执行 50ms,光标就冻住 3 帧
-
CSS transition 的真相:
transition作用在transform上时,插值动画确实由合成线程跑(所以"看起来"平滑);但目标值的更新依然要等主线程处理下一个mousemove。主线程一忙,目标值就断供,合成线程只能干等着——这就是你感受到的"表面丝滑、实际一卡一卡"
而系统原生光标为什么不卡? 因为它根本不走网页的渲染管线——光标是操作系统合成器(Windows 的 DWM / macOS 的 WindowServer)直接画的,独立于浏览器进程,你的 JS 再卡也影响不到它。
五、帧预算:一切优化的度量衡
60Hz 屏幕 → 每帧 16.6ms120Hz 屏幕 → 每帧 8.3ms一帧内主线程要完成:JS 执行 → Style → Layout → Paint → Composite└────────── 全部加起来不能超过 16.6ms ──────────┘超了 = 掉帧 = 你看到的卡顿
一帧的典型时间线:
VSync ──┬── rAF 回调 ── Style ── Layout ── Paint ── Composite ──┬── 显示 │ │ └────────────── 16.6ms 预算 ─────────────────────────────┘
顺带说清两个常被混淆的概念:
-
重排(Reflow) = 重新 Layout,几何变了
-
重绘(Repaint) = 重新 Paint,外观变了但位置没变
-
重排一定导致重绘,重绘不一定导致重排
六、如何亲眼验证
两个 DevTools 工具:
-
Performance 面板:录制一段,紫色条 = Style/Layout,绿色条 = Paint。紫色条超过 50ms 基本就是布局卡住了主线程
-
Rendering 面板(
Cmd/Ctrl+Shift+P搜 “Rendering”):-
勾选 Paint flashing:页面重绘的区域会闪绿光
-
勾选 Layer borders:看页面被切成了哪些图层
-
以移动光标举例:移动自定义光标时,如果看到光标周围一直在闪绿光,说明它在走"重绘"路线;如果改成 transform 并且元素独立成层,绿光就消失了。