引子
作为一名前端工程师,我们必须时刻保持高度的好奇心与求知欲,提出问题并去探索其原因或机制。
于是,我抛出一个问题,浏览器是如何将“没有趣味”的代码变成“五彩缤纷”的网页的?
如下图,我将介绍,这个 rendering 的过程。
参考:docs.google.com/presentatio…
CONTENT
首先,我们要明确一个概念,我们我指的网页内容是什么,content负责的是哪个区域,从架构角度出发,Chromium c++代码库中的 “content”的命名空间所负责的是图片中红框里面的所有内容。即,标签栏,地址栏,导航按钮,菜单这些都不属于网页的内容。
content是Chromium中的通用术语,指的是网页内或者web应用程序前端的所有代码。基本的构建单位是
- 其他,当然还有很多其他渲染内容,比如: canvas,video,webGL...
我们打开网页,按下F12,开启新的世界,我们会发现啥?没错,其实一个真正的网页也只是数千行的html,css,javascipt。而这个源代码其实就是渲染器的输入,不需要什么繁琐的流程,又编译又打包的,我们只需要给它源代码就ok,这种简单性也是网络早期成功的关键。
chrome安全模型中,渲染的过程是在沙盒中进行的。Blink是渲染进程代码的子集,在网页的内容层之下。Blink实现了web平台api和web规范的语义。
于此同时,浏览器进程同时运行了一个被称为“compositor(cc)”的组件,裆盐,这个cc在后面我也会介绍。
ps:前面提到了一个名词,Chromium是个啥东西呢,相比大家作为开发者,chrome肯定是熟悉的不行,毕竟chrome天下无敌也是经常挂在嘴边,我就简单介绍一下Chromium,它是一个由Google主导开发的网页浏览器。Chromium的开发可能早自2006年即开始,设计思想基于简单、高速、稳定、安全等理念,在架构上使用了Apple发展出来的WebKit引擎、Safari的部份源代码与Firefox的成果,并采用Google独家开发出的V8引擎,以提升解译JavaScript的效率(在Chrome中,只有htm渲染采用了webkit的代码,而js上自己搭建了一个牛逼的V8引擎,webkit+v8强强联手,当然这其中也一定有许多的“爱恨纠葛”,我就不在此多言了)。Chromium是Google为发展自家的浏览器Google Chrome(以下简称Chrome)而开启的计划,所以Chromium相当于Chrome的工程版或称实验版(尽管Chrome自身也有β版阶段),新功能会率先在Chromium上实现,待验证后才会应用在Chrome上,故Chrome的功能会相对落后、稳定。Chromium的更新速度很快。
Pixels
在整个渲染管道的另一头,我们必须要使用底层操作系统提供的图形库来将像素放到屏幕上。在今天的大多数平台上,那是一个被称为“OpenGL”的标准化API。在Windows上,有一个额外的转换到DirectX。将来,我们可能会支持新的api,比如Vulkan(最早由柯纳斯组织提出,在2015年游戏开发者大会上发表,最开始把Vulkan称为:下一代OpenGL行动,或者glNext,但是正式宣布之后这些名字就没有再用过了,Vulkan计划提供高性能和低CPU管理负担。。。)。 这些库提供了底层的图形原语,如“纹理”和“着色器”,并让你做到类似于“在这些坐标上绘制一个三角形到虚拟像素缓冲区”这样的事情。
Goals
于是经过上面的话,我们明白了,整个渲染的过程的目标就是将 html/css/js 转换到 正确的opengl调用来调整像素的样式。不仅于此,我们还有另外一个目标,就是我们也需要一个正确的中间数据结构,以便于我们在绘制完成后去有效的去进行更新。
触发更新的原因也有很多:
- js脚本触发更新
- 用户的输入
- 异步加载
- 动画
- 滚动条滚动
- ...
我们将管道分为很多个“生命周期阶段”,生成中输出,我先会去表述工作管道的每个阶段,之后再回到高效更新的概念。
DOM
html文档中加入了一个语义上有意义的层次结构。 比如一个div可以包含两个段落,每个段落都有文本。于是,第一步就是去解析这些标签,来建立一个对象模型,去映射这个结构。
<div>
<p> hello </p>
<p> world </p>
</div>
比如上面这个代码,HTML标签可以嵌套,div中包含了两个p标签,p标签中又包含了文本。于是最后会解析出这样的树形结构,当然,我也建议所有开发者去学习一下 《编译原理》,即使是前端开发。
ok,看上图,有一点计算机基础的人就会发现这是一个树形结构。他的名字是 Document Object Model 文档对象模型(很重要,要考的,嘿嘿),DOM是一个树形结构。
DOM有双重功能:
- 页面的内部表示
- 也是暴露给脚本用于在渲染过程中查询和修改的API接口
javascript引擎(V8)通过一个被称为“binding”的系统,将DOM web api作为实际DOM树的瘦身后的包装器暴露了出来,一个文档里面可能有多个dom树,自定义元素有一个阴影树。主树中的阴影树的孩子会被分配到阴影树的槽中。
FlatTreeTraversal算法会将其转化:(从host到 shadow的根节点,从slot到assigned节点)
STYLE
在构建DOM树之后下一步就是处理css的样式,css选择器的属性声明会 委派到 他所选择的dom元素中去
样式属性是网页作者用来影响dom元素呈现的手段,有数百种样式属性。
此外,确定样式规则选择哪些元素并不简单,有些元素可能被多个规则选择,对于特定的样式属性的声明也有可能存在冲突,比如下面的代码。(复杂性)
div:not(.foo) > p:nth-of-type(2n) {
color: red !important;
}
p {
color: blue;
}
CSS解析器从每个活跃的的样式表构建样式规则的模型。样式表可以位于< Style >元素中,也可以是一个单独加载的资源(styles.css),或者由浏览器提供。
样式解析器从活跃的样式表中提取所有已解析的样式规则,并为每个DOM元素计算每个样式属性的最终值。这些存储在一个名为ComputedStyle的对象中,该对象是从一个样式属性到值的巨大映射。
如上图,Chrome开发者工具会显示任何DOM元素的“计算样式”。这也会暴露给javascript。这些是基于Blink的计算属性对象的。(但是一些属性只是布局数据的增强)
Layout
在构建了DOM并计算了所有样式之后,下一步是确定所有元素的视觉几何形状。对于这个块级元素我们会计算这个矩形的坐标,对应到这个由元素在content区域占有的几何区域。
在最简单的情况下,布局按DOM顺序,垂直降序排列一个个块。我们称之为“block flow”。即 块流(如下图)
文本以及行内元素比如会生成行内盒,行内盒通常在一行中从左向右流动,这叫做“行内流”,inline-flow。RTL语言,如阿拉伯语和希伯来语,反转了行内流的方向,是从右到左的。
布局要求从计算样式使用字体。 布局使用一个名为HarfBuzz的文本形状库来计算每个字形的大小和位置,这决定了文本运行的总体宽度。字体成形必须考虑排版,比如如字距和(ligatures 连体)
布局可以为一个元素计算多种类型的边界矩形。 例如,当存在溢出时,布局将计算边框框的矩形和布局溢出矩形。如果节点的溢出是可滚动的,布局还计算滚动边界并为滚动条保留空间。最常见的可滚动DOM节点是document节点本身(dom树的根)。
更复杂的布局需要table元素或者更加复杂的布局,例如将内容拆分为多个列,或浮动对象位于一侧,内容在其周围流动,或部分东亚语言的文本是垂直运行而不是水平运行。 注意DOM结构和ComputedStyle值是如何输入到布局算法的。每个管道阶段使用的是前几个阶段的结果。
布局操作在一个单独的树上(layout tree),这个树与DOM相关联,layout tree的节点实现yin法,LayoutObject有不同的子类(block,text...),这取决于所需的布局行为。样式的更新阶段也会去构建布局树,布局阶段遍历布局树,计算每个LayoutObject的视觉几何形状,并对每个LayoutObject执行布局。
在一般的情况下,一个DOM节点是对应一个LayoutObject,但有时候,一个DOM节点没有对应的LayoutObject,也有的时候一个LayoutObject没有对应的DOM节点,也有的时候,一个DOM节点可以对应多个LayoutObject。(如果一个容器盒子,里面如果有了一个块级盒子,那么,他里面便只能有块级盒子)
- 如上图,一个容器里面有了 div和span,span是行内元素,div是块级元素,于是,为了保证容器里面只有块级盒子,所以会在span的布局对象外面包裹一层匿名的块级盒子。这就是一个LayoutObject没有对应的DOM节点的情况
- 以及如果一个盒子的计算属性中的display属性是none,那么它也没有对应的LayoutObject。
这个布局树的构造是基于一个叫做FlatTreeTraversal(平面树遍历的算法)。(ps:可以深入研究)
现在的布局引擎正在重写,现在的布局树中保留着上一代的布局对象和 新一代布局对象(NG = next generation),当然在最后,所有的布局对象都会变成新一代的布局对象,上一代的布局对象中保留着输入输出,以及布局的算法,这可以看到整棵树的状态。在下一代中,布局的输入和输出都是有清晰的分隔的,输出是一个不可更改,可缓存的布局结果。
布局结果会指向一个描述物理几何的片段树。
Example
以下举个例子:
左上角是代码,右下角是效果,
那么这个代码会对应什么样子的DOM呢。
<div style="max-width: 100px">
<div style="float: left; padding: 1ex">F</div>
<br>The <b>quick brown</b> fox
<div style="margin: -60px 0 0 80px">jumps</div>
</div>
没错,就是左侧这样的DOM树,那它会对应怎么样的LayoutTree结构呢。
对应的布局树的结果是这样的,这里需要我解释一下,首先这个容器div里面有块级元素div,所以,为了保证容器盒子里面只有块级元素,所以会用一个匿名的块级盒子将其包裹起来,(这里注意,第一个div因为具有浮动属性,所以它也已经不是块级元素了)
下一步是什么,没错,就是上面说到的,他会计算生成一棵片段树,里面会有描述物理几何的信息。
Box (block-flow) at 0,0 100x12
Box (block-flow children-inline) at 0,0 100x54
LineBox at 24.9,0 0x18
Box (floating block-flow children-inline) at 0,0 24.9x34
LineBox at 8,8 8.9x18
Text 'F' at 8,8 8.9x17
Text '\n' at 24.9,0 0x17
LineBox at 24.9,18 67.1x18
Text 'The ' at 24.9,18 28.9x17
Text 'quick' at 53.8,18 38.25x17
LineBox at 0,36 69.5x18
Text 'brown' at 0,36 44.2x17
Text ' fox' at 44.2,36 25.3x17
Box (block-flow children-inline) at 80,-6 20x18
LineBox at 0,0 39.125x18
Text 'jumps' at 0,0 39.125x17
这棵树大概就是这样的,上面会有坐标以及宽高等信息。
Paint绘制
经过上面那一大堆流程,现在我们已经理解了布局对象的几何形状,是时候把他们绘制出来了。Paint这个流程会将绘制的操作记录在显示项(display items)列表中。绘制操作可能类似于“在这些坐标处以这种颜色绘制矩形”。每个布局对象可能有多个显示项(display items),对应其视觉外观的不同部分,如背景、前景、轮廓等。
重要的是要按照正确的顺序绘制元素,以便在重叠时能够正确地堆叠。顺序可以通过style来控制(z-index),如下图,举个例子(注意:z-index只能在定位元素上生效,真的是边写例子,变解决知识漏洞,嘿嘿)。
上面代码对应的DOM树是这样的,但是在Paint绘制的时候,并不是按照DOM顺序,先画黄的,再画绿的,而是按照栈的顺序,如上面效果,是先画的底层的绿色,再去绘制的黄色的方块。
一个元素甚至可以部分在另一个元素的前面,部分在另一个元素的后面。这是因为绘制在多个阶段中运行,每个绘制阶段都对子树进行自己的遍历。每个绘制阶段都是一个堆叠上下文的单独遍历。
比如上面这个例子,虽然蓝色是再绿的后面,但是因为foregrounds是在background之后,于是,文字就在最前面了。
Paint - example
上面是代码和效果,那么它在绘制阶段的display items是什么样的呢
就是首先画 根节点 document,之后对盒子进行绘制,之后绘制前景色。其中文本运行的绘制操作包含一个blob,其中包含每个符号的标识符和偏移量。
结语
这样就讲完了浏览器渲染机制的前半段,下一篇会从光栅化开始讲,整个浏览器渲染机制会有2-3篇文章,也会有其他的延申。
鸡汤(最关键的在这里)
打不倒你的,都会使你变得更强,2021加油。 这句话送给自己也送给所有在外努力漂泊的工程师们。