深入了解现代网络浏览器(第 3 部分)

Mariko Kosaka

渲染程序的内部运作

这是该系列博文的第 3 部分,共 4 部分,介绍浏览器的工作原理。我们之前介绍了多进程架构导航流程。在本文中,我们将了解渲染程序内部会发生什么。

渲染程序涉及 Web 性能的许多方面。由于渲染程序中有很多操作,因此本文仅提供一个概览。如果您想深入了解,请参阅 “Web 基础知识”的“效果”部分,其中提供了更多资源。

渲染程序处理 Web 内容

渲染程序负责处理标签页中发生的所有事情。在渲染程序进程中,主线程会处理您发送给用户的大部分代码。如果您使用的是 Web Worker 或 Service Worker,有时 worker 线程会处理 JavaScript 的某些部分。合成器和光栅线程也会在渲染程序内运行,以高效顺畅地渲染网页。

渲染程序的核心工作是将 HTML、CSS 和 JavaScript 转换为用户可以与之互动的网页。

渲染程序进程
图 1:包含主线程、工作器线程、合成器线程和光栅线程的渲染程序

解析

DOM 的构建

当渲染程序进程收到导航的提交消息并开始接收 HTML 数据时,主线程会开始解析文本字符串 (HTML) 并将其转换为 Document Object Model (DOM)。

DOM 是浏览器对网页的内部表示,也是 Web 开发者可以通过 JavaScript 与之互动的数据结构和 API。

将 HTML 文档解析为 DOM 由 HTML 标准定义。您可能已经注意到,向浏览器提交 HTML 永远不会抛出错误。例如,缺少闭合 </p> 标记的 HTML 是有效的。系统会将错误的标记(例如 Hi! <b>I'm <i>Chrome</b>!</i>,即 b 标记在 i 标记之前关闭)视为您编写了 Hi! <b>I'm <i>Chrome</i></b><i>!</i>。这是因为 HTML 规范旨在妥善处理这些错误。如果您想知道这些操作是如何完成的,可以参阅 HTML 规范的“An introduction to error handling and strange cases in the parser”(对解析器中的错误处理和异常情况的简介)部分。

子资源加载

网站通常会使用图片、CSS 和 JavaScript 等外部资源。这些文件需要从网络或缓存加载。主线程可以在解析构建 DOM 时找到它们时逐个请求它们,但为了加快速度,“预加载扫描器”会并发运行。如果 HTML 文档中存在 <img><link> 等内容,预加载扫描器会查看 HTML 解析器生成的令牌,并向浏览器进程中的网络线程发送请求。

DOM
图 2:主线程解析 HTML 并构建 DOM 树

JavaScript 可能会阻止解析

当 HTML 解析器找到 <script> 标记时,它会暂停解析 HTML 文档,并必须加载、解析和执行 JavaScript 代码。原因是 JavaScript 可以使用 document.write() 等内容更改文档的形状,而 document.write() 会更改整个 DOM 结构(HTML 规范中的解析模型概览中有个很棒的图表)。因此,HTML 解析器必须等待 JavaScript 运行,然后才能继续解析 HTML 文档。如果您想了解 JavaScript 执行过程中会发生什么,请参阅 V8 团队就此发表的演讲和博文

向浏览器提示您希望如何加载资源

Web 开发者可以通过多种方式向浏览器发送提示,以便顺利加载资源。如果您的 JavaScript 不使用 document.write(),您可以向 <script> 标记添加 asyncdefer 属性。然后,浏览器会异步加载和运行 JavaScript 代码,并且不会阻塞解析。您也可以使用 JavaScript 模块(如果适用)。<link rel="preload"> 是一种方式,用于告知浏览器当前导航确实需要该资源,并且您希望尽快下载。如需了解详情,请参阅资源优先级 - 让浏览器为您提供帮助

样式计算

仅凭 DOM 并不足以了解网页的外观,因为我们可以在 CSS 中为网页元素设置样式。主线程会解析 CSS 并确定每个 DOM 节点的计算样式。这类信息会说明系统根据 CSS 选择器将哪种样式应用于每个元素。您可以在开发者工具的 computed 部分中查看此信息。

计算样式
图 3:主线程解析 CSS 以添加计算样式

即使您未提供任何 CSS,每个 DOM 节点也都有计算样式。<h1> 标记的显示大小大于 <h2> 标记,并且为每个元素定义了边距。这是因为浏览器具有默认样式表。如果您想了解 Chrome 的默认 CSS 是什么样的,可以点击此处查看源代码

布局

现在,渲染程序知道文档的结构以及每个节点的样式,但这还不足以呈现网页。假设您正尝试通过电话向朋友描述一幅画。“有一个大红圈和一个小蓝方块”的信息不足以让您的朋友知道画作具体是什么样子。

人传真机游戏
图 4:一个人站在一幅画前,电话线连接到另一个人

布局是查找元素几何图形的过程。主线程会遍历 DOM 和计算样式,并创建包含 x、y 坐标和边界框大小等信息的布局树。布局树的结构可能与 DOM 树类似,但它仅包含与网页上显示的内容相关的信息。如果应用了 display: none,则该元素不属于布局树(不过,具有 visibility: hidden 的元素属于布局树)。同样,如果应用了包含 p::before{content:"Hi!"} 等内容的伪元素,即使该元素不在 DOM 中,也会包含在布局树中。

布局
图 5:主线程遍历 DOM 树,并使用计算的样式生成布局树
图 6:由于换行更改而移动的段落的盒布局

确定页面布局是一项具有挑战性的任务。即使是最简单的页面布局(例如从上到下排列的块流),也必须考虑字体的大小和换行位置,因为这些因素会影响段落的大小和形状;而这又会影响下一段落的位置。

CSS 可以使元素浮动到一侧、遮盖溢出项,以及更改书写方向。您可以想象,此布局阶段是一项艰巨的任务。在 Chrome 中,有一整支工程师团队负责布局。如果您想详细了解他们的工作,可以观看 BlinkOn 大会上的几场演讲,这些演讲非常有趣。

颜料

绘图游戏
图 7:一名男子站在画布前,手持画笔,思考自己应该先画圆还是先画方形

仅有 DOM、样式和布局还不足以呈现网页。假设您尝试再现一幅画作。您知道元素的大小、形状和位置,但仍需要判断绘制它们的顺序。

例如,系统可能会为某些元素设置 z-index,在这种情况下,按 HTML 中编写的元素顺序绘制将导致渲染错误。

z-index 失败
图 8:网页元素按 HTML 标记的顺序显示,导致因未考虑 z-index 而呈现错误的图片

在此绘制步骤中,主线程会遍历布局树以创建绘制记录。绘制记录是对绘制过程的说明,例如“先绘制背景,然后绘制文本,最后绘制矩形”。如果您使用 JavaScript 在 <canvas> 元素上绘制过,则可能对此过程很熟悉。

绘制记录
图 9:主线程遍历布局树并生成绘制记录

更新渲染流水线的成本很高

图 10:DOM+样式、布局和绘制树的生成顺序

在渲染流水线中,最重要的一点是,在每个步骤中,都会使用上一步操作的结果来创建新数据。例如,如果布局树发生了变化,则需要为文档的受影响部分重新生成绘制顺序。

如果您要为元素添加动画效果,则浏览器必须在每一帧之间运行这些操作。我们的大多数显示屏每秒刷新 60 次(60 fps);如果您在每一帧中在屏幕上移动内容,动画在人眼中看起来会很流畅。但是,如果动画跳过了中间的帧,则网页会出现卡顿。

由于缺少帧而导致的 jage 卡顿
图 11:时间轴上的动画帧

即使您的渲染操作能够跟上屏幕刷新速度,这些计算也会在主线程上运行,这意味着当您的应用运行 JavaScript 时,这些计算可能会被阻塞。

JavaScript 导致的 jage jank
图 12:时间轴上的动画帧,其中一个帧被 JavaScript 阻塞

您可以将 JavaScript 操作划分为小块,并使用 requestAnimationFrame() 安排在每个帧中运行。如需详细了解此主题,请参阅优化 JavaScript 执行。您还可以在 Web Worker 中运行 JavaScript,以避免阻塞主线程。

请求动画帧
图 13:在包含动画帧的时间轴上运行的较小 JavaScript 代码块

合成

您会如何绘制页面?

图 14:朴素光栅化流程的动画

现在,浏览器知道了文档的结构、每个元素的样式、网页的几何图形和绘制顺序,那么它如何绘制网页?将此信息转换为屏幕上的像素称为光栅化。

处理此问题的一种简单方法可能是对视口内的部分进行光栅化处理。如果用户滚动网页,则移动已光栅化的帧,并通过更多光栅化填充缺失的部分。这是 Chrome 首次发布时处理光栅化的方式。不过,现代浏览器会运行一个更复杂的过程,称为合成。

什么是合成

图 15:合成过程的动画

合成是一种将网页的各个部分拆分为图层、单独光栅化这些图层,并在称为合成程序线程的单独线程中将其合成为网页的技术。如果发生滚动,由于图层已光栅化,因此只需合成新帧即可。您也可以通过移动图层并合成新帧,以同样的方式实现动画。

您可以使用 Layers 面板在 DevTools 中查看网站是如何划分为各个图层的。

分层

为了确定哪些元素需要位于哪些层中,主线程会遍历布局树以创建层树(此部分在 DevTools 性能面板中称为“更新层树”)。如果网页的某些部分应为单独的层(例如滑入式侧边菜单),但未获得单独的层,则您可以在 CSS 中使用 will-change 属性向浏览器发出提示。

图层树
图 16:主线程遍历布局树并生成层次结构

您可能会想要为每个元素添加层,但与每帧对网页的某些小部分进行光栅化相比,跨过多层合成可能会导致操作速度变慢,因此衡量应用的渲染性能至关重要。如需详细了解此主题,请参阅仅使用合成器属性并管理图层数量

在主线程之外进行光栅化和合成

创建图层树并确定绘制顺序后,主线程会将这些信息提交给合成器线程。然后,合成程序线程会对每个图层进行光栅化处理。图层可能很大,例如整个网页的长度,因此合成程序线程会将其划分为图块,并将每个图块发送到光栅线程。光栅线程会对每个图块进行光栅化处理,并将其存储在 GPU 显存中。

光栅
图 17:创建图块位图并将其发送到 GPU 的光栅线程

合成器线程可以优先处理不同的光栅线程,以便先光栅化视口内(或附近)的内容。图层还具有适用于不同分辨率的多个平铺,以处理放大操作等操作。

拉伸功能处理完图块后,合成器线程会收集称为绘制四边形的图块信息,以创建合成器帧

绘制四边形 包含功能块在内存中的位置以及考虑页面合成功能块在页面中的绘制位置等信息。
合成器帧 一组表示页面帧的绘制四边形。

然后,通过 IPC 将合成器帧提交给浏览器进程。此时,可以从界面线程添加另一个 compositor 帧(针对浏览器界面更改),也可以从其他渲染程序添加另一个 compositor 帧(针对扩展程序)。这些合成器帧会发送到 GPU,以便在屏幕上显示。如果收到滚动事件,合成器线程会创建另一个合成器帧,以发送到 GPU。

composit
图 18:创建合成帧的 compositor 线程。帧发送到浏览器进程,然后发送到 GPU

合成的好处在于,它无需涉及主线程即可完成。合成器线程无需等待样式计算或 JavaScript 执行。因此,仅合成动画被认为是实现流畅性能的最佳方式。如果需要重新计算布局或绘制,则必须涉及主线程。

小结

在本博文中,我们介绍了从解析到合成渲染流水线。希望您现在可以进一步了解网站性能优化。

在本系列的下一篇也是最后一篇博文中,我们将更详细地介绍 compositor 线程,并了解在收到 mouse moveclick 等用户输入时会发生什么。

您喜欢这篇文章吗?如果您对日后发布的博文有任何疑问或建议,欢迎在下方的评论区留言,或在 Twitter 上通过 @kosamari 与我联系。

下一步:输入即将进入合成器