Chromium 渲染机制深度解析:从 HTML 响应到页面渲染的完整流程
当我们打开一个网页时,从浏览器收到 HTML 响应到元素最终被渲染到屏幕上,这整个过程背后究竟遵循着什么样的机制?本文将深入探讨 Chromium 浏览器的渲染机制,通过 Chrome 的 tracing 工具来揭示浏览器内部的工作流程。
浏览器为了尽可能高效地协调资源来完成渲染、请求、事件、脚本等工作,采用了事件循环(Event Loop)的工作策略。这种机制确保了浏览器能够有序、高效地处理各种任务。
事件循环机制
让我们通过 Chrome DevTools 的 Tracing 功能来深入理解 Chromium 的工作机制。首先,我们来看一段包含多种异步操作的代码:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div style="width: 100px; height: 100px; background-color: red;"></div>
<script>
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
queueMicrotask(() => {
console.log(3);
});
requestAnimationFrame(() => {
console.log(4);
});
console.log(5);
</script>
</body>
</html>
当开启 tracing 后,刷新打开这个 HTML 页面,我们可以看到如下的 tracing 结果:

通过使用 w 键放大 CrRendererMain 的工作单元,我们可以看到主渲染线程在不断循环执行两个核心函数:

任务执行循环
CrRendererMain 线程不断地在循环执行 ThreadControllerImpl::RunTask 和 BlinkScheduler_OnTaskCompleted。其中,ThreadControllerImpl::RunTask 负责执行具体的任务,比如解析 HTML、遇到同步 script 标签时会执行脚本并阻塞 HTML 解析。
浏览器的实现机制是将不同的工作都定义为一个个 Task,每完成一个 Task 之后,就会检查微任务队列,然后清空微任务队列。
在 W3C 抽象的 JavaScript 执行模型里,JavaScript 的同步代码总是在 V8 的调用栈(Call Stack)中执行。当遇到异步任务时,会将其放入不同的任务队列(Task Queue,对应不同的 Task Source),或者交给独立的模块(如 Timer 模块)执行。这些模块在合适的时机将回调放入任务队列,然后由 BlinkScheduler 负责决定优先级,在下一次 ThreadControllerImpl::RunTask 时执行。
需要注意的是,被抽象为"宏任务"的并不是一个具体的队列,而是多个不同任务源的任务队列集合。这些任务通过优先级调度,不断地被 ThreadControllerImpl::RunTask 执行;而微任务则是在当前宏任务运行完成后,在 BlinkScheduler_OnTaskCompleted 回调中清空的。
多进程架构
通过 Tracing 我们还可以观察到,为了安全性和稳定性,Chromium 采用了多进程架构:
- 每一个标签页都是独立的
Renderer进程进行渲染 - 还有一个独立的
Browser进程,负责进程管理和协调,以及与操作系统交互
关于 requestAnimationFrame 和 setTimeout 的执行顺序
在上述代码的执行结果中,我们可能会看到两种不同的输出顺序:1, 5, 3, 2, 4 或 1, 5, 3, 4, 2。为什么 requestAnimationFrame 和 setTimeout 的执行顺序不明确呢?
这涉及到渲染流程的时序问题:
-
渲染流程概述 :
Renderer线程将layout和paint的数据commit到Compositor Thread后,Compositor会进行rasterization(栅格化)和compositing(合成),然后将数据提交到Viz/GPU进程。 -
任务执行的时序 :在
Renderer进行commit后,Renderer线程可以继续执行其他任务。但是requestAnimationFrame的回调是在layout之前执行的(由Blink Scheduler调度)。这意味着:- 一帧内可能进行多次
layout和paint - 但只会进行一次
commit commit后,不会执行requestAnimationFramerequestAnimationFrame会进入到下一帧的事件循环里执行
- 一帧内可能进行多次
-
顺序不确定的原因 :这会导致
setTimeout可能在上一帧的commit后的事件循环中完成,而requestAnimationFrame在下一帧才完成。这个现象的根本原因是:当你打开页面时,浏览器收到 HTML 请求并开始进行parser的时机,不一定是从VSync信号刚开始就进行的。因此,页面加载的开始时刻与显示器的刷新周期不同步,导致第一帧的执行时机存在不确定性。
渲染流程详解
渲染的主要步骤
一般来讲,完整的渲染流程包含以下步骤:
-
主渲染线程(CrRendererMain) :
Layout(布局) →Paint(绘制) →Commit(提交)- 提交的数据包括:
Layer Tree(图层树)、Paint Records(绘制指令)、Scroll Offsets(滚动偏移)
- 提交的数据包括:
-
合成器线程(Compositor Thread) :
Rasterization(栅格化) →Compositing(合成) →Presentation(呈现) -
VSync 信号到来时 :将
GPU进程里的帧数据渲染到屏幕
优化机制
-
Commit 合并 :一帧内的多次
paint操作会进行commit合并,减少不必要的提交。 -
requestAnimationFrame 的使用 :
requestAnimationFrame的回调在每帧内执行完毕,可以利用这个特性,将需要一次commit的layout或者paint代码放入requestAnimationFrame的回调中。每帧结束后会进行回调队列的清空。如果在宏任务里,layout和paint被分到两帧运行,则会有多次commit,可能影响性能。
强制同步布局(FSL - Forced Synchronous Layout)
当 Chromium 执行脚本时,如果脚本对元素属性进行了修改,浏览器不会立即进行 layout,而是将 layout 延迟到下一次 RunTask 执行。但是,如果代码中进行了读取操作(比如读取 offsetWidth、offsetHeight 等布局相关的属性),会中断 JavaScript 的执行,立刻进行 layout(这就是 FSL 策略)。需要注意的是,即使触发 FSL,也不会立即 commit ,commit 仍然会在合适的时机统一进行。
让我们通过以下代码来验证这一机制:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div style="width: 100px; height: 100px; background-color: red;" id="box"></div>
<script>
console.log(1);
box.style.width = '200px';
console.log(box.offsetWidth);
// queueMicrotask(() => {
// const start = performance.now();
// while (performance.now() - start < 2000) {}
// console.log('start to commit');
// })
</script>
</body>
</html>
实验 1:有读取操作的情况
如果有读取操作(如 console.log(box.offsetWidth)),在 Tracing 结果中可以看到,在 v8.run 执行时,有 ForcedStyleAndLayout 子任务:

这说明读取操作触发了强制同步布局(FSL)。
实验 2:无读取操作的情况
如果将读取代码注释掉,可以看到 layout 是在 v8.run 之后执行的:
这证明了在没有读取操作的情况下,layout 被延迟到了下一次任务执行。
如何确认 FSL 只触发 layout 而不触发 commit?
首先,渲染不是浏览器想渲染就能渲染的。layout 是内存操作,浏览器可以在任何时候执行;而渲染需要硬件配合,必须等待 VSync 信号。
其次,我们可以在上述代码的最后加入阻塞代码(比如取消注释代码中的 queueMicrotask 部分),通过肉眼观察可以发现:页面会阻塞,但不会立刻渲染新的布局结果。这证明了 FSL 只触发了 layout,并没有立即 commit 和渲染。
VSync 与帧率
显示器会根据刷新率每隔一定的时间进行刷新。刷新是指显示器将 GPU 里的帧数据进行展示(这个动作几乎是瞬间完成的)。通常由硬件发起 VSync(垂直同步)信号,表示一帧的开始(当然也表示上一帧的结束)。
浏览器必须在一帧时间内(通常是 16.6ms,对应 60Hz 的刷新率)完成脚本执行、渲染(将元素的改动进行统一的 commit,提交到合成器线程),然后等待 VSync 信号到来时,显示新的帧数据。
长任务的影响
CrRendererMain 虽然是通过异步的方式调度任务,但是执行任务本身是同步的。比如一个定时器的回调这样写:
jsx
setTimeout(() => {
let start = performance.now();
while (performance.now() - start <= 2000) {}
console.log('end');
}, 0)
JavaScript 的执行会占用主线程 2 秒时间。在这 2 秒内,即使 VSync 信号到来,合成器线程里的帧数据永远是上一帧的(因为主线程根本没有时间提交更新),一直在执行 JavaScript。这会导致页面"卡顿",用户体验下降。
总结
Chromium 基于多进程架构,每个窗口的渲染和脚本执行都采用事件循环(Event Loop)模式进行任务调度和执行。核心要点包括:
-
任务调度机制 :通过
ThreadControllerImpl::RunTask执行宏任务,在任务完成后通过BlinkScheduler_OnTaskCompleted清空微任务队列。 -
渲染流程 :主渲染线程执行
Layout→Paint→Commit,然后由合成器线程进行Rasterization→Compositing→Presentation,最终在VSync信号到来时呈现到屏幕。 -
性能优化 :浏览器采用 FSL 策略避免不必要的布局计算,通过
commit合并减少提交次数,利用requestAnimationFrame将渲染相关的代码在合适的时机执行。 -
长任务问题:长时间执行的 JavaScript 代码会阻塞主线程,导致无法及时提交新的渲染数据,造成页面卡顿。
理解这些机制有助于我们编写出性能更好的前端代码,避免不必要的强制同步布局,合理使用 requestAnimationFrame 等 API。
名词参考
主渲染线程部分
| 步骤 | 作用 |
|---|---|
| Layout (重排) | 计算页面上所有元素的 几何信息(尺寸和位置)。 |
| Paint (重绘) | 生成 绘制指令(Paint Records),记录如何用颜色、线条填充每个元素的像素。 |
| Commit (提交) | 主线程将所有渲染结果打包,提交给合成器线程。 |
| 提交数据 | Layer Tree (图层树): 描述了元素的分层结构和 3D 位置。 Paint Records (绘制指令): 告诉 GPU 如何生成像素。 Scroll Offsets (滚动偏移): 最新的滚动位置信息。 |
合成器线程部分
| 步骤 | 作用 (GPU 加速核心) |
|---|---|
| Rasterization (栅格化) | 利用 GPU 的并行核心,将 Paint Records 中的 绘制指令 转化为 GPU 内存中的 实际像素数据(位图纹理)。 |
| Compositing (合成) | 利用 GPU 对 图层 进行 混合和叠加,应用 CSS 变换(transform)、不透明度,生成最终显示在屏幕上的单个图像(Compositor Frame)。 |
| Presentation (呈现) | 将合成好的 Compositor Frame 提交给 Viz/GPU 进程,并 等待 VSync 信号 的到来,将帧数据展示到屏幕上。 |
参考资源
小声说一句,看完感觉似曾相识,react 的 fiber 架构简直就是在模仿浏览器渲染机制啊!!!