深入理解现代浏览器 3: 渲染器进程的工作
目录
译注:本文翻译自 ChromeDeveloper 博客, 原文发表于2018年底, 部分特性可能与目前 Chrome 有所不同。建议有条件的读者直接阅读原文。
本章是该系列4章中的第3章。之前我们研究了多进程架构和导航流程。在此章中, 我们将渲染进程内部发生了什么
渲染进程涉及到web性能的方方面面。由于渲染进程内部做了太多工作, 本章只做一个总体概述,如果你想深挖,the Performance section of Web Fundamentals 里有更多的资源。
渲染进程处理 web 内容
渲染进程负责处理标签内的一切。在渲染进程中, 主线程处理你发送给用户的大部分代码。如果你使用 web worker 或 service worker, 有时部分 JavaScript 代码由 worker 线程处理。合成器(compositor) 和栅格(raster)线程也在渲染进程内运行,以高效流畅地渲染页面。
渲染进程的核心工作是将 HTML,CSS 和 JavaScript转换为可与用户交互的网页。
解析
DOM的构建
当渲染进程接收到导航的提交消息, 并开始接收 HTML 数据时, 主线程开始解析文本字符串(HTML)并将其转换为 DOM(Document Object Model).
DOM 是浏览器对页面的内部表示,也是 Web 开发人员可以通过 JavaScript 与之交互的数据结构和 API。
将 HTML 文档转换为 DOM 的解析行为由 HTML 标准定义。你可能已经注意到了, 向浏览器塞入 HTML 从来不会引发错误。比如缺少 </p>
tag 的HTML是有效的 。像 Hi! <b>I'm <i>Chrome</b>!</i>
(b tag 在 i tag 之前关闭)这样的错误标记会被当作 Hi! <b>I'm <i>Chrome</i></b><i>!</i>
。 这是因为 HTML 规范旨在优雅地处理这些错误。如果你感到好奇, 你可以阅读 "An introduction to error handling and strange cases in the parser" 关于 HTML 的部分。
子资源加载
一个网站通常使用外部资源,如 image, CSS,以及 JavaScript。 这些文件需要从网络或缓存中加载。 构建 DOM时, 主线程 可以 以解析时找到资源的顺序一个一个地请求它们,但为了提速,同时 "预加载扫描器(preload scanner)" 会启动运行。如果 HTML 中有像 <img> <link>
之类的东西,preload scanner 会查看 HTML 解析器生成的 token, 并向 browser 进程中的 network 线程发送请求。
JavaScript 会阻塞解析
当 HTML 解析器发现有 <script>
tag 时, 它将暂停解析 HTML 文档, 并加载、解析、执行 JavaScript代码, 因为 JavaScript 会使用诸如 document.Write()
之类的东西来改变文档的形态, 这会改变整个 DOM的结构( overview of the parsing model 有不错的图示)。这就是为何 HTML 解析器必须要等待 JavaScript运行完成后才能恢复解析 HTML 文档。如果你对 JavaScript 执行时会发生什么感到好奇, 可以看看 V8团队对此的讨论及他们的博客
告诉浏览器你希望如何加载资源
Web开发者有多种方式提示浏览器如何更好地加载资源。如果你的 JavaScript 没有使用 document.Write()
, 你可以在 <script>
tag 中使用 async
或 defer
属性, 这样浏览器会异步加载并执行 JavaScript 代码而不会阻塞解析。如果合适, 你也可以使用 JavaScript module 。 <link rel="preload">
则会告诉浏览器这是当前导航需要的资源, 需要尽快下载。更多的内容可以点击 Resource Prioritization – Getting the Browser to Help You.
样式计算
有了 DOM 并不足以让我们了解页面的样子, 因为我们会在 CSS 中设置页面元素的样式。主线程解析 CSS 并确定每个 DOM 节点的计算样式,这是基于CSS 选择器将哪种样式应用于每个元素的信息。你可以在开发者工具中 computed
部分看到这些信息。
即使你没有提供任何 CSS, 每个 DOM 依然有一个计算样式。 <h1>
tag 会比 <h2>
tag 显示得更大,且每个元素都定义了边距(margins )。这是因为浏览器有一个默认样式表。如果你想了解 Chrome 的默认样式表,你可以在这里查看源码
布局
至此渲染进程知道了文档的结构与每个节点的样式,但这并不足以渲染一个页面。设想你正在通过电话向你的朋友描述一幅画,"这里有个大红圈一个了小蓝块" 并不足以向你的朋友准确的描述这画的样子。
布局是一个寻求元素几何开关的过程。主线程遍历 DOM, 计算样式并创建布局树,其中包含 x, y 坐标及边框大小等信息。布局树与 DOM 树的构造类似,不过它只包含与页面可见内容相关的信息。如果元素使用了 display:none
那么该元素不在布局树中 (而使用 visibility:hidden
的元素在其中)。类似地, 如果内容中使用了 p::before{count:"Hi"}
之类的伪类,即使它不在DOM 中, 它也包含在布局树里.
确定页面的布局是一项极具挑战的任务。即使是最简单的,从下到下的块流的布局,也需要考虑字体字体大小,换行位置等信息, 因为这会影响到段落的大小和形状,进而影响到下一个段落的形状。
CSS 可以使元素涉浮动到一侧,屏蔽溢出项,以及改变书写方向,可以想象布局阶段任务之艰巨。在 Chrome中, 有一整个工程师团队在处理布局。如果你想了解他们工作的细节, few talks from BlinkOn Conference 这个记录很有趣, 值得一看。
绘制
有了 DOM, 样式和布局仍不足以渲染一个页面。假设你想复制一幅画, 你知道了元素的大小、形状和位置, 但你还需要确定绘制它们的顺序。例如,可能为某些元素设置了 z-index 。此种情况下, 以 HTML 中写入的元素顺序来绘制将导致错误的渲染。
在此绘制步骤中, 主线程遍历布局树以创建绘制记录(paint records),绘制记录是对譬如 "先背景,再方案,再矩形" 这样的绘制过程的记录。如果你用过 JavaScript 的 <canvas>
元素, 那么你就会熟悉于此
更新渲染管道(rendering pipline)代价高昂
渲染管道中最重要的一点是, 每一步都会使用前一步操作的结果来生成新的数据。比如,如果布局树发生了变化,那么文档中受影响的部分的绘制顺序都需要变更。
若你为元素设置了动画元素,那么浏览器必须在每帧(frame)之间都运行这些动画操作。大部分的显示器都是每秒刷新60次( 60fps ), 当你让元素在屏幕上的每一帧都运动时,动画就会看上去平滑,而如果动画错过了某一帧,那么页面就会变得"卡顿(janky)"
这些计算都跑在主线程上,这意味着,即使你的渲染操作能跟上屏幕刷新(即跟得上每一帧),当你的程序在跑 JavaScript 时, 它仍可能会被阻塞。
你可以使用 requestAnimationFrame() ,将 JavsScript 操作分成小块并在每一帧上运行。此话题的更多信息请参见 Optimize JavaScript Execution 。也可以 JavaScript in Web Workers 以避免其阻塞主线程。
合成
如何绘制页面
到此, 浏览器知道了文档结构、每个元素的样式,页面的几何位置,以及绘制顺序,那么如何绘制页面呢?将这些信息转换成其在屏幕上的像素,叫做栅格化。
也许一种幼稚的方式是将视窗内的部分栅格化。如果用户用户滚动页面,则移动栅格化的帧,并且栅格化更多部分的帧来填充缺失的那部分。这就是 Chrome 初次发布时处理栅格化的方式。然后现代浏览器运行的过程要复杂得多,我们称之为合成(compositing)。
何为合成
合成,是一种将页面的各个部分分成多个图层,分别栅格化它们,并用一个称之为compositor thread 的线程中将其组合成一个页面。当滚动时,因为图层已经被栅格化了,所需要做的就是合成一个新的帧。动画也可以通过同样移动图层变合成新帧的方式来实现。
你可以使用开发者工具中的 Layers panel 来查看你的网站是如何被分成图层的。
分层
为找出哪些元素需要放在哪些图层中,主线程遍历了布局树并生成了图层树(这部分在开发者工具的性能面板上被称为"更新图层树")。如果确实页面的某部分应该被分成单独的层但实际并没有,你可以使用CSS的 will-cnage
属性提示浏览器。
你可能想为每个元素创建一个图层,但在过多的图层上进行合成可能会导致它比每帧栅格化页面小块更慢,所以这对衡量应用程序的渲染性至关重要。更多相关信息参见 Stick to Compositor-Only Properties and Manage Layer Count
栅格与合成离开主线程
当图层树和绘制顺序确定后, 主线程将这些信息提交给合成器线程, 之后合成器线程将栅格化每个图层。一个图层可能长达一整页,因此合成器线程将它们分割成瓦片(tiles),并将瓦片发送到栅格线程, 栅格线程对每个瓦片栅格化并将其存储在 GPU 内存中。
合成器线程会调整不同的栅格线程, 以便优先栅格化视窗(或其附近)的页面元素。图层还会针对不同的分辨率切分不同的瓦片, 以便处理诸如缩放之类的操作。
对瓦片进行栅格化之后,合成器线程会收集称作"绘制四边形"的瓦片信息以创建合成器帧。
- 绘制四边形 考虑到页面合成,其包含了像 瓦片在内存中的位置以及瓦片要绘制在页面的何处这类信
- 合成器帧 表示一个页面的帧的绘制四边形集合
然后通过 IPC 将合成器帧提交给 browser 进程。此刻,可能为浏览器UI的变化而从 UI 线程,或为插件(extensions)而从另一个渲染进程添加了另一个合成器帧。这些合成器帧会被发往 GPU 以便显示在屏幕上。如果进来一个滚动事件,合成器线程会创建另一个被发送到GPU的合成器帧。
合成的优点, 是它是在不涉及到主线程的情况下完成的。合成器线程无须等待计算样式或 JS 执行。这就是为何只合成动画会被认为是流畅性能之最佳选择。如果重新计算布局或绘制, 那么就不得不涉及主线程。
小节
本章中,我们研究了从解析到合成的渲染管道。希望你现在可以阅读更多关于网站性能优化的相关内容。
下一章,也就是本系列的最后一章,我们会更详细地研究合成器线程,并会看看当诸如 mouse move
和 click
这种输入进来时会发生什么。