深入了解现代浏览器-系列三

576 阅读11分钟

渲染器进程内部工作原理

这是关于浏览器内部原理系列的第三部分。我们在前两篇博客中介绍了多进程架构导航流程。在这篇文章中,我们将看看渲染器进程(renderer process)内部发生了什么。

渲染器进程涉及 Web 性能的许多方面,它内部工作流程复杂,这篇文章只是一个总体概述。如果你想深入挖掘,可以参考网络基础知识之性能

渲染器进程处理网页内容

渲染器进程负责 tab 页内发生的所有事情。在渲染器进程中,主线程(main thread)处理你发送给用户的大部分代码(HTML、CSS、JavaScript)。如果使用 Web Worker 或 Service Worker,这部分 JavaScript 将由工作线程(worker threads)处理。合成器(compositor)和光栅(raster)线程也在渲染器进程内运行,以高效、流畅地渲染页面。

渲染器进程的核心工作是将 HTML、CSS 和 JavaScript 转换为用户可以交互的网页。

image.png 图 1:渲染器进程内部有主线程、工作线程、合成器线程和光栅线程

解析(Parsing)

DOM 的构建

当渲染器进程收到导航流程的 commit 消息并开始接收 HTML 数据时,主线程开始解析文本字符串 (HTML) 并将其转换为文档对象模型 (DOM)。

DOM 是浏览器对页面的内部表示,也是 Web 开发人员可以通过 JavaScript 交互的数据结构。

HTML 标准 定义了如何将 HTML 文档解析为 DOM。你可能已经注意到,向浏览器提供 HTML 永远不会引发错误。例如,缺少结束 </p> 标签是有效的 HTML。错误的标记,如:Hi! <b>I'm <i>Chrome</b>!</i>(b 标签在 i 标签之前关闭)被视为 Hi! <b>I'm <i>Chrome</i></b><i>!</i>。这是因为 HTML 规范旨在宽容地对待这些错误。

子资源加载

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

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

JavaScript 可以阻止解析

当 HTML 解析器发现 <script> 标签时,它会暂停对 HTML 文档的解析,等待 JavaScript 代码加载、解析和执行完成后才继续解析 HTML。为什么要这样做呢? 因为 JavaScript 可以使用诸如 document.write() 之类的代码来改变文档的结构,这会改变整个 DOM 结构。 如果你对 JavaScript 执行中发生的事情感到好奇,V8 团队有关于此的演讲和博客文章

提示浏览器如何加载资源

Web 开发者可以通过多种方式提示浏览器,以便更好地加载资源。如果请求的 JavaScript 不使用 document.write() 等改变 DOM 结构的 API,可以在<script> 标签中添加 [async](<https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/script#attr-async>)[defer](<https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/script#attr-defer>) 属性。 这样浏览器就会异步加载和运行 JavaScript 代码而不阻塞 HTML 解析。 如果合适,也可以使用 JavaScript 模块<link rel="preload"> 是一种通知浏览器当前导航肯定需要该资源并且希望尽快下载的方式。 可以在资源优先级 - 让浏览器为您提供帮助中阅读更多相关信息。

样式计算

拥有 DOM 不足以知道页面会是什么样子,因为我们可以在 CSS 中设置页面元素的样式。主线程会解析 CSS 并确定每个 DOM 节点的样式(computed style)。基于 CSS 选择器,主线程将会决定哪种样式应用于哪个元素。 你可以在 DevTools 的 computed 部分查看此信息。

image.png 图 3:主线程解析 CSS 添加计算样式

布局

现在渲染器进程知道文档的结构和每个节点的样式,但这还不足以渲染页面。 想象一下,你试图通过电话向朋友描述一幅画。 “有一个大的红色圆圈和一个小的蓝色方块”不足以让你的朋友知道这幅画究竟是什么样子。

image.png 图 4:一个人试图通过电话向另一个人描述一幅画

布局是一个寻找元素几何形状的过程。主线程遍历 DOM 和计算样式,并创建布局树(layout tree),其中包含 x、y 坐标和边界框大小等信息。布局树与 DOM 树的结构相似,但它只包含页面可见的内容相关的节点。 如果某元素具有 display: none 样式,则该元素不会出现在布局树上(具有 visibility: hidden 样式的元素会出现在布局树中)。类似地,如果元素应用了似于 p::before{content:"Hi!"} 的伪类,它会出现在布局树中,即使它不在 DOM 中。

image.png 图 5:主线程通过计算样式遍历 DOM 树并生成布局树

确定页面的布局是一项具有挑战性的任务。即使是最简单的页面布局,比如从上到下堆叠的文字块状盒子,也必须考虑字体有多大以及在哪里换行,因为这些会影响段落的大小和形状,而这会影响下一段文字需要放在的位置。

CSS 可以使元素浮动到一侧,并改变书写方向等。可以想象,这个布局阶段任务艰巨。Chrome 内部有整个工程师团队负责布局。如果你想查看他们工作的详细信息,BlinkOn 会议上的一些演讲非常有趣。

绘制(Paint)

image.png 图7:一个人拿着画笔站在画布前,不知道是先画圆还是先画正方形

拥有 DOM、样式和布局仍然不足以呈现页面。假设你正尝试临摹一幅画。你知道元素的大小、形状和位置,但你仍然需要判断你绘制它们的顺序。例如,某些元素可能被设置 z-index,在这种情况下,按照 HTML 中编写的元素的顺序进行绘制将导致错误地渲染。

image.png 图 8:页面元素按 HTML 标记的顺序出现,由于未考虑 z-index,导致图像渲染错误

在此绘制过程中,主线程遍历布局树以创建绘制记录(paint record)。绘画记录是对“先背景,后文字,再矩形”的绘画过程的描述。 如果你使用 JavaScript 绘制过<canvas> 元素,那你可能对这个过程很熟悉。

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

更新渲染管线的成本很高

渲染管线(rendering pipeline)中最重要的一点是:每一步都使用前一步操作的结果来创建新数据。 例如,如果布局树中的某些内容发生了变化,则需要为文档的受影响部分重新生成绘制顺序。

图 10:DOM + Style、Layout 和 Paint 树的生成顺序

如果为元素设置动画,浏览器必须在每一帧之间运行上述操作。大多数显示器每秒刷新屏幕 60 次 (60 fps), 当每一帧在屏幕上移动物体时,动画对人眼来说会显得平滑。 但是,如果动画错过了中间的帧,则页面将出现卡顿(janky)。

image.png 图 11:时间线上的动画帧

因为布局绘制等计算也在主线程上进行,即使你的渲染操作能跟上屏幕刷新的速率,当 JavaScript 运行时间过长时,它也会被阻塞。

image.png 图 12:时间轴上的动画帧,其中一帧被 JavaScript 阻塞

我们可以将 JavaScript 分成小块,并使用 requestAnimationFrame() 分散在每一帧运行。有关此主题的更多信息,请参阅优化 JavaScript 执行。 你还可以在 Web Workers 中运行 JavaScript 以避免阻塞主线程。

image.png 图 13:在带有动画帧的时间轴上运行的较小的 JavaScript 块

合成

如何绘制页面?

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

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

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

什么是合成

合成是一种将页面的各个部分分成多个层、单独光栅化它们并在称为合成器线程(compositor thread)的独立线程中合成为一个页面的技术。因为图层已经被光栅化,如果发生滚动,它所要做的就是合成一个新的帧。 动画可以以相同的方式实现:通过移动图层并合成新帧。

可以使用“图层”面板在 DevTools 中查看网站如何被划分为多个图层。

图 15:合成过程动画

分层

为了找出哪些元素需要在哪些层中,主线程遍历布局树时会创建层树(这部分在 DevTools 性能面板中称为“更新层树”)。如果页面的某些部分应该被划入到单独的层(例如滑入式的侧边菜单)但没有被浏览器识别出来,可以使用 CSS 中的 will-change 属性来提示浏览器。

image.png 图 16:主线程遍历布局树生成层树

你可能想为每个元素提供图层,但与每帧光栅化页面的小部分相比,在过多的图层上进行合成可能会导致操作更慢,因此测量应用程序的渲染性能至关重要。有关主题的更多信息,请参阅坚持仅合成器属性和管理图层计数

主线程的光栅和合成

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

image.png 图 17:创建切片位图并发送到 GPU 的光栅线程

合成器线程可以对不同的光栅线程进行优先级排序,以便可以首先对视口内(或附近)的事物进行光栅化。一个图层也有多个不同分辨率的图块来处理诸如放大操作之类的操作。

对图块进行光栅化后,合成器线程会收集称为 draw quads 的图块信息以创建合成帧(compositor frame)

  • Draw quads:包含诸如图块在内存中的位置以及在页面中绘制图块的位置等信息。
  • Compositor frame:代表一帧页面的 draw quads 集合

然后通过 IPC 将合成帧提交给浏览器进程。这些合成器帧被发送到 GPU 以将其显示在屏幕上。如果出现滚动事件,合成器线程会创建另一个合成器帧以发送到 GPU。

image.png 图 18:合成器线程创建合成帧。帧被发送到浏览器进程然后到 GPU

合成的好处在于它是在不涉及主线程的情况下完成的。合成器线程不需要等待样式计算或 JavaScript 执行。这就是为什么合成动画被认为是获得流畅性能的最佳选择。如果需要重新计算布局或绘制,则必须涉及主线程。

总结

在这篇文章中,我们研究了从解析到合成的渲染管线。希望你现在有更多背景知识来阅读网站性能优化的博客。

在本系列的下一篇文章中,我们将更详细地研究合成器线程,看看当用户交互,如 mouse move 和 click 时会发生什么。

出处

本文为 Inside look at modern web browser (part 3) 的译文,若想要有更好的理解请查看原文。