这篇阅读笔记与前几篇不太一样,我希望能够自己提炼出一些知识点,提升一下自己的概括能力。所以本篇不会与前三篇阅读笔记一样是以基本上全文翻译为主。如果有我说的不明白或者有错误的地方欢迎留言提出~。
正文较长,喜欢吃快餐的朋友可以直接跳到总结~
正文开始
这篇阅读笔记主要是对Inside look at modern web browser (part 3)进行一些解读,主要内容是浏览器的渲染器进程(renderer process)的任务与相关原理。
如之前提到过的,渲染器进程主要负责的是一个tab里的所有内容
如果将渲染器进程比作一个函数$g=f(x)$,那么此时的$x$是我们开发人员编写的HTML
、CSS
和javascript
,而输出$g$则是呈现在用户面前的页面。我们这个函数$f$最主要的任务就是将代码转换为一个页面。
还是一次简单的页面请求
当一个页面被访问,浏览器进程中的UI线程和网络线程通力合作处理请求。当网络线程将数据接受完成并且判断数据为可渲染的HTML的时候,就可以将数据通过IPC传送给UI线程找到的或者生成的渲染器进程。此时,渲染器进程的任务就算正式开始了。
DOM树构建
渲染器做的第一件事情就是构建Dom树。当从IPC接收到一次导航已经完成的通知和网络进程传送过来的HTML字符串时,渲染器进程的主线程开始对HTML文本进行解析。为了提升速度,渲染器进程不会等到数据全部传送完再开始解析,而是边接收边解析。
DOM树的解析遵循HTML Standard。得益于HTML良好的设计规范,DOM树构建过程中对于错误的HTML的解析也非常的优雅。例如下面的HTML片段
Hi! <b>I'm <i>Chrome</b>!</i>
可以看到本来i
标签应该在b
标签闭合之前闭合的,在这种情况下,浏览器会将这一段HTML文本解析成这样的结构
Hi! <b>I'm <i>Chrome</i></b><i>!</i>
具体的实现可以查看这一篇文档
An introduction to error handling and strange cases in the parser
子资源预载(Subresource loading)
在我们的页面中,除了我们标签中的一些javascript和CSS,更多的是通过link
和script
引入的样式和脚本文件。除此之外,还有我们页面需要的一些媒体资源。原本的渲染器进程在解析HTML字符串的时候,一旦碰到外部的文件或者资源的时候,就会在渲染器进程主线程发起请求来获取该资源。可是这样子的解析是非常慢的,为了提升速度,现代浏览器提出了preload scanner
来处理这些子资源的请求。
preload scanner
和HTML字符串解析是同步执行的,当preload scanner
捕捉到类似img
或者link
等子元素的标签时,就会调用浏览器进程的网络线程来请求,而不是占用渲染器主线程。这样一来,子资源的获取和HTML字符串的解析就可以有序同步进行了.....吗?
处理Javascript
如果一个网站只有CSS和图片,那么上述的方案已经可以很好的运作了。可是Javascript作为前端三剑客之一,我们自然不能忽略它的影响。
Javascript不同于CSS和其他媒体资源,其拥有一些类似于document.write()
的方法可以改变DOM结构。考虑到其拥有改变DOM树结构的能力,所以其对于HTML解析的影响更是不可忽视。当HTML解析器解析到script
标签时,会暂停HTML的解析,转而去执行标签中的js代码。如果是解析到外部的js文件,那还得先下载下来再去执行其中的js代码。执行完当前的js代码后,才会接着去继续解析HTML。
可以看到,js对于HTML解析的阻塞是非常严重的。这也是为什么有些人会提倡将js文件放到body
标签后引入的原因。作者Mariko Kosaka对此也提出了一些建议,如果你的js脚本中不存在对DOM的一些操作的话,那就给他加上async
或者defer
吧。
样式计算(Style calculation)
只是将HTML转化为Dom树还是远远不够的,毕竟Dom只是页面的骨架,CSS才是页面表现的主要影响因素。所以下一步渲染器进程的主要任务变成了CSS树的解析。渲染器进程结合CSS选择器为DOM中所有的节点计算样式并应用到元素上。
布局(Layout)
渲染器进程已经获取到了完整的DOM树和每个节点的样式信息。但是怎么将他们摆放到页面中呢?计算元素在页面中的“地理”信息这一过程被称之为布局。
渲染器主线程会去遍历DOM树以及每一个节点计算后的样式信息,并以此来生成一个Layout tree(布局树)。布局树主要的内容是一些类似元素的坐标(x,y)、元素的盒模型大小等布局信息。在结构上Layout tree和Dom是很像的,不同的是这Layout tree会挂载在页面中实际参与布局的元素而不会保留任何不参加布局的节点。
例如CSS定义的伪元素p::before{content:"Hi!"}
,虽然没有在Dom中出现,但是因为其参与了页面的布局,所以他也是Layout tree中的一个节点。再例如一个带有display: none
的div
,虽然它本身在Dom树中,但是因为其CSS定义了其不参与布局的属性,所以它不会出现在Layout tree上。
visible: hidden
是参与布局的!会出现在Layout tree上!
绘制(Paint)
现在渲染器已经获得了Dom树、每个节点的样式信息以及Layout树。下一步,还需要考虑元素间的层级关系——到底谁在上,谁在下?
渲染器主线程通过遍历Layout tree来生成一些绘制记录(paint records)。我理解的每一个绘制记录都是对绘制过程的一些顺序的指示。
Paint record is a note of painting process like "background first, then text, then rectangle".
合成(Compositing)
现在渲染器进程已经拥有了页面的结构(Dom)、每个节点的样式、布局信息(Layout tree)以及绘制顺序(Paint records)。下一步就是将这些信息转化为像素点铺在屏幕上,而这一过程被称为光栅化(rasterizing)。
在Chrome刚刚发布的时候,其采用的方案是只光栅化视窗内的页面部分。发生滚动时,持续地去光栅化新的部分。然而现在这种方案已经无法满足需求了,现代的浏览器采用的是更为复杂的流程——合成。
合成的核心是将页面中独立的部分划分到独立的layer中去,再在独立的合成线程中去将每个layer都光栅化并缓存起来合成成完整的页面。并且由于页面已经完整的合成完并缓存起来了,所以滚动时不需要再去重新光栅化了。
那么渲染器是如何判断元素应该被划分到哪一个layer上的呢?与绘制步骤一样,渲染器也是通过遍历Layout tree来判断的。渲染器遍历Layout tree并计算元素的层级关系,最终生成一个Layer tree并以此为标准进行光栅化。
除了渲染器通过遍历Layout tree来划分layer,你也可以通过主动对元素添加will-change
属性来提示渲染器:这是一个单独的元素,请为其单独开一个layer。
至此,渲染器进程已经将页面从HTML、CSS和Javascript代码转化成了用户可以看到的画面了。
考虑更多!
虽然我们已经了解了页面从代码到画面的过程,但是也许我们还可以思考地更多些。
更新渲染流水线的开销
从上面的流程中我们可以发现,整个画面的呈现过程是循序渐进的。归纳下来大体的流程如下
- 解析HTML文本和样式计算
- 布局
- 绘制
- 合成
其中每一步的输入都是上一步的输出。那么如果我对Dom节点有了一些些改变,那么整个计算过程的总输入都改变了,为了更新画面,我们需要重新跑满整个流程(也就是常说的回流重绘)。其中除了HTML解析和样式可以从已有的Dom树和已计算的样式信息中减少绝大部分的运算量,但是剩下的三个部分还是需要从头开始。所以在非必要的情况下,还是建议减少对Dom的修改。
requestAnimationFrame
当我们的页面中有动画时,为了使动画在现在大多数的显示器(60fps)中看起来更加流畅,页面需要每秒钟绘制60帧。在每一帧的绘制中都需要跑一遍上述的流程。如果有某一帧出现了问题丢失了的话,就会出现一些卡顿(Jank)。
整个流程的绘制是在渲染器的主线程中运行的。但是在渲染器主线程中并不只有这一项任务,我们的javascript代码也是在主线程上跑的。这也就导致我们的动画可能会出现因为javascript的运行而被阻塞的情况。
这时就该requestAnimationFrame
方法登场了。requestAnimationFrame
可以将你的javascript操作切片并合理调度到每一帧渲染的间隙中运行,就像这样。
当然,避免被js阻塞渲染的方式还有很多,例如将js代码放到worker里面去运行。
这里的切片思想在React的 Scheduler中有很好的体现,兴趣的小伙伴也可以去了解一下React的Scheduler以及requestIdleCallback这个方法。
Raster and composite off of the main thread
也许我们可以为光栅操作和合成操作单独开几个线程?实际上这种想法已经被投入使用了。
一旦layer tree生成完成,合成线程就会在光栅线程的协助下将各个layer光栅化。一个layer光栅化出来的大家可能差不多有整个页面那么大。为了效率起见,合成线程会将一个layer划分为一个个tile
,并将这些tile
分配给不同的光栅线程进行光栅化。光栅线程会将每一个光栅化后的tile
存储在显存中。
每一个layer的除了原本的分辨率的tile
外还有其他分辨率的tile
以便在用户缩放时有更好的表现。
初次之外,合成器线程还可以对不同线程进行优先级判定,以此保证更靠近视窗的部分可以被更快地光栅化。
当所有的tile
都被光栅化完成后,合成线程将tile
的信息集合起来组成一个draw quads来生成一个合成帧(compositor frame)。draw quad中主要存储着tile
的显存地址以及其在页面中的位置。合成帧则是一个draw quad的集合,包含着满足一个页面所需的draw quad信息。
渲染器进程将这一合成帧丢给浏览器进程,并由浏览器进程调用GPU将一帧显示在屏幕上。这种方式的优点就是合成和光栅再也不用等待样式计算和js的执行啦。不过只有在Dom和样式不变的基础上才能这么操作,因为如果Dom或者样式又了改变,那么上层Layer tree和paint records也需要重新计算,当前的合成线程中使用的Layer tree和paint records也就不适用了,这个时候仍然需要等待主线程传来新的Layer tree才可以继续“光合作用”了。
不过值得一提的是得益于独立的合成和光栅线程,当页面中只有动画而没有其他的Dom操作或者样式变动时可以说是最浏览器丝滑的表现了。
总结
渲染器进程在处理html
、css
和javascript
并将其展示屏幕的过程中,参与的成员主要有
- 渲染器主线程(负责绝大部分任务)
- 浏览器进程的网络线程(负责异步下载子资源)
- 合成线程(负责将layer tree转化为合成帧供GPU使用)
- 光栅线程(负责将合成线程切割的
tile
光栅化)
主要经历的步骤有
- 解析HTML和CSS(得到DOM树和样式)
- 布局(获取元素的位置信息)
- 绘制(获取元素的绘制顺序)
- 合成(将上面步骤的产物转化为合成帧)
- 将合成帧通过浏览器进程传送给GPU并在屏幕中显示。
在过程中扮演关键角色的三棵树:
- Dom
- Layout tree
- Layer tree
得到的一些优化技巧
- 尽量减少Dom节点或者样式的改动,回流重回的开销很大。
- javascript可能会导致页面阻塞,或许可以试试
requestAnimationFrame
- 让页面中的变动元素都是通过CSS的动画属性造成的,或者给元素添加
will-change
属性以最大化发挥合成线程的作用(减少直接使用js去操作Dom或者样式来实现动画!)
到这里,本篇阅读笔记终于走向了尾声。不得不说,深入理解现代浏览器这个这4篇博客,每一篇都让人受益颇丰。每次看完之后都让我对之前的遗留的一些问题得到了解答,有种各种问题都豁然开朗后热泪盈眶的感觉。下一次就是最后一篇了,加油!