深入了解现代浏览器之三-渲染

7次阅读

共计 3304 个字符,预计需要花费 9 分钟才能阅读完成。

无意间在 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 时,会发现一个加载一个,但是这样太慢,于是为了加快速度,“预加载扫描器”会同时运行。当在文档中发现有像 <img> 或者 <link> 的内容时,预加载扫描器会将请求提交给浏览器进程中的网络线程。

JavaScript 可能阻塞解析

如果解析器碰到了 <script> 标签,就会暂停解析 HTML 文档,然后开始解析和执行 JavaScript 代码。为什么呢?因为 JavaScript 可能会通过 document.write() 这样的代码修改文档,从而改变 DOM 结构(HTML 标准里有张解析模型的图非常好)。所以 HTML 解析器就必须要停下来执行 JavaScript,然后再继续解析 HTML。如果你对 JavaScript 执行的细节感兴趣,可以看看 V8 团队的分享。

提示浏览器如何加载资源

Web 开发者可以通过多种方式提示浏览器。如果你的 JavaScript 代码不使用 document.write(),就可以在 <script> 标签上添加 async 或者 defer 属性,这样浏览器就会异步加载运行 JavaScript 代码,而不会阻塞解析。如果可以的话,也可以使用 JavaScript 模块。可以使用 <link rel="preload"> 告诉浏览器当前导航肯定需要该资源,希望尽快下载。有关信息请参阅资源优先级。

样式计算

光一个 DOM 结构,我们还是不知道页面长啥样子,我们还需要 CSS 来设置页面元素的样式。所以主线程会解析 CSS 来计算每个 DOM 节点长什么样子。基于 CSS 选择器,对每个元素应用相应的样式,这些都可以在 DevTools 中的 computed 中看到。

即便你不提供任何 CSS,每个 DOM 节点都会有样式。比如 <h1> 显示出来比 <h2> 大,并且每个元素都有边距。这是因为浏览器具有默认样式,如果你想知道 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 的执行,这也是为什么只需要合成的动画流畅平滑的原因。如果需要再次计算布局或者绘制,就需要涉及到主线程了(这就是为什么要减少重排和重绘)。

后续还会有接下来的最后一篇 – 交互,公众号里有上两篇的内容,欢迎关注、转发、分享支持我。

正文完
 0