窥探现代浏览器架构(三)

前言

本文是笔者对Mario Kosaka写的inside look at modern web browser系列文章的翻译。这里的翻译不是指直译,而是结合我的的理解将做者想表达的意思表达出来,并且会尽可能补充一些相关的内容来帮助你们更好地理解。javascript

渲染进程里面发生的事

这篇文章是探究Chrome内部工做原理的四集系列文章中的第三篇。以前咱们分别探讨了Chrome的多进程架构以及导航的过程都发生了什么。在本篇文章中,咱们将要窥探一下渲染进程在渲染页面的时候具体都发生了什么事情。css

渲染进程会影响到Web性能的不少方面。页面渲染的时候发生的东西实在太多了,本篇文章只能做一个大致的介绍。若是你想要了解更多相关的内容,Web Fundamentals的Performance栏目有不少资源能够查看。html

渲染进程处理页面内容

渲染进程负责标签(tab)内发生的全部事情。在渲染进程里面,主线程(main thread)处理了绝大多数你发送给用户的代码。若是你使用了web worker或者service worker,相关的代码将会由工做线程(worker thread)处理。合成(compositor)以及光栅(raster)线程运行在渲染进程里面用来高效流畅地渲染出页面内容。html5

渲染进程的主要任务是将HTML,CSS,以及JavaScript转变为咱们能够进程交互的网页内容

渲染进程里面有:一个主线程(main thread),几个工做线程(worker threads),一个合成线程(compositor thread)以及一个光栅线程(raster threadjava

解析

构建DOM

前面文章提到,渲染进程在导航结束的时候会收到来自浏览器进程提交导航(commit navigation)的消息,在这以后渲染进程就会开始接收HTML数据,同时主线程也会开始解析接收到的文本数据(text string)并把它转化为一个DOM(Document Object Model)对象git

DOM对象既是浏览器对当前页面的内部表示,也是Web开发人员经过JavaScript与网页进行交互的数据结构以及APIgithub

如何将HTML文档解析为DOM对象是在HTML标准中定义的。不过在你的web开发生涯中,你可能历来没有遇到过浏览器在解析HTML的时候发生错误的情景。这是由于浏览器对HTML的错误容忍度很大。举些例子:若是一个段落缺失了闭合p标签(</p>),这个页面仍是会被当作为有效的HTML来处理;Hi! <b>I'm <i>Chrome</b>!</i> (闭合b标签写在了闭合i标签的前面) ,虽然有语法错误,不过浏览器会把它处理为Hi! <b>I'm <i>Chrome</i></b><i>!</i>。若是你想知道浏览器是如何对这些错误进行容错处理的,能够参考HTML规范里面的An introduction to error handling and strange cases in the parser内容。web

子资源加载

除了HTML文件,网站一般还会使用到一些诸如图片,CSS样式以及JavaScript脚本等子资源。这些文件会从缓存或者网络上获取。主线程会按照在构建DOM树时遇到各个资源的循序一个接着一个地发起网络请求,但是为了提高效率,浏览器会同时运行“预加载扫描”(preload scanner)程序。若是在HTML文档里面存在诸如<img>或者<link>这样的标签,预加载扫描程序会在HTML解析器生成的token里面找到对应要获取的资源,并把这些要获取的资源告诉浏览器进程里面的网络线程。

主线程会解析HTML内容而且构建出DOM树chrome

JavaScript会阻塞HTML的解析过程

当HTML解析器碰到script标签的时候,它会中止HTML文档的解析从而转向JavaScript代码的加载,解析以及执行。为何要这样作呢?由于script标签中的JavaScript可能会使用诸如document.write()这样的代码改变文档流(document)的形状,从而使整个DOM树的结构发生根本性的改变(HTML规范里面的overview of the parsing model部分有很好的示意图)。由于这个缘由,HTML解析器不得不等JavaScript执行完成以后才能继续对HTML文档流的解析工做。若是你想知道JavaScipt的执行过程都发生了什么,V8团队有不少关于这个话题的讨论以及博客canvas

给浏览器一点如何加载资源的提示

Web开发者能够经过不少方式告诉浏览器如何才能更加优雅地加载网页须要用到的资源。若是你的JavaScript不会使用到诸如document.write()的方式去改变文档流的内容的话,你能够为script标签添加一个async或者defer属性来使JavaScript脚本进行异步加载。固然若是能知足到你的需求,你也可使用JavaScript Module。同时<link rel="preload">资源预加载能够用来告诉浏览器这个资源在当前的导航确定会被用到,你想要尽快加载这个资源。更多相关的内容,你可阅读Resource Prioritization - Getting the Browser to Help You这篇文章。

样式计算 - Style calculation

拥有了DOM树咱们还不足以知道页面的外貌,由于咱们一般会为页面的元素设置一些样式。主线程会解析页面的CSS从而肯定每一个DOM节点的计算样式(computed style)。计算样式是主线程根据CSS样式选择器(CSS selectors)计算出的每一个DOM元素应该具有的具体样式,你能够打开devtools来查看每一个DOM节点对应的计算样式。

<p align="center">主线程解析CSS来肯定每一个元素的计算样式</p>

即便你的页面没有设置任何自定义的样式,每一个DOM节点仍是会有一个计算样式属性,这是由于每一个浏览器都有本身的默认样式表。由于这个样式表的存在,页面上的h1标签必定会比h2标签大,并且不一样的标签会有不一样的magin和padding。若是你想知道Chrome的默认样式是长什么样的,你能够直接查看代码

布局 - Layout

前面这些步骤完成以后,渲染进程就已经知道页面的具体文档结构以及每一个节点拥有的样式信息了,但是这些信息仍是不能最终肯定页面的样子。举个例子,假如你如今想经过电话告诉你的朋友你身边的一幅画的内容:“画布上有一个红色的大圆圈和一个蓝色的正方形”,单凭这些信息你的朋友是很难知道这幅画具体是什么样子的,由于他不知道大圆圈和正方形具体在页面的什么位置,是正方形在圆圈前面呢仍是圆圈在正方形的前面。

你站在一幅画面前经过电话告诉你朋友画上的内容

渲染网页也是一样的道理,只知道网站的文档流以及每一个节点的样式是远远不足以渲染出页面内容的,还须要经过布局(layout)来计算出每一个节点的几何信息(geometry)。布局的具体过程是:主线程会遍历刚刚构建的DOM树,根据DOM节点的计算样式计算出一个布局树(layout tree)。布局树上每一个节点会有它在页面上的x,y坐标以及盒子大小(bounding box sizes)的具体信息。布局树长得和先前构建的DOM树差很少,不一样的是这颗树只有那些可见的(visible)节点信息。举个例子,若是一个节点被设置为了display:none,这个节点就是不可见的就不会出如今布局树上面(visibility:hidden的节点会出如今布局树上面,你能够思考一下这是为何)。一样的,若是一个伪元素(pseudo class)节点有诸如p::before{content:"Hi!"}这样的内容,它会出如今布局上,而不存在于DOM树上。

主线程会遍历每一个DOM tree节点的计算样式信息来生成一棵布局树

即便页面的布局十分简单,布局这个过程都是很是复杂的。例如页面就是简单地从上而下展现一个又一个段落,这个过程就很复杂,由于你须要考虑段落中的字体大小以及段落在哪里须要进行换行之类的东西,它们都会影响到段落的大小以及形状,继而影响到接下来段落的布局。

浏览器得考虑段落是否是要换行

若是考虑到CSS的话将会更加复杂,由于CSS是一个很强大的东西,它可让元素悬浮(float)到页面的某一边,还能够遮挡住页面溢出的(overflow)元素,还能够改变内容的书写方向,因此单是想一下你就知道布局这个过程是一个十分艰巨和复杂的任务。对于Chrome浏览器,咱们有一整个负责布局过程的工程师团队。若是你想知道他们工做的具体内容,他们在BlinkOn Conference上面的相关讨论被录制了下来,有时间的话你能够去看一下。

绘画 - Paint

知道了DOM节点以及它的样式和布局其实仍是不足以渲染出页面来的。为何呢?举个例子,假如你如今想对着一幅画画一幅同样的画,你已经知道了画布上每一个元素的大小,形状以及位置,你仍是得思考一下每一个元素的绘画顺序,由于画布上的元素是会互相遮挡的(z-index)。


一我的拿着画笔站在画布前面,在思考着是先画一个圆仍是先画一个正方形

举个例子,若是页面上的某些元素设置了z-index属性,绘制元素的顺序就会影响到页面的正确性。


单纯按照HTML布局的顺序绘制页面的元素是错误的,由于元素的z-index元素没有被考虑到

在绘画这个步骤中,主线程会遍历以前获得的布局树(layout tree)来生成一系列的绘画记录(paint records)。绘画记录是对绘画过程的注释,例如“首先画背景,而后是文本,最后画矩形”。若是你曾经在canvas画布上有使用过JavaScript绘制元素,你可能会觉着这个过程不是很陌生。


主线程遍历布局树来生成绘画记录

高成本的渲染流水线(rendering pipeline)更新

关于渲染流水线有一个十分重要的点就是流水线的每一步都要使用到前一步的结果来生成新的数据,这就意味着若是某一步的内容发生了改变的话,这一步后面全部的步骤都要被从新执行以生成新的记录。举个例子,若是布局树有些东西被改变了,文档上那些被影响到的部分的绘画顺序是要从新生成的。

DOM+Style,布局以及绘画树

若是你的页面元素有动画效果(animating),浏览器就不得不在每一个渲染帧的间隔中经过渲染流水线来更新页面的元素。咱们大多数显示器的刷新频率是一秒钟60次(60fps),若是你在每一个渲染帧的间隔都能经过流水线移动元素,人眼就会看到流畅的动画效果。但是若是流水线更新时间比较久,动画存在丢帧的情况的话,页面看起来就会很“卡顿”。


流水线更新没有遇上屏幕刷新,动画就有点卡

即便你的渲染流水线更新是和屏幕的刷新频率保持一致的,这些更新是运行在主线程上面的,这就意味着它可能被一样运行在主线程上面的JavaScript代码阻塞。


某些动画帧被JavaScript阻塞了

对于这种状况,你能够将要被执行的JavaScript操做拆分为更小的块而后经过requestAnimationFrame这个API把他们放在每一个动画帧中执行。想知道更多关于这方面的信息的话,能够参考Optimize JavaScript Execution。固然你还能够将JavaScript代码放在WebWorkers中执行来避免它们阻塞主线程。


在动画帧上运行一小段JavaScript代码

合成

如何绘制一个页面?

到目前为止,浏览器已经知道了关于页面如下的信息:文档结构,元素的样式,元素的几何信息以及它们的绘画顺序。那么浏览器是如何利用这些信息来绘制出页面来的呢?将以上这些信息转化为显示器的像素的过程叫作光栅化(rasterizing)

可能一个最简单的作法就是只光栅化视口内(viewport)的网页内容。若是用户进行了页面滚动,就移动光栅帧(rastered frame)而且光栅化更多的内容以补上页面缺失的部分。Chrome的第一个版本其实就是这样作的。然而,对于现代的浏览器来讲,它们每每采起一种更加复杂的叫作合成(compositing)的作法。


最简单的光栅化过程

什么是合成

合成是一种将页面分红若干层,而后分别对它们进行光栅化,最后在一个单独的线程 - 合成线程(compositor thread)里面合并成一个页面的技术。当用户滚动页面时,因为页面各个层都已经被光栅化了,浏览器须要作的只是合成一个新的帧来展现滚动后的效果罢了。页面的动画效果实现也是相似,将页面上的层进行移动并构建出一个新的帧便可。

你能够经过Layers panel在DevTools查看你的网站是如何被浏览器分红不一样的层的。


页面合成过程

页面分层

为了肯定哪些元素须要放置在哪一层,主线程须要遍历渲染树来建立一棵层次树(Layer Tree)(在DevTools中这一部分工做叫作“Update Layer Tree”)。若是页面的某些部分应该被放置在一个单独的层上面(滑动菜单)但是却没有的话,你能够经过使用will-change CSS属性来告诉浏览器对其分层。


主线程遍历布局树来生成层次树

你可能会想要给页面上全部的元素一个单独的层,然而当页面的层超过必定的数量后,层的合成操做要比在每一个帧中光栅化页面的一小部分还要慢,所以衡量你应用的渲染性能是十分重要的一件事情。想要获取关于这方面的更多信息,能够参考文章Stick to Compositor-Only Properties and Manage Layer Count

在主线程以外光栅化和合成页面

一旦页面的层次树建立出来而且页面元素的绘制顺序肯定后,主线程就会向合成线程(compositor thread)提交这些信息。而后合成线程就会光栅化页面的每一层。由于页面的一层可能有整个网页那么大,因此合成线程须要将它们切分为一块又一块的小图块(tiles)而后将图块发送给一系列光栅线程(raster threads)。光栅线程会栅格化每一个图块而且把它们存储在GPU的内存中。


光栅线程建立图块的位图并发送给GPU

合成线程能够给不一样的光栅线程赋予不一样的优先级(prioritize),进而使那些在视口中的或者视口附近的页面能够先被光栅化。为了响应用户对页面的放大和缩小操做,页面的图层(layer)会为不一样的清晰度配备不一样的图块。

当图层上面的图块都被栅格化后,合成线程会收集图块上面叫作绘画四边形(draw quads)的信息来构建一个合成帧(compositor frame)。

  • 绘画四边形:包含图块在内存的位置以及图层合成后图块在页面的位置之类的信息。
  • 合成帧:表明页面一个帧的内容的绘制四边形集合

上面的步骤完成以后,合成线程就会经过IPC向浏览器进程(browser process)提交(commit)一个渲染帧。这个时候可能有另一个合成帧被浏览器进程的UI线程(UI thread)提交以改变浏览器的UI。这些合成帧都会被发送给GPU从而展现在屏幕上。若是合成线程收到页面滚动的事件,合成线程会构建另一个合成帧发送给GPU来更新页面。


合成线程构建出合成帧,合成帧会被发送给浏览器进程而后再发送给GPU

合成的好处在于这个过程没有涉及到主线程,因此合成线程不须要等待样式的计算以及JavaScript完成执行。这也就是为何说只经过合成来构建页面动画是构建流畅用户体验的最佳实践的缘由了。若是页面须要被从新布局或者绘制的话,主线程必定会参与进来的。

总结

在这篇文章中,咱们探讨了从解析HTML文件到合成页面整个的渲染流水线。但愿你读完后,能够本身去看一些关于页面性能优化的文章了。

在接下来也是最后一篇本系列的文章中,咱们将要查看合成线程更多的细节,来了解一下当用户在页面移动鼠标(mouse move)以及进行点击(click)的时候浏览器会作些什么事情。

持续关注个人技术动态

我是进击的大葱,关注我和我一块儿进步成独当一面的全栈工程师!

文章首发于:窥探现代浏览器架构(三)

关注个人我的公众号获取个人最新技术推送!

相关文章
相关标签/搜索