从《Life of A Pixel》来看Chrome的渲染机制
从《Life of A Pixel》来看Chrome的渲染机制
PPT本身就很精彩,你可以访问:Life of a Pixel - Google 幻灯片来看看Google自己是如何说明网页的内容(Web Content)是如何映射成Pixel的(像素)
Chrome的渲染机制说的很简单,其实定义就是——网页的内容(包含HTML CSS JS Image)是如何映射到动态的可视化窗口的
This talk is about how Chrome turns web content into pixels. The entire process is called “rendering”.
在这个地方,我们一般说明的是——地址栏所在的操作栏的下方才是咱们的WebContent
您可以看到,红色框就是咱们渲染的核心,一般而言,在代码中咱们需要找的就是content::WebContents
的地方。所有的绘制逻辑都会被派发到咱们的沙箱渲染进程中去,关心的核心就是Blink框架。
Blink框架是另一个有意思的话题,笔者还会进一步的翻译和重述Google自己的文档,做一些理解上的笔记。一些对Blink的理解,可以参考这个文档自己先看:How Blink works - Google 文档
一个网页的“内容”的基本构建块包括文本、图像、标记(围绕文本)、样式(定义标记的渲染方式)和脚本(可以动态修改上述所有内容)。
如果你很想看看网页背后是什么,其实很简单,只需要右键——打开网页源代码,你就可以看到成千上万行的由HTML,CSS和JavaScript组成的代码,他们默默的成为了一张网页的骨干——哦对了,熟悉计算机体系架构的朋友一定会追问——计算机肯定看不懂这些文本。他们是如何正确的理解由HTML,CSS和JavaScript组成的说明文本,渲染成一个又一个好看的文本的呢?啊哈。这个工作是浏览器的渲染子模块完成的——您看到的这些输入文本就是他们实时的渲染的输入,从编译原理的角度上看,他们更加像是一种解释型的语言,由咱们的渲染子模块动态的渲染这些代码后产生的好看的窗口内容。(There's no notion of compilation or packaging as you might find on other kinds of software platforms - the webpage's source code is the input to the renderer.
)
那显然,以Python为例子——他们需要被解释成机器看得懂的东西——比如说机器码。咱们的这些代码通过渲染进程渲染,但这没有解决问题!因为这是输入,另一端是什么呢?答案是——操作系统的底层绘制逻辑。
我们将这些代码翻译成一种操作系统图形库,比如说,OpenGL就是一个经典的图形库。Chrome渲染进程的另一个端口就是类似OpenGL的backend,如果是Windows,显然还会有DirectX。现在是2025年,Chrome的绘制后端还支持了Vulkan。
他们的作用很简单,就是将Chrome渲染进程翻译好的内容利用这些后端已经提供好的抽象——诸如“纹理”和“着色器”之类的低级图形基元,并允许您执行诸如“在虚拟像素缓冲区中绘制相应坐标处的三角形”之类的操作。
所以,我们知道:输入是网页的代码,输出是渲染后端看得懂的图形绘制操作。那么渲染进程的工作变得非常容易猜测了:将 HTML/CSS/JavaScript 转换为正确的 渲染后端调用,以显示像素。
Google的PPT特别强调了这些工作的渲染指标——我们还需要正确的中间数据结构,以便在渲染完成后高效地更新渲染,并响应来自脚本或系统其他部分的查询。这也是我们学习的一个抓手——Chrome是如何高效的转换以保证网页变化的实时性的?
所以中间到底在做什么?
Stage1 : Parsing What We Draw
Parsing!每一个语言都要做!我们要着手翻译咱们的人类可读的文本,编程方便生成底层操作的AST树,这些AST树反映这种结构的对象模型。我相信前端水平优秀的你已经猜到这样的AST的名称了——Document Object Model
,也就是DOM树。我们的类XML标签被翻译成了一颗树!用于指代每一个标签之前潜在的父子兄弟关系!
我们知道,这样的DOM树,不光描述了页面内部的组织关系是如何的,同时还给咱们的JavaScript提供抓手,用于查询或修改渲染。
var div = document.body.firstChil;
var p2 = div.childNodes[1];
...
JS中我们就类似这种方式编写代码,在解释的时候可以被通过,就是因为我们的JS跟DOM树是联动起来的。Chrome采用的JS引擎 (V8) 通过一个名为“绑定”的系统,将 DOM Web API 作为真实 DOM 树的薄包装器公开。
说的太抽象了,咱们来看一个真正的例子:
<SomeWidget><b>insideaar</b>
</SomeWidget>
上面的代码可能是咱们的Vue等框架导出后的一段HTML代码。这样一个看似普通的 HTML 标签 <SomeWidget>
,怎么就能被Parse成为变成一个带有强大功能的 JavaScript 组件?
马上,浏览器Parser扫描到了SomeWidget标签。他不管三七二十一,第一个步骤就是建立一个ShadowRoot,也就是造一个空盒子,先把里面包裹的内容扔进去再说。
很快,咱们的JS代码会出来说明这个<SomeWidget>
到底是啥——
class SomeWidget extends HTMLElement {// ... 一段复杂的JS逻辑,主要说明SomeWidget// 的行为逻辑
}customElements.define("some-widget", SomeWidget);
当自定义元素被“升级”(upgrade,浏览器把 DOM 节点关联到类的原型时),该元素就具备了类中定义的行为。如果该元素在其 shadow tree 中定义了 slot
,浏览器会在渲染/分发阶段把 light DOM(元素的子节点)分发到对应的 slot 中,从而形成最终的渲染树。
总结一下:解析阶段构建的是 DOM(描述结构与节点关系),自定义元素的类则在元素升级时决定节点的行为,Shadow DOM 与 slot 则是由自定义元素在运行时创建并由浏览器负责分发与渲染的机制。最终,这棵树承载了页面有哪些组件、怎么绘制、以及它们的交互行为。这个阶段,我们就Parse好了部分的JS和HTML,约定好了咱们要准备绘制什么内容!
Stage2: Prettier Paints With CSS
CSS作为样式描述,就要准备选中对应的DOM树,告知对应映射的DOM树是如何绘制的。举个例子,如果说咱们的CSS中写下了全局的段落标签的文字渲染成红色,比如说p { color: "red" }
的时候,我们的浏览器渲染进程就会调用对应的转化代码,将这段CSS映射为:在DOM树全局搜索P标签,且设置绘制文字的颜色是红色。这样的CSS标签还会有几百种,甚至可能存在方言——但是没关系,对应的解释代码总是会映射到DOM树对应的绘制渲染属性的修改。是文字的颜色,大小,是否斜体,加粗带下划线等等。都会进行一定的影响。
此外,确定样式规则选择哪些元素并非易事。某些元素可能被多条规则选中,并且特定样式属性的声明存在冲突。但是好在我们的文章是导论性质的,不去一头扎进去看看他到底怎么做的。
到底谁在做这个工作?答案是CSS 解析器,具体的将,咱们的CSS样式描述代码(可能是Style标签的描述,也有可能是从专门的CSS文件中读取)会被翻译成活动样式表构建样式规则模型(人话:翻译成底层渲染后端看得懂的玩意)。
样式解析(或重新计算)会从活动样式表中获取所有已解析的样式规则,并计算每个 DOM 元素的每个样式属性的最终值。这些值存储在一个名为 ComputedStyle 的对象中,该对象只是一个巨大的样式属性到值的映射。ComputedStyle里面就存储了咱们要提供给底层渲染的绘制细节是如何的。
Stage3: Build up Layout Tree
上面的两个步骤,我们解决了——网页的内容有什么,长得怎么样,下一步就是排布和确定布局。这个时候,我们会拿到这些映射好的DOM节点,开始计算位置在哪,多长?多宽?这一步就主要解决这个问题:为这些元素准备绘制的绘制矩形。
我们的绘制扫描是按照一个流下来的——每一块元素我们都要进行扫描(Block Flow),然后不同的文字咱们需要依赖使用名为 HarfBuzz 的文本整形库来计算每个字形的大小和位置,从而决定文本串的整体宽度。当然具体的细节这里不展开了。
对于表格元素或样式,需要更复杂的布局,例如指定将内容分成多列、将浮动对象放置在一侧并让内容在其周围流动,或者将文本垂直排列而不是水平排列的东亚语言。这些布局都会使用前些阶段——比如说CSS样式翻译规则 + HTML DOM树解析的结果进行合理的排布。
通常,一个 DOM 节点对应一个 LayoutObject。但有时 LayoutObject 没有节点,或者节点没有 LayoutObject。一个节点甚至可能包含多个 LayoutObject(例如,一个内联元素,其文本位于块子元素前后)。最后,布局树的构建基于 FlatTreeTraversal,它跨越了 Shadow DOM 边界。LayoutObject 的节点可能与其包含块的节点位于不同的 TreeScope 中。
我们得到的结果就是映射了DOM树的布局树——告诉我们的布局绘制时如何的。
Stage4:绘制这些布局
第三步中,咱们已经根据拿到的DOM + CSS绘制规则得到了布局树,现在我们要做的是——画出来他们!这些操作包括进一步理解我们的DOM树 + 布局树。
这些LayoutObject调用内部的Paint,将每一个节点的绘制翻译成对应更加详细粒度的操作,准备交给我们的渲染后端(OpenGL等)。上面的图已经很好的说明了——HTML的标签节点是如何映射为对应的LayoutObject,LayoutObject是如何被翻译为一系列的绘制操作,这些绘制操作将会如何指导后端进行对应的绘制。当然事情不会这么简单,我们会考虑前后景,会考虑Z-Index,会考虑各种元素的显示优先级。这里不再我们的讨论范围内。
Stage5: Raster The Paint Rects
光栅化!光栅化将会返回这些具体的绘制操作的绘制图像的翻译结果。主要就干一个事情——翻译上一个阶段的绘制指令为一个一个更加具体的位图,位图中的每个单元格都包含用于编码单个像素的颜色和透明度的位(我们终于看到绘制的一点可能性了!)
特别值得一提的是:Raster 还会解码页面中嵌入的图像资源。绘制操作引用压缩数据(JPEG、PNG 等),然后 Raster 会调用相应的解码器进行解压缩。我们的Image也是在这个时候被翻译为可绘制的位图。
这些位图存储在内存中,通常是由 OpenGL 纹理对象引用的 GPU 内存。他们还没有被提交到GPU线程进行绘制。这一点务必注意!
Stage6: Bridging The Real Rendering backend from the Bitmaps(Skia)
Skia在这里起作用,他是一个经典的跨平台的绘图库。光栅化通过名为 Skia 的库发出 OpenGL 调用。Skia 围绕硬件提供了一个抽象层,能够理解更复杂的对象,例如路径和贝塞尔曲线。Skia 的 GPU 加速代码路径会构建自己的绘图操作缓冲区,并在光栅任务结束时刷新。
Stage7:Real Paints by GPU Processes
回想一下,渲染器进程是沙盒化的,因此它无法直接进行系统调用。绘制操作会被发送到 GPU 进程进行光栅化。GPU 进程可以发出真正的 GL 调用。除了逃避渲染器沙盒之外,将 GL 隔离在 GPU 进程中还可以保护我们免受不稳定或不安全的图形驱动程序的影响。
这些光栅化的绘制操作被封装在 GPU 命令缓冲区中,并通过 IPC 通道发送。这个时候,这些指令就被认为是安全的,可以安全的进行系统调用,调用GPU进行绘制。
现在这些绘制的具体指令被IPC到了GPU渲染进程。GPU 进程中的 GL 函数指针通过动态查找系统共享的 OpenGL 库(Windows 上为 ANGLE 库)进行初始化。ANGLE 是 Google 构建的另一个库;它的作用是将 OpenGL 转换为 DirectX,后者是微软在 Windows 上用于加速图形的 API。
通过动态查找系统共享的 OpenGL 库(Windows 上为 ANGLE 库)进行初始化。ANGLE 是 Google 构建的另一个库;它的作用是将 OpenGL 转换为 DirectX,后者是微软在 Windows 上用于加速图形的 API。
这下,GPU进程就会把这些已经完全翻译好的后端调用命令提交给咱们的渲染后端——剩下的就是计算机图形学的主场了!我们的任务已经结束!