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

小作幸子

渲染程序进程的内部工作原理

本系列博文共 4 部分,本文将探讨浏览器工作方式,本文为第 3 部分。之前,我们介绍了多进程架构导航流。在本博文中,我们将了解渲染程序进程内部的情况

渲染程序进程会影响网页性能的很多方面。由于渲染程序进程内会进行很多操作,因此本博文只是简要概述。如需深入了解,请参阅“网站开发基础”的“性能”部分,其中提供了更多资源。

渲染程序进程处理网页内容

渲染程序进程负责标签页内发生的一切。在渲染程序进程中,主线程会处理您发送给用户的大多数代码。有时,如果您使用 Web Worker 或 Service Worker,则部分 JavaScript 将由工作器线程处理。合成器和光栅线程也会在渲染程序进程内运行,以便高效、流畅地渲染页面。

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

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

解析

DOM 的构建

当渲染器进程收到导航的提交消息并开始接收 HTML 数据时,主线程开始解析文本字符串 (HTML),并将其转换为一个对象态 (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 process and strange case in parser”(解析器中的错误处理和异常情况简介)部分。

正在加载子资源

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

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

JavaScript 可能会阻止

当 HTML 解析器找到 <script> 标记时,它会暂停解析 HTML 文档,并必须加载、解析并执行 JavaScript 代码。为什么呢?因为 JavaScript 可以使用 document.write() 之类的东西改变文档的形状,而这会改变整个 DOM 结构(HTML 规范中的解析模型概述有一个很不错的图表)。因此,HTML 解析器必须先等待 JavaScript 运行完毕,然后才能继续解析 HTML 文档。如果您想了解 JavaScript 执行过程中会发生什么,V8 团队会对此发表演讲和撰写博文

提示浏览器应如何加载资源

Web 开发者可以通过多种方式向浏览器发送提示,以便妥善加载资源。如果您的 JavaScript 未使用 document.write(),您可以将 asyncdefer 属性添加到 <script> 标记中。然后,浏览器会异步加载并运行 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 中)。

layout
图 5:主线程经过计算的样式并生成布局树的 DOM 树
图 6:因换行而移动的段落的框布局

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

CSS 可以使元素悬浮在一侧、遮盖溢出项以及更改书写方向。可以想象一下,此布局阶段具有一项强大的任务。在 Chrome 中,整个工程师团队都会处理布局问题。如果您想详细了解他们的工作,可以观看 BlinkOn Conference 的几个讲座,而且非常有趣。

颜料

绘画游戏
图 7:一个人拿着画笔坐在画布前,不知道该先画圆形还是方形

拥有 DOM、样式和布局仍然不足以渲染网页。假设您正试图再现一幅画您知道元素的大小、形状和位置,但仍需要判断绘制元素的顺序。

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

Z-index 失败
图 8:页面元素按 HTML 标记的顺序显示,导致渲染的图片不正确,因为未考虑 Z-index

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

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

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

图 10:DOM+样式、布局和绘制树(按生成顺序排列)

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

如果您要为元素添加动画效果,浏览器必须在每一帧之间执行这些操作。大多数显示屏每秒会刷新屏幕 60 次 (60 fps);当每帧在屏幕上移动内容时,动画对人眼来说会很流畅。但是,如果动画在动画中间缺失帧,页面就会出现“卡顿”。

因缺失帧而导致卡顿
图 11:时间轴上的动画帧

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

JavaScript 导致 jage 卡顿
图 12:时间轴上的动画帧,但有一帧被 JavaScript 屏蔽

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

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

合成

您将如何绘制页面?

图 14:简单光栅过程的动画

现在,浏览器已经知道文档的结构、每个元素的样式、页面的几何图形和绘制顺序,接下来该如何绘制页面呢?将这些信息转换为屏幕上的像素就称为光栅化。

或许,一个简单的处理方法就是对视口内的部分进行光栅处理。如果用户滚动页面,则移动光栅帧,并通过更多光栅化来填充缺失部分。这就是 Chrome 首次发布光栅化时的处理方式。不过,现代浏览器运行着一个更复杂的过程,即合成。

什么是合成

图 15:合成过程的动画

合成是一种技术,可将页面的各个部分分离成图层,单独将其光栅化,然后在单独的线程(称为合成器线程)中合成为页面。如果发生滚动,由于图层已经光栅化,您只需合成一个新帧即可。通过移动层和合成新帧,可以采用相同的方式实现动画。

在开发者工具中,您可以使用“图层”面板查看如何将网站划分为多个图层。

分成多个图层

为了找出哪些元素需要位于哪些层,主线程会遍历布局树来创建层树(此部分在开发者工具的“性能”面板中称为“更新层树”)。如果网页中本应成为单独图层的某些部分(例如滑入式侧边菜单)没有显示该部分,那么您可以在 CSS 中使用 will-change 属性来提示浏览器。

层树
图 16:遍历布局树生成层树的主线程

您可能想要为每个元素都赋予层,但与过多的层进行合成可能会导致操作速度比每帧光栅化地对网页的一小部分进行光栅化,因此衡量应用的渲染性能至关重要。如需详细了解该主题,请参阅坚持仅合成器的属性和管理层数

主线程以外的光栅图像和合成图像

图层树创建完毕并确定绘制顺序后,主线程会将该信息提交到合成器线程。然后,合成器线程会光栅化每个图层。图层可能像页面的整个长度一样大,因此合成器线程会将其划分为图块,并将每个图块发送到光栅线程。光栅线程会光栅化每个图块并将其存储在 GPU 内存中。

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

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

将图块进行光栅化后,合成器线程会收集称为“绘制四边形”的图块信息,以创建合成器帧。

绘制四边形 包含功能块在内存中的位置,以及考虑到页面合成的情况下要绘制功能块在页面中的位置等信息。
合成器框架 一组绘制四边形,表示页面一个框架。

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

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

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

小结

在这篇博文中,我们了解了渲染流水线从解析到合成的整个过程。希望您现在能够阅读更多有关网站性能优化的内容。

在本系列的下一篇和最后一篇博文中,我们将更详细地介绍合成器线程,并了解当 mouse moveclick 等用户输入传入时会发生什么。

您喜欢这个帖子吗?如果您对日后发布的博文有任何疑问或建议,欢迎通过下方的评论部分或 Twitter 上的 @kosamari 告诉我们。

下一步:输入来自合成器