别给面试官讲旧的浏览器渲染了

3,926 阅读12分钟

学习输出之作

通常,我们在面试的时候,面试官常常会问到一个问题: “说一下浏览器输入url到页面展示,这中间发生了什么?”我们往往会更多的关注于网络中间的流程,对于浏览器渲染往往一知半解。

了解浏览器渲染流程,我们能够更加清晰的了解HTML、CSS、JS在渲染过程中的关系

那么就让我们一起看一下浏览器的渲染流程

图出自《浏览器工作原理与实践》

渲染流程

当浏览器从网络中接收到第一个数据分块时,它就开始解析收到的信息。我们知道浏览器初次请求接受到的第一个内容分块通常是14kb的数据(性能优化的一个手段)。

在解析过程中,我们要进行一系列流程,才能得到我们最终的渲染成果 --- 网页。

按照渲染的时间顺序,流水线可以分为下述几个阶段

构建DOM树、样式计算(构建CSS Rule Tree)、布局阶段、分层、绘制、分块、光栅化和合成。

构建DOM树

渲染进程主线程的HTML解析器解析HTML并构建出结构化的树状数据结构DOM树。具体会经历如下几个步骤:

  1. Conversion(转换):浏览器从网络或磁盘读取HTML文件原始字节,根据指定的文件编码将字节转换为字符。
  2. Tokenizing(分词): 浏览器根据HTML规范将字符串转换为不同的标记,Token中会标识出当前Token是 startTag 还是 endTag 还是 Character 等信息。

Chrome总共定义了7种标签类型

enum TokenType {
    Uninitialized,
    DOCTYPE,
    StartTag,
    EndTag,
    Comment,
    Character,
    EndOfFile
};

对于 Token 这是编译原理中的一个术语,它表示最小的有意义的单元。对于一个标准的标签,将会被拆分为多个 Token。例如:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
</head>
<body>
    <div>
        <h1 class="title">demo</h1>
        <input value="hello">
     </div>
</body>
</html>
tagName: html  |type: DOCTYPE   |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: html  |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: head  |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n    "
tagName: meta  |type: startTag  |attr:charset=utf-8 |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: head  |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: body  |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n    "
tagName: div   |type: startTag  |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n        "
tagName: h1    |type: startTag  |attr:class=title   |text: "
tagName:       |type: Character |attr:              |text: demo"
tagName: h1    |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n        "
tagName: input |type: startTag  |attr:value=hello   |text: "
tagName:       |type: Character |attr:              |text: \n    "
tagName: div   |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text:     \n"
tagName: body  |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName: html  |type: EndTag    |attr:              |text: "
tagName:       |type: Character |attr:              |text: \n"
tagName:       |type: EndOfFile |attr:              |text: "

大佬文章里面的数据,借花献佛。 zhuanlan.zhihu.com/p/24911872

  1. Lexing(语法分析):上一步产生的标记将被转换为对象,这些对象包含了HTML语法的各种信息,如属性、属性值、文本等。
  2. DOM construction(DOM构造):因为HTML标记定义了不同标签之间的关系,上一步产生的对象会链接在一个树状数据结构中,以标识父子、兄弟关系。

构建DOM的流程如下图

DOM树描述了文档的内容,html元素是第一个标签也是文档树的根节点。树反映了不同标记之间的关系和层次结构。嵌套在其他标记中的标记是子节点。DOM节点的数量越多,构建DOM树所需的时间就越长。

预加载扫描器

一个网页通常会使用多个外部资源,如图片、JavaScript、CSS、字体等。主线程在解析 DOM 的过程中遇到这些资源后会一一请求。为了加速渲染流程,会有一个叫做预加载扫描器(preload scanner)线程并发运行。如果 HTML 中存在 img 或 link 之类的内容,则预加载扫描器会查看 HTML parser 生成的标记,并发送请求到浏览器进程的网络线程获取这些资源。

预加载扫描器的作用是推测性,他会在主要HTML解析器发现之前检查原始标记,以便找到要适时获取的资源。

CSS Rule Tree 构建(样式计算)

样式计算的流程可以分为三个阶段: 构建 StyleSheets , 属性值标准化,计算节点样式

和html文本一样,浏览器并不能直接理解这些纯文本的CSS样式,所以当渲染引擎接收到CSS文本时,会执行一个转换操作,将CSS文本转换为浏览器可以理解的结构 --- styleSheets

在浏览器控制台中输入 document.styleSheets 我们就会见到它。

渲染引擎会把获取到的CSS文本全部转换为styleSheets结构中的数据,并且该结构同时具备了查询和修改功能,这会为后面的样式操作提供基础。

在将CSS文本转化为styleSheets结构之后,那么就需要对其进行属性值的标准化操作。在我们写样式的时候会有一些emrem以及redblue 这样的一些属性,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值。

在将样式转换为标准的计算值之后,就需要计算DOM树中每个节点的样式属性了。

在计算DOM树中每个节点的样式属性时,我们就会涉及到CSS的继承规则和层叠规则。

层叠样式表

层叠是一个定义了如何合并来自多个源的属性值的算法。我们知道一个元素可以拥有来自不同源的CSS声明,这里的源可能是

  • 浏览器默认的 UserAgent 样式表
  • 通过 link 引用的外部的CSS 文件
  • 标记内的CSS
  • 元素的style属性内嵌的CSS

当不同的规则都应用于同一个元素时,就会产生冲突,层叠规则定义了产生冲突时应该应用的规则,如果来源相同,则会根据层叠样式的优先级,层叠顺序决定到底使用那个值。样式权重计算这里不加赘述。

在层叠权重计算时,其中一层是继承属性,这是由继承规则定义的。

继承规则

一些CSS属性值会默认继承其父元素设置的值,比如 字体、颜色等属性会被继承。也可以理解为CSS继承就是每个DOM节点都包含父节点的样式。举个例子看一下

示例来源于: 《浏览器工作原理与实践》

body { font-size: 20px }
p {color:blue;}
span  {display: none}
div {font-weight: bold;color:red}
div  p {color:green;}

这张样式表最终应用到DOM节点的效果如下图所示

我们可以看到当前的节点都继承了body节点都字号样式。

理解了层叠和继承规则后,回到节点样式的计算流程,这个阶段渲染引擎会遵循 CSS 的层叠和继承规则,计算出 DOM 节点中每个元素的具体样式,最后生成 ComputedStyle,我们可以打开浏览器控制台选择 Element 下的 Computed 标签,查看每个节点的计算样式。

完成 DOM Tree 构建和样式计算后,渲染引擎接下来会进入布局阶段

布局阶段

目前我们有了DOM树和DOM树中元素的样式,但这不足以显示我们的页面,因为我们还不知道DOM元素的几何位置信息。那么接下来就需要计算出DOM树中可见元素的几何位置,这个阶段被称为布局。

在布局阶段,我们需要完成两个任务: 创建布局树(另类的渲染树)和布局计算

HTML解析器构建的 DOM Tree 中包含了一些特殊的节点,比如 head 标签, display 属性为 none 的元素等,它们是不需要被渲染到屏幕上的,所以渲染引擎会额外构建一颗只包含可见元素的布局树

图出自《浏览器工作原理与实践》

从上图可以看出,DOM树中所有不可见的节点都没有包含到布局树中

为了构建布局树,浏览器大体上完成了下面这些工作

  • 遍历 DOM 树中的所有可见节点,并把这些节点加到布局树中
  • 而不可见的节点会被布局树忽略掉,如 head 标签下面的全部内容,再比如 body.p.span 这个元素,因为它的属性包含 display:none ,所以这个元素也没有被包进布局树。

在构建布局树,遍历DOM节点的同时,会进行节点几何坐标位置的计算,也就是布局计算,最后这些信息会被保存在“布局树(LayoutTree)”中。

有了布局树之后,我们依旧不能开始绘制,因为页面往往还包含很多复杂的效果,比如常见的 3D 变换、页面滚动,层叠上下文的z轴排序等等,为了实现这些效果,渲染引擎会进行分层

分层

为了更加方便地实现一些复杂的效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一颗对应的图层树。

要想直观地理解什么是图层,你可以打开 Chrome 的开发者工具,选择 Layers 标签,就可以看到可视化页面分层情况。

所以浏览器的页面被分为了很多图层,这些图层叠加后合成了最终的页面。

这些图层往往和布局树节点之间有一些具体的关系。

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层

那么满足什么条件,渲染引擎才会为特定的节点创建新的图层?

  1. 拥有层叠上下文属性的元素会被提升为单独的一层

层叠上下文定义了元素在z轴上的排列方式,简单来讲

  • 元素在z轴上会按“层叠等级”进行层叠。
  • 具有层叠上下文的元素会优先于普通元素进行层叠。
  • 同一个层叠上下文中,层叠等级相同的元素,按它们在文档流中出现的顺序进行层叠。
  • 同一个层叠上下文中,可以通过 z-index 调整元素的层叠等级。

那些元素会创建层叠上下文呢?

  • 文档根元素
  • position 为 absolute 或 relative 且 z-index 不为 auto 的元素。
  • position 为 fixed 或 sticky 的元素
  • flex 容器内,z-index 不为 auto 的子元素
  • grid 容器内,z-index 不为 auto 的子元素
  • opacity 属性值小于 1 的元素
  • transform、filter、clip-path、perspective 值不为 none 的元素
  • will-change 设定了任一属性
  1. 需要剪裁(clip)的地方会被创建为图层
<style>
    #app {
        /* width: 200px; */
        overflow: auto;
    }
    p {
        white-space: nowrap;
    }
</style>
<body>
    <div id="app">
        <p>需要剪裁(clip)的地方会被创建为图层</p>
    </div>
</body>

这里我们做个对照实验,首先,我们在不触发裁剪时,#app归属于了文档的根节点。

当我们触发了裁剪时,#app 就会创建出一个新的图层。

渲染引擎通过分层完成了图层树(LayerTree)的构建,接下来就可以进行图层绘制。

图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制。渲染引擎会遍历 LayerTree, 为每一个图层生成一个绘制指令列表。

我们可以打开 控制台的 Layers 标签, 选择 document 层,去查看当前dom节点的绘制列表

前面我们介绍了DOM树构建、样式计算、布局、分层、绘制指令生成等流程基本都在渲染引擎主线程内完成,而实际上的图层绘制是由渲染引擎中的合成线程来完成的,绘制指令列表准备好后,主线程会把列表提交给合成线程,然后执行栅格化流程。

栅格化

在渲染进程中,主线程和合成线程的关系如下图所示。

当图层绘制列表准备好之后,主线程会把该绘制列表提交给合成线程,那么接下来合成线程是怎么工作的?

当合成线程接受到指令列表后,首先会将图层划分为图块(tile),一个图块大小一般为 256 * 256或 512 * 512,然后根据图块来生成位图,生成位图的操作由栅格化线程完成。所谓栅格化,就是指将图块转换为位图。而图块是栅格化执行的最小单位。

为什么不直接渲染整个图层?

因为在有些情况下,有的图层可以很大,不可能在页面中进行完全展示,浏览器视口(屏幕的可视区域)只能看到图层的一部分,在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销。优先绘制视口附近的位图,可以加速图层的绘制。

渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的。

通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。

合成和显示

一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。

浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

当执行完成这一步之后,我们就可能看到浏览器对页面进行了展示。那么来看一下整个的渲染流程吧。

结语

本文为 学习 《浏览器工作原理与实践》的输出,感谢以下参考资料的大佬们。

参考资料

developer.mozilla.org/zh-CN/docs/…

time.geekbang.org/column/arti…

zhuanlan.zhihu.com/p/24911872

febook.hzfe.org/awesome-int…

github.com/campcc/blog…