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 架构简直就是在模仿浏览器渲染机制啊!!!

相关推荐
栀秋6662 小时前
深入浅出AI流式输出:从原理到Vue实战实现
前端·vue.js·前端框架
UIUV2 小时前
JavaScript流式输出技术详解与实践
前端·javascript·代码规范
weixin_462446232 小时前
PyQt 与 Flask 融合:实现桌面端一键启动/关闭 Web 服务的应用
前端·flask·pyqt
Hy行者勇哥2 小时前
Edge 网页长截图 + 网站安装为应用 完整技术攻略*@
前端·edge
Dreamboat-L2 小时前
VUE使用前提:安装环境(Node.js)
前端·vue.js·node.js
小徐不会敲代码~2 小时前
Vue3 学习
前端·javascript·vue.js·学习
大猩猩X2 小时前
vue vxe-gantt table 甘特图实现多个维度视图展示,支持切换年视图、月视图、周视图等
前端·javascript·甘特图·vxe-table·vxe-ui
m0_740043732 小时前
Element-UI 组件库的核心组件及其用法
前端·javascript·vue.js·ui·elementui·html
向上的车轮3 小时前
从“能用”到“好用”:基于 DevUI 构建高维护性、多端自适应的企业级前端架构实践
前端·架构