2

原文:Inside a Super Fast CSS Engine: Quantum CSS(Aka Stylo), Lin Clark

注:原文发布于 2017 年 8 月,本文翻译于 2018 年 4 月,因此对文中跟时间相关的部分内容做了调整,但不影响核心内容。

全新的 CSS 引擎 - Stylo

你可能已经对量子项目(Project Quantum)有所耳闻,该项目对 Firefox 浏览器的内部实现进行了重大重写,以求更高性能。
本次重写中,我们使用了并行浏览器引擎 Servo 的新技术。这里要介绍地是我们对浏览器引擎的重大改进。

该项目的开发过程就像是给正在飞行中的飞机更换引擎。对于浏览器中的各个组件,我们是逐个进行更改的。这样,我们就可以在一个组件准备就绪后,立刻从 Firefox 中看到最新效果。

01-500x317.png

Servo 的主要组件包含了一个全新的 CSS 引擎,称为量子 CSS(Quantum CSS,也称为 Stylo)。

这个新引擎集成了四种不同浏览器的最新创新技术,创造出一个全新的超级 CSS 引擎。

02.png

它充分利用了现代硬件多核心的特性,把所有的工作都变成了并行化操作。这使得它可以提速 2 ~ 4 倍,甚至最大能达到 18 倍。

在并行化的基础上,它还结合了其他浏览器现有的最先进优化技术。所以,即使抛开并行化运行技术,它也仍然是个快速的 CSS 引擎。

03.png

这里,我们不禁要问,这个 CSS 引擎到底做了什么呢?
要回答这个问题,首先要知道 CSS 引擎是什么,它是如何跟浏览器其他组件一起工作的。然后,我们再看看 Stylo 是如何变得更快的。

CSS 引擎原理

CSS 引擎是浏览器渲染引擎(Rendering Engine)的重要组成部分。
渲染引擎的工作就是把网页的 HTML 和 CSS 文件转化为屏幕上显示的像素点。

04.png

每个浏览器都有一个渲染引擎。Chrome 的称为 Blink,Edge 的称为 EdgeHTML,Safari 的称为 WebKit,以及 Firefox 的称为 Gecko 。

基本流程

要想把文件变为像素点,所有的渲染引擎基本上都会做以下相同的工作:

1、把文件解析成浏览器能理解的对象,包括 DOM 。从这个角度来说,DOM 掌握了整个页面结构。它知道每个元素之间的父子关系,但是它不知道这些元素具体长什么样。

05-01.png

2、弄清楚每个元素应该长什么样。对于每个 DOM 节点,CSS 引擎首先会弄清楚应该对它应用什么 CSS 规则。然后,会计算出每个 CSS 属性的值。

05-02.png

3、计算出每个节点的尺寸和它在屏幕上的位置。为每个要在屏幕上显示的内容创建盒模型。这些盒模型不仅仅用来表示 DOM 节点,它们也用来表示 DOM 节点的内部内容,比如一行一行的文本。

05-3.png

4、绘制不同的盒模型。这可以发生在多个图层上。它就像是以前使用半透明纸的手绘动画,每个图层都是独立的一张纸。这样我们就可以只改变当前图层的内容,而不会影响到其他图层的内容。

05-04.png

5、取出已绘制的图层,应用任何仅包含合成器的属性(比如变换),然后把它们合成为一张图片。这就好比为这些叠加在一起的图层拍一张照片,之后这张照片将会在屏幕上渲染出来。

05-05.png

从以上过程可以看出,当 CSS 引擎开始计算样式时,它已经得到了两样东西:

  • DOM 树
  • 样式规则列表

引擎会逐个遍历所有 DOM 节点,并计算出它们的样式。这个过程中,它会给 DOM 节点的每个 CSS 属性都进行求值,包括样式表中没有声明的属性。

这个过程就像一个人从头到尾填一张表格一样。CSS 引擎需要给每个 DOM 节点都填写一张表格。并且,表格的每一处空白都需要填上值。

06.png

为了填这张表格,CSS 引擎需要做两件事:

  • 计算出每个节点应该应用什么样式规则,即选择器匹配(Selector Matching)
  • 根据父节点或默认值计算出缺失的属性值,即样式级联(Cascading)

选择器匹配

在这个步骤中,CSS 引擎会把所有与 DOM 节点相匹配的样式规则添加到一个列表中。
因为可能有多个样式规则都匹配中,所以可能有多个相同的 CSS 属性声明。

07.png

此外,浏览器本身也提供了一些默认的样式规则,即用户代理样式表
那 CSS 引擎是如何确定应该使用哪个值的呢?

这个时候就需要特异性规则(Specificity Rules)来帮忙了。
CSS 引擎会创建一个电子表格,然后根据不同的列对其进行排序。

08.png

最终,拥有最高特异性的规则会胜出。所以,基于这个表格,CSS 引擎就能够填充那些它能够填充的属性值了。

09.png

对于不能使用这个方式计算出的值,它就会使用样式级联。

样式级联

样式级联让 CSS 的编写和维护都变得更加简单。因为样式级联,你可以在设置了 <body>color 属性后,直接就知道 <p><span><li> 也将会使用你设置的颜色值(除非被重写了)。

为了找出级联的样式属性,CSS 引擎会查看表格中的空白部分。
如果属性默认为继承值,那么 CSS 引擎会沿着 DOM 树往上查找,看看其祖先元素是否已经设置了该值。
如果所有的祖先元素都没有设置该值,或者该属性并不是继承,那么就会使用默认值。

10.png

至此,一个 DOM 节点的所有样式属性就都已经得到计算值了。

样式结构共享

其实,上面提到的样式表格与实际情形并不完全一致。
CSS 拥有的样式属性非常多,达到上百个。如果 CSS 引擎针对每个 DOM 节点的属性都保存一份样式值,那么内存将会被迅速耗尽。

相反,CSS 引擎通常会使用样式结构共享(Style Struct Sharing)。
它会把通常在一起使用的样式值存储于一个单独的对象中,该对象称为样式结构
然后,与其重新存储相同对象上的所有样式值,计算的样式对象实际只保存了指向那个对象的指针。
对于每个类别的样式,实际上存储的都是一个指向样式结构的指针。

11.png

这种共享方式既省内存又省时间。这样的话,具有相似样式的节点(比如兄弟节点)就只需要保存指向共享样式结构对象的指针即可。而且,由于很多属性都是继承来的,所以祖先节点可以跟所有的子孙节点共享相同的样式结构对象。

优化改进

上面所说的就是优化之前的样式计算过程。

12.png

这个过程中进行了很多计算工作。而且它不只是在第一个页面加载的时候发生。它会在用户与页面进行交互的过程中反复的发生,悬停在元素上或者改变 DOM 结构,都会触发样式重算(Restyle)。

13.png

也就是说,CSS 样式计算是一个举足轻重的待优化点。而且在过去的 20 年里,各个浏览器一直都在测试使用不同的策略来优化它。
Stylo 充分吸收了来自不同引擎的优化策略,然后把它们结合在一起,从而创造出一个全新的超级引擎。

下面让我们来看看 Stylo 的实现细节。

并行运行

Servo 是一个实验版的浏览器,Stylo 就是该项目的一部分。Servo 想把渲染页面所需的所有工作都进行并行化。
并行化具体指什么呢?

一台计算机就像一个大脑。其中有一个部分是专门进行逻辑思考的,叫做算术逻辑单元(Arithmetic Logic Unit, ALU)。在 ALU 附近,排列着一些短期记忆存储单元,称为寄存器(Register)。ALU 和寄存器都是一起放在在 CPU 内部的。当然也有用于长期记忆的存储单元,称为内存(RAM)。

14.png

使用这种 CPU 的早期计算机在同一时间只能进行一种事情。
不过在过去的十几年里,CPU 已经进化到同时拥有多个 ALU 和寄存器组,具备了多个核心。
这就意味着 CPU 可以一次进行多种事情,而且是同时进行的。

15.png

Stylo 通过利用计算机的这种特性,把不同 DOM 节点的样式计算过程分配到不同的计算核心当中。

这看起来是一件很简单的事情,只需要把 DOM 树的不同分支分开来,然后交给不同的核心即可。但实际上做起来却比想象的更加困难,其中一个原因就是 DOM 树通常是不均匀的。这会导致有些核心做的工作会比其它的做得多很多。

16.png

为了让工作分配得更加均匀,Stylo 采用了一种称为工作偷窃(Work Stealing)的技术。当处理一个 DOM 节点时,运行的代码会把它的子节点分成一个或多个工作单元(Work Units)。这些工作单元会被添加到一个队列中去。

17.png

当某个核心把它的工作队列都完成后,它会查看其它队列中的工作单元,然后拿过来做。
这样的话,我们就可以把工作分配得更加均匀,而又不需要花费时间来遍历 DOM 树,也不需要事先就花费时间来计算该如何均匀地分配工作。

18.png

在大多数的浏览器中,这种并行化工作做起来非常困难。总所周知,并行化是一块难啃的硬骨头,而且 CSS 引擎非常复杂。同时, CSS 引擎还处于其他两个最复杂部分: DOM 和布局的中间地带。
因此,这会非常容易引入 BUG ,而且并行化也会导致非常难以追查的 BUG,叫做数据竞争(Data Races)。我在另一篇文章中详细介绍了这种类型错误,感兴趣的可以参考下。

如果你接受来自成百上千名工程师的代码贡献,你是如何做到平行编程而又无惧 BUG 的呢?这正是 Rust 的用武之地。

19.png

在 Rust 中,你可以通过静态检查的方式来避免数据竞争。也就是说,你可以直接在代码中就避免这种难以调试的错误。编译器是不会让你的代码存在这样的问题的。

使用 Rust ,CSS 样式计算就变成了所谓的完美并行问题,因为你基本上不用做什么就实现了并行化。这意味着我们的这个优化可以达到线性增长。如果你的机器有 4 个核心,那么你就拥有接近 4 被的性能增长。

规则树

对于每个 DOM 节点,CSS 引擎需要遍历所有的样式规则来完成选择器匹配。
但是对于大多数节点来说,这种匹配规则并不是改变的太频繁。
比如,如果用户把光标悬停在某个父元素上,那么匹配中该元素的样式规则就可能改变了。我们也需要重新计算它的后代元素的样式,以重新处理那些继承属性。当然,匹配中这些后代元素的规则也可能是不变的。

如果我们能够记录哪些规则能够匹配中这些后代元素,那是极好的,这样我们就不需要对它们重新进行选择器匹配。这就是我们从 Firefox 上一代 CSS 引擎中借鉴过来的规则树(Rule Tree)的原理。

CSS 引擎会经历选择器进行匹配的整个过程,然后按照特异性来排列它们,由此创建一个规则链表。

该链表会被添加到规则树中。

20.png

CSS引擎会尽量使得规则树的分支数量保持在最小值。为此,它会尽量复用已存在的规则分支。

如果链表中的选择器与已存在的分支相同,那么它将顺着相同的路径往下走。不过它可能最终会走到一个下一个规则不同的节点处,只有这个时候引擎才会新增一个分支。

21.png

DOM 节点会取得指向这个规则最尾端节点的指针(这个例子中是 div#warning 规则)。而且,它的特异性最高的规则。

在样式重算时,CSS 引擎会进行一项快速检查,以判断对父元素的变更是否会影响匹配中子元素的规则。如果不影响,那么对于任何后代节点,引擎只需要顺着后代节点保存的规则指针就可以找到对应规则分支。在规则树中,只要顺着树向上遍历到根节点就可以获取所有匹配的样式规则。也就是说,CSS 引擎完全跳过了选择器匹配和特异性排列过程。

22.png

这样我们就减少了样式重算过程的计算量。
虽然如此,但是在样式初始化时还是会耗费大量计算。假如有 10,000 个节点,仍然需要进行 10,000 次选择器匹配。
不过,不用担心,我们还有另一种方式来优化它。

样式共享缓存

对于一个拥有成千上万个节点的页面,其中有许多节点都会匹配中相同的样式规则。
举例来说,对于一个很长的维基页面,主要内容区的段落应该都是应用相同的样式规则,因此也就有相同的计算样式。

如果这里不做优化的话,那么 CSS 引擎必须对每个段落都进行一次选择器匹配和样式计算。
但是如果有一种方式能证明这些不同段落使用的是相同样式的话,那么引擎就只需要做一次计算即可,然后其他段落节点都指向相同的计算样式。

这就是我们所说的样式共享缓存(Style Sharing Cache),这种做法的灵感来自 Safari 和 Chrome 。
当处理完一个节点之后,引擎会把计算样式放进缓存。然后,在开始计算下一个节点的样式之前,引擎会做一些检查来判断是否可以使用已缓存的样式。

这些检查包括:

  • 两个节点是否有相同的 id、class 等?如果是,那么它们可以匹配中相同的样式规则。
  • 对于任何不是基于选择器的样式,比如内联样式,节点具有相同的样式值么?如果是,那么继承自父节点的属性不会被覆盖,或者以相同的方式被覆盖。
  • 节点的父节点是否指向相同的计算样式对象?如果是,那么继承的样式值则是一样的。

23.png

从样式共享缓存被提出的一开始,这些检查就已经应用了。
不过,随着 CSS 的发展,有许多其它小场景会导致样式共享缓存的检查方式失效。
比如,如果一个 CSS 规则使用了 :first-child 选择器,那么两个段落元素时就可能会导致样式不一致,即使上面的那些检查都认为它们是相同的。

在 WebKit 和 Blink 中,样式共享缓存会忽略这些场景,并且不使用缓存。
随着越来越多的网站使用现代选择器,样式共享缓存的优化变得越来越鸡肋,因此 Blink 团队最终还是把它移除了。
但是,事实证明样式共享缓存还是有办法跟上这些进化节奏的。

在 Stylo 中,我们把记录着所有这些现代选择器并检查他们是否能够适用于 DOM 节点。然后,我们把检查结果以 0 和 1 的方式存储起来。如果两个元素有相同的 0 和 1 ,那么我们就可以确定它们是匹配的。

24.png

如果一个 DOM 节点可以使用已经计算的样式缓存,那么引擎就可以直接跳过大量的计算过程。由于页面中经常有大量的 DOM 节点拥有相同的样式规则,所以样式共享缓存不仅可以节省内存,同时也能加快计算过程。

25.png

结论

Stylo 是第一个从 Servo 迁移到 Firefox 的大型技术。
在这个过程中,我们已经学到了很多关于如何把使用 Rust 编写现代高性能代码集成到 Firefox 核心。

事不宜迟,赶紧下载 Firefox ,体验极速吧!


mingzhong
2.1k 声望3.2k 粉丝

世界很美好,时间很宝贵。