[译]深入了解现代web浏览器(三)
本文是根据Mariko Kosaka在谷歌开发者网站上的系列文章https://developer.chrome.com/blog/inside-browser-part3/ 翻译而来,共有四篇,该篇是第三篇。对于其中一些直译出来不太好理解的句子,笔者做了加工处理和提炼。
渲染器进程的内部工作
在前面我们介绍了多进程架构和导航流程,在这篇文章中我们将看看渲染器进程内部发生了什么。
渲染器进程涉及到web性能的许多方面。渲染器进程内部运行的细节非常之多,而本篇文章只是做一个概览;如果你想要深入了解,在这里可以找到更多资源。
渲染器进程处理web内容
渲染器进程负责响应tab页面中发生的所有事情。在渲染器进程中,绝大部分代码都是由主线程处理;如果你使用了web worker或是service worker,则你的Javascript代码是由工作者线程(worker thread)来处理。合成器和光栅化线程也是运行在渲染器进程中的——以此让页面更加高效、流畅地渲染。
渲染器进程的核心工作就是将HTML,CSS和Javascript转换为能够让用户交互的web页面。
渲染器进程内部包括了主线程、工作者线程、合成器线程和光栅化线程
解析
DOM的构建
当渲染器进程接收到导航的提交消息并开始接收HTML数据时,主线程开始解析文本字符串(HTML)并将其转换为文档对象模型(DOM)。
DOM是浏览器对页面的内部表示,也是web开发人员可以通过JavaScript与之交互的数据结构和API。
将HTML文档解析为DOM由HTML标准定义。您可能已经注意到,将HTML提供给浏览器解析永远不会引发错误。例如,缺少</p>
结束标记也是有效的HTML。像Hi!<b>I'm <i>Chrome</b>!</i>
(b标签在i标签之前关闭)这样的错误标记会被视为Hi!<b>I'm <i>Chrome</i></b><i>!</i>
。这是因为HTML规范被设计为是可以优雅地处理这些错误的。如果您对这些事情是如何完成的感到好奇,您可以阅读HTML规范的解析器中的错误处理和奇怪案例简介部分。
子资源加载
一个网页通常还会引用外部资源像图片,CSS和Javascript。这些文件需要从网络或者缓存中加载。主线程会在解析构建DOM的过程中找到它们并一一请求。但为了加快这一过程,会同时运行一个“预加载扫描器”;如果HTML文档中有类似于<img>
或<link>
这样的标签,预加载扫描器会在HTML解析器生成对应的token时找到它并向浏览器进程中的网络线程发送请求,因此这些子资源的加载不会影响到DOM树的解析构建(下面会讲到Javascript资源在大多数情况下是会影响到DOM解析构建的)。
主线程解析HTML并构建DOM树
Javascript会阻塞解析
当HTML解析器遇到一个<script>
标签时,它就不得不停止对HTML文档的解析,转而去加载、解析和执行Javascript代码。为什么呢?因为Javascript可以只用诸如document.write()
的代码来改变整个DOM的结构(HTML标准中的overview of the parsing model小节处的示意图很好地描述了这一过程)。这就是为什么HTML解析器不得不等待Javascript运行完后才能继续解析HTML文档。如果你对Javascript执行中发生的事情感兴趣,可以阅读V8团队的JavaScript engine fundamentals
提示浏览器如何加载资源
web开发人员有多种方式来给浏览器提示,以便更好地加载资源。如果你的Javascript代码中没有使用诸如document.write()
这样会改变DOM结构的API,可以添加async
或者defer
属性到<script>
标签中;然后浏览器会异步地加载和运行该Javascript代码,不会阻塞HTML解析。如果合适地话,你也可以使用Javascript模块。
样式计算
拥有DOM不足以知道页面会是什么样子,因为我们还会在CSS中设置页面元素的样式。主线程解析CSS并确定每个DOM节点的计算样式;这里做的事情主要就是关于如何根据CSS选择器为每个元素计算正确的样式信息。你可以在Devtools中的computed(计算样式)部分查看某个元素上应用的所有样式(位于Elements板块的右侧)。
主线程解析CSS并添加计算样式
即使你不提供任何的CSS,每个DOM节点也会有计算样式。例如h1
标签就比h2
标签显示的更大、每个元素都会定义有外边距margin
。这是因为浏览器有一个默认样式表。如果你想知道Chrome的默认CSS是什么样的,可以查看这里的源代码。
布局
现在渲染器进程知道了HTML文档的结构以及每个节点的样式,但这还不足以渲染出一个页面。想象一下,你正在通过电话向你的朋友描述一幅画:“这里有一个红色的大圆形和一个蓝色的小正方形”,这样并没有足够的信息让你的朋友确切地知道这幅画是什么样的。
一个人站在一幅画的前面,与另一个人通话
布局是一个获取元素几何信息的过程。主线程遍历DOM树和计算样式(computed style),创建出包含xy坐标和边界框大小等信息的布局树。布局树和DOM树的结构类似,但它只包含页面上可见内容的相关信息;如果应用了display: none
,那么该元素就不会成为布局树的一部分(然而应用visibility: hidden
的元素是会在布局树中的,详情可见这里)。类似地,如果应用了诸如p::before{content: "Hi!"}
这样的伪类,即使其生成的内容不在DOM中,仍然会被包含在布局树种。
主线程使用计算样式遍历DOM树并生成布局树
确定一个页面的布局是一项非常有挑战性的任务。即使是从上到下的块(block)流这种简单的布局,也必须要考虑字体的大小以及在哪里换行,因为这些会影响到段落的大小和形状,然后影响到下一行的段落会在哪里。
段落因为换行而移动的盒布局
CSS可以使元素浮动到一侧、掩盖移除的内容,亦或是改变书写方向;可以想象,布局阶段的任务非常艰巨。在Chrome中,有一整个工程师团队专门负责布局的工作。如果你想了解这块工作的细节,可以观察BlinkOn Conference上一些被记录下来且非常有趣的演讲。
绘制
拥有DOM,样式和布局还是不足以渲染出页面。假设你正在复现一幅画,你知道了元素的大小,形状和位置,但你还需要决定绘制它们的顺序。
一个人拿着笔刷站在画布前,想知道该先画圆还是先画正方形
比如,可能为某些元素设置了z-index
属性;在这种情况下,按照元素在HTML中的顺序来绘制将会导致错误的渲染:
页面元素按照HTML标签的顺序出现导致了错误的渲染,因为没有把z-index考虑进去
在此绘制步骤中,主线程遍历布局树并创建绘制记录。绘制记录是对“先画背景,再是文字,再然后是矩形”这样的绘画过程的记录。如果你使用过Javascript在canvas
元素上绘制,你应该会对此过程感到熟悉。
主线程遍历布局树并创建绘制记录
更新渲染管线(pipeline)的成本很高
在渲染管线中需要掌握的最重要的一点是,在每一步骤中,利用上一次操作的结果来创建新的数据。例如,如果布局树中的某些内容发生了变化,则需要为文档中受影响的部分重新生成绘制顺序。
DOM+样式,布局树和绘制记录的生成顺序
如果你为元素设置了动画,那么浏览器将会在每帧之间执行上述的操作。我们大多数的显示器都是一秒钟刷新60次(60fps)。当你在每一帧中都有在屏幕上移动物体的时候,该动画对于人眼来说会显得很平滑。但是,如果动画在某些帧中错失了的话,页面就会表现得"janky"(美式俚语,表示质量不好不可靠的意思)。
在时间轴上的动画帧
即使你的渲染操作跟得上屏幕的刷新频率,但由于这些计算是发生在主线程上的,这就意味着:它会被你的运行中的Javascript给阻塞住(我们在前面提到过,Javascript也是运行在主线程上的):
时间轴上的动画帧,但其中好几帧都被Javascript阻塞了
你可以将Javascript的执行拆分为小块,利用requestAnimationFrame()
这一API来安排它们运行在每一帧中。有关此主题的更多信息,请看Optimize JavaScript Execution。你还可以通过在Web worker中运行Javascript来避免主线程的阻塞。
在带有动画帧的时间轴上运行更小的Javascript块
合成
你会怎么画一个页面
现在浏览器知道了文档的结构,每个元素的样式,页面的几何信息以及绘制顺序,接下来它会如何画出这个页面呢?将这些信息转换为屏幕上的像素的操作被称为光栅化。
一种简单的光栅化过程的示意动画
处理这个问题的一种简单直接的办法就是——只光栅化视窗内的部分。如果用户滚动了页面,则移动已光栅化过的帧,接着光栅化缺失的部分。这是Chrome最开始发布时所采用的处理方法。然而,现代浏览器的光栅化过程更为复杂,被称为——合成。
什么是合成
合成是一项将页面各个部分分成多个层,再将它们单独地进行光栅化并最终在合成线程中合成为一个完整页面的技术。如果发生了滚动,由于各个图层都已经光栅化了,因此需要做的只有将它们合成为一个新的帧。可以通过相同的方式,即移动图层然后合成新帧的方式来实现动画。
合成过程的示意动画
你可以通过DevTools中的Layers面板来查看你的页面是如何被划分为多个图层的。
划分图层
为了找出每个元素需要被划入哪个图层,主线程遍历布局树来创建图层树(这部分在DevTools中的performance面板中被称为“更新图层树”)。如果页面中某一部分应该被划为单独的图层(比如侧边滑动栏)却并没有如此,你可以在CSS中使用will-change
属性来提示浏览器这么做。
主线程遍历布局树来生成图层树
你可能会想给每一个元素都设为单独的图层,但是跨多图层进行合成可能会比我们最开始讲述的方法(在每帧中光栅化页面中的某一部分)还要更慢;因此测量你的应用程序的性能是至关重要的。有关该主题的更多信息,请参阅坚持使用仅与合成器相关的属性和管理图层数量。
主线程的光栅与合成
一旦图层树被创建并且绘制顺序确定了,主线程会将这些信息提交至合成线程,然后合成线程光栅化每一个图层。一个图层可能会很大甚至是同整个页面一样大,所以合成线程会将它们分成小块并将每个小块发送到多个光栅线程中。光栅线程光栅化每个小块并将它们存放到GPU内存中。
光栅线程为每个小块创建位图(bitmap)并发送到GPU中
合成线程可以针对不同的光栅线程按优先级排序处理,因此可以让视窗中(或者视窗附近)的内容优先处理。一个图层还会有针对不同分辨率的图块来处理诸如放大之类的动作。
图块在光栅化完成后,合成线程会收集被称为draw quads的图块信息以此来创建合成帧。
draw quads | 包含图块在内存中的位置以及当合成页面时图块应该绘制的地方等信息 |
合成帧 | 用来表示当前页面的一帧画面的draw quads集合 |
合成帧之后会通过IPC提交到浏览器进程。与此同时,也会有来自其他地方的合成帧被提交;例如为了改变浏览器UI的UI线程,或者是其他为插件服务的渲染进程。这些合成帧会被发送到GPU以显示在屏幕上。如果一个滚动事件到来,合成线程会继续创建合成帧再发送给GPU。
合成线程创建合成帧。该帧被发送到浏览器进程然后再到GPU
采用合成方式的好处是可以从主线程中分离开来:合成线程不需要等待样式计算或者Javascript执行(因为在合成线程中单独计算完了图层需要展示的内容和位置),这也是为什么以合成方式来运行动画被认为是获取流畅表现的最佳方式。如果需要重新计算布局或者绘制,则必须涉及到主线程。
总结
在本篇文章中,我们了解到了从解析到合成的整个渲染管线。希望你现在能够阅读更多有关于网页性能优化的内容。
在下篇文章也就是最后一篇文章中,我们将深入合成线程中的更多细节,看看当用户输入产生mouse move
和click
事件时都发生了些什么。