Chromium 渲染机制

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::RunTaskBlinkScheduler_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, 41, 5, 3, 4, 2。为什么 requestAnimationFramesetTimeout 的执行顺序不明确呢?

这涉及到渲染流程的时序问题:

  1. 渲染流程概述Renderer 线程将 layoutpaint 的数据 commitCompositor Thread 后,Compositor 会进行 rasterization(栅格化)和 compositing(合成),然后将数据提交到 Viz/GPU 进程。

  2. 任务执行的时序 :在 Renderer 进行 commit 后,Renderer 线程可以继续执行其他任务。但是 requestAnimationFrame 的回调是在 layout 之前执行的(由 Blink Scheduler 调度)。这意味着:

    • 一帧内可能进行多次 layoutpaint
    • 但只会进行一次 commit
    • commit 后,不会执行 requestAnimationFrame
    • requestAnimationFrame 会进入到下一帧的事件循环里执行
  3. 顺序不确定的原因 :这会导致 setTimeout 可能在上一帧的 commit 后的事件循环中完成,而 requestAnimationFrame 在下一帧才完成。这个现象的根本原因是:当你打开页面时,浏览器收到 HTML 请求并开始进行 parser 的时机,不一定是从 VSync 信号刚开始就进行的。因此,页面加载的开始时刻与显示器的刷新周期不同步,导致第一帧的执行时机存在不确定性。

渲染流程详解

渲染的主要步骤

一般来讲,完整的渲染流程包含以下步骤:

  1. 主渲染线程(CrRendererMain)Layout(布局) → Paint(绘制) → Commit(提交)

    • 提交的数据包括:Layer Tree(图层树)、Paint Records(绘制指令)、Scroll Offsets(滚动偏移)
  2. 合成器线程(Compositor Thread)Rasterization(栅格化) → Compositing(合成) → Presentation(呈现)

  3. VSync 信号到来时 :将 GPU 进程里的帧数据渲染到屏幕

优化机制

  • Commit 合并 :一帧内的多次 paint 操作会进行 commit 合并,减少不必要的提交。

  • requestAnimationFrame 的使用requestAnimationFrame 的回调在每帧内执行完毕,可以利用这个特性,将需要一次 commitlayout 或者 paint 代码放入 requestAnimationFrame 的回调中。每帧结束后会进行回调队列的清空。如果在宏任务里,layoutpaint 被分到两帧运行,则会有多次 commit,可能影响性能。

强制同步布局(FSL - Forced Synchronous Layout)

当 Chromium 执行脚本时,如果脚本对元素属性进行了修改,浏览器不会立即进行 layout,而是将 layout 延迟到下一次 RunTask 执行。但是,如果代码中进行了读取操作(比如读取 offsetWidthoffsetHeight 等布局相关的属性),会中断 JavaScript 的执行,立刻进行 layout(这就是 FSL 策略)。需要注意的是,即使触发 FSL,也不会立即 commitcommit 仍然会在合适的时机统一进行。

让我们通过以下代码来验证这一机制:

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)模式进行任务调度和执行。核心要点包括:

  1. 任务调度机制 :通过 ThreadControllerImpl::RunTask 执行宏任务,在任务完成后通过 BlinkScheduler_OnTaskCompleted 清空微任务队列。

  2. 渲染流程 :主渲染线程执行 LayoutPaintCommit,然后由合成器线程进行 RasterizationCompositingPresentation,最终在 VSync 信号到来时呈现到屏幕。

  3. 性能优化 :浏览器采用 FSL 策略避免不必要的布局计算,通过 commit 合并减少提交次数,利用 requestAnimationFrame 将渲染相关的代码在合适的时机执行。

  4. 长任务问题:长时间执行的 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 架构简直就是在模仿浏览器渲染机制啊!!!

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅8 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼9 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax