无意间在 Google Developer 上看到的文章,这是这个系列博客的 第三部分,主要是研究渲染进程所做的事情。渲染进程涉及到 Web 性能的很多方面,这里只是概述,如果你想深入了解,可以去 Web 基础的性能部分看看。
渲染进程处理网页内容
渲染进程负责处理标页内的所有事情。其中,主线程负责处理大部分代码。少部分的代码可能会由工作线程处理(比如 Service Worker 或者 Web Worker)。同时,合成器线程和栅格线程也在渲染进程中运行,负责高效、流畅的呈现页面。
解析数据
构造 DOM 树
渲染进程接收到导航提交的消息后,就开始接收 HTML 数据,主线程就开始解析文本字符串(HTML),并将其转换成 DOM(Document Object Model)。
DOM 是页面在浏览器内部的结构,也是开发人员通过 JavaScript 与之交互的数据结构和 API。
解析 HTML 的规则由 HTML 标准定义。同时 HTML 标准要求兼容错误的写法,如果你对这个感兴趣,可以查看 An introduction to error handling and strange cases in the parser 的 HTML 部分。
子资源加载
一个网站经常会使用一些外部资源,比如 CSS、图片以及 JavaScript 等。这些文件都需要从网络获取或者是从缓存中加载。主线程在解析构建 DOM 时,会发现一个加载一个,但是这样太慢,于是为了加快速度,“预加载扫描器”会同时运行。当在文档中发现有像 或者
的内容时,预加载扫描器会将请求提交给浏览器进程中的网络线程。
JavaScript 可能阻塞解析
如果解析器碰到了 标签,就会暂停解析 HTML 文档,然后开始解析和执行 JavaScript 代码。为什么呢?因为 JavaScript 可能会通过
document.write()
这样的代码修改文档,从而改变 DOM 结构(HTML 标准里有张解析模型的图非常好)。所以 HTML 解析器就必须要停下来执行 JavaScript,然后再继续解析 HTML。如果你对 JavaScript 执行的细节感兴趣,可以看看 V8 团队的分享。
提示浏览器如何加载资源
Web 开发者可以通过多种方式提示浏览器。如果你的 JavaScript 代码不使用 document.write()
,就可以在 标签上添加
async
或者 defer
属性,这样浏览器就会异步加载运行 JavaScript 代码,而不会阻塞解析。如果可以的话,也可以使用 JavaScript 模块。可以使用 告诉浏览器当前导航肯定需要该资源,希望尽快下载。有关信息请参阅资源优先级。
样式计算
光一个 DOM 结构,我们还是不知道页面长啥样子,我们还需要 CSS 来设置页面元素的样式。所以主线程会解析 CSS 来计算每个 DOM 节点长什么样子。基于 CSS 选择器,对每个元素应用相应的样式,这些都可以在 DevTools 中的 computed
中看到。
即便你不提供任何 CSS,每个 DOM 节点都会有样式。比如 显示出来比
大,并且每个元素都有边距。这是因为浏览器具有默认样式,如果你想知道 Chrome 默认的样式,可以到这里看源代码。
布局
现在渲染进程知道了文档的结构和每个节点的样式,但还是不足以渲染页面。想象一下,你给你朋友打电话描述一幅画:“画里有一个大红圈和一个蓝色小方块。”,你的朋友听了你的描述,可能还是一脸懵逼。
布局就是计算出元素之间的几何位置的过程。主线程会遍历 DOM 树和样式,然后构造出一颗布局树,这棵树上的节点都带有 x、y 坐标和边界框大小之类的信息。布局树和 DOM 树的结构类似,但是树上只包含页面可见元素的信息。如果元素被设置了 display: none
,那么布局树就不会包含这个元素(visibility: hidden
的元素会被包含)。同样的,如果一个内容是通过伪类(比如 p::before { content: 'Hi!' }
)添加进来的,那么这个元素会被包含在布局树中,但是 DOM 树中没有。
确定页面如何布局是一项非常难的事情。即使是最简单的布局方式也要考虑字体大小、换行之类的事情,更别说浮动、隐藏溢出、修改文本显示方向等等事情了。在 Chrome 里,有一个专门负责布局的团队,感兴趣的话,可以看看这个分享。
绘制
有了 DOM 结构、样式、布局之后,我们还是不能渲染页面,我们还要解决渲染的顺序问题。比如,有些元素可能设置了 z-index
属性,那么按照 HTML 里面的元素顺序进行渲染就会出错。
所以在这一步,主线程会遍历布局树,并创建绘制记录。绘制记录会记录绘制过程,就像是先画背景,再画文本,最后画矩形。如果你用过 canvas
,那么你可能对这个过程会很熟悉。
更新渲染管道的成本很高
渲染的过程是一个流水线,每个步骤的结果都用于下一个步骤。如果布局树变化了,那么就需要重新为受影响的部分生成绘制记录。
如果要给元素设置动画,浏览器就要在每一帧运行这些操作。大多数的显示器屏幕每秒刷新 60 次(60 fps),当每一帧都在变化的时候,人就会觉得动画很流畅,但是,如果中间丢了一些帧就会显得很卡顿。
即便渲染能跟得上屏幕刷新,但动画是在主线程上进行计算,也就是说如果主线程一旦因为执行 JavaScript 代码而被阻塞了,动画也就被卡住了。
你可以将动画涉及的 JavaScript 操作分成小块,并使用 requestAnimationFrame()
调度在每一帧上执行,更多请参考。你也可以在 Web Worker 中运行 JavaScript以避免阻塞主线程。
合成
如何绘制页面?
现在浏览器知道了文档结构、元素的样式、页面的几何关系以及绘制顺序,接下来就该渲染页面了。具体该怎么渲染呢?把上述信息转换成屏幕上的像素叫做栅格化。
最简单的处理方式就是把页面在当前视窗中的部分先转换成像素。如果用户滚动页面,则移动栅格化的画框,填补没有渲染的部分。Chrome 最早就是这么干的,但现代浏览器有更复杂的流程,叫做合成。
合成是将页面的各个部分进行分层,然后分别对其进行栅格化,然后通过单独的线程进行合成的技术。这样的话,当用户滚动页面的时候,因为图层都被栅格化了,所以浏览器只需要合成一个新的帧即可。动画也可以通过移动图层再合成新的帧来实现。
你可以在 DevTools 里通过 Layers 面板查看网站的分层(可以在开发者工具里找到)。
分层
为了找出哪些元素在那个图层,主线程会遍历布局树来创建图层树。如果页面的某些部分是单独的图层(比如滑入式侧边菜单)但是没有拆分出来,你可以用 CSS 里的 will-change
属性来提示浏览器进行拆分。
分层并不是越多越好,层过多可能会造成操作速度变慢,甚至还不如每帧都对页面中的小部分执行一次栅格化快,至于该怎么平衡,可以参考这里。
主线程的栅格化和合成
一旦创建了图层树,并确定了绘制的顺序,主线程就会将信息提交给合成线程。紧接着,合成线程会栅格化每个图层。有的情况下一个图层可能和页面一样长,因此合成线程会将它们划分成图块后发送给栅格线程。栅格线程栅格化每个图块(图块转化为位图),并将它们存到显存中。
合成线程会根据栅格线程不同的优先级处理图块,比如它会优先处理视窗(及附近)的图块。并且图块还具有不同分辨率的图块,以便在用户放大、缩放时使用。
所有的图块都栅格化后,合成线程会收集这些图块的信息(绘制图块)来创建合成帧。
- 绘制图块:包含图块在内存中的地址、页面中的位置等相关信息
- 合成帧:多个绘制图块的集合,绘成了页面的一帧
创建好的合成帧会通过 IPC 提交给浏览器进程。此时,可以从 UI 线程或者其他插件的渲染进程添加另一个合成帧。这些合成帧会被发送到 GPU 进行,最终展示到屏幕上。如果发生了滚动,合成线程会创建另一个合成帧发送给 GPU。
合成的好处就是和主线程无关。合成线程不需要等待样式计算或者 JavaScript 的执行,这也是为什么只需要合成的动画流畅平滑的原因。如果需要再次计算布局或者绘制,就需要涉及到主线程了(这就是为什么要减少重排和重绘)。
后续还会有接下来的最后一篇 - 交互,公众号里有上两篇的内容,欢迎关注、转发、分享支持我。