现代浏览器探秘(part1):架构

现代浏览器探秘(part2):导航

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

这是关于浏览器内部工作原理系列的第3部分。 之前,我们介绍了多进程架构导航流程。 在这篇文章中,我们将看看渲染器进程内部发生了什么。

渲染进程涉及Web性能的诸多方面。 由于渲染进程中发生了很多事情,因此本文不能一一赘述。 如果你想深入挖掘,可以在Web基础的性能部分找到更多内容。

渲染器进程处理Web内容

渲染器进程负责选项卡内发生的所有事情。 在渲染器进程中,主线程处理你为用户编写的大部分代码。 如果你使用了web worker 或 a service worker,有时JavaScript代码的一部分将由工作线程处理。 排版和栅格线程也在渲染器进程内运行,以便高效、流畅地呈现页面。

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

图1:渲染器进程内部有主线程、工作线程、排版线程和栅格线程

图1:渲染器进程内部有主线程、工作线程、排版线程和栅格线程

解析

构建DOM

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

DOM是页面在浏览器中的内部表示,同时也是Web开发人员可以通过 JavaScript 与之交互的数据结构和API。

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规范旨在优雅地处理这些错误。 如果你对如何完成这些工作感到好奇,可以阅读HTML规范中的“解析器中的错误处理和奇怪情况介绍”部分。

子资源加载

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

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

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

JavaScript可以阻止解析

当HTML解析器找到<script>标记时,它会暂停解析HTML文档,并且必须加载、解析和执行JavaScript代码。 为什么要这样处理? 因为JavaScript可以使用像document.write() 那样改变整个DOM结构的东西来改变文档的形状(HTML规范中的解析模型概述有一个很好的示意图)。 这就是HTML解析器在重新解析HTML文档之前必须等待JavaScript运行的原因。 如果你对JavaScript执行中发生的事情感到好奇,V8团队的博客对此进行了讨论。

提示浏览器如何加载资源

Web开发人员可以通过多种方式向浏览器发送提示,以便很好地加载资源。 如果你的JavaScript不使用 document.write(),则可以向<script>标记添加asyncdefer属性。 然后,浏览器异步加载和运行JavaScript代码,不会阻止解析。 如果合适,你也可以使用JavaScript模块<link rel ="preload">是一种通知浏览器当前导航肯定需要这个资源的方法,你希望尽快下载。 你可以在资源优先级找到更多信息。

样式表计算

拥有DOM不足以知道页面的外观,因为我们可以在CSS中设置页面元素的样式。 主线程解析CSS并确定每个DOM节点的计算样式。 这是有关基于CSS选择器将哪种样式应用于每个元素的信息。 你可以在浏览器中开发者工具中的computed部分中看到此信息。

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

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

即使你不提供任何CSS,每个DOM节点都具有计算样式。比如 <h1>标签的显示要大于<h2>标签,同时为每个元素定义边距。 这是因为浏览器具有默认样式表。 如果你想知道Chrome的默认CSS是什么样的,你可以在此处查看源代码

布局

现在,渲染器进程知道每个节点的文档和样式的结构,但这还不足以呈现页面。 想象一下,你正试图通过手机向朋友描述一幅画: “有一个大的红色圆圈和一个小的蓝色方块” 这并不能完全让你的朋友了解这幅画的外观。

图4:一个人站在一幅画,通过电话线与另一个人联系

图4:一个人站在一幅画,通过电话线与另一个人联系

布局是查找元素几何的过程。 主线程遍历DOM并计算样式和创建布局树,其中包含x y坐标和边界框大小等信息。 布局树可以是与DOM树类似的结构,但它仅包含与页面上可见内容相关的信息。 如果display:none,则该元素不是布局树的一部分(但是在布局树中包含visibility:hidden的元素)。 类似地,如果应用具有类似p::before {content:"Hi!}之类的内容的伪类,则它将包含在布局树中,即使它不在DOM中。

图5:主线程通过DOM树生成计算样式和布局树

图5:主线程通过DOM树生成计算样式和布局树

确定页面布局是一项具有挑战性的任务。 即使是最简单的页面布局,如从上到下的块流,也必须考虑字体的大小以及在哪里划分它们,因为它们会影响段落的大小和形状; 然后影响下一段所需的位置。

图6:由于换行符而移动的段落的框布局

图6:由于换行符而移动的段落的框布局

CSS可以使元素浮动到一侧,掩盖溢出项,并更改写入方向。 你可以想象,这个布局阶段是一项艰巨的任务。 在Chrome项目中,有一个完整的工程师团队负责布局。 如果你想看到他们工作的细节,看看这些会议记录非常有意思。

绘制

拥有了DOM、样式和布局仍然不足以呈现页面。 假设你正在尝试重现一幅画。 你不仅需知道元素的大小,形状和位置,还需要判断绘制它们的顺序。

图7:一个在画布前拿着画笔的人,正在思考是应该先画圆圈还是矩形

图7:一个在画布前拿着画笔的人,正在思考是应该先画圆圈还是矩形

例如:可以为某些元素设置z-index,在这种情况下,按HTML中编写的元素顺序绘制将导致不正确的呈现。

图8:页面元素按HTML标记的顺序出现,会导致错误的渲染图像,因为没有考虑z-index

图8:页面元素按HTML标记的顺序出现,会导致错误的渲染图像,因为没有考虑z-index

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

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

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

更新渲染通道的成本很高

在渲染通道中最重要的一件事就是在每个步骤中,前一个操作的结果被用于创建新数据。 例如:如果布局树中的某些内容发生更改,则需要为文档的受影响部分重新生成绘制顺序。

图10:DOM + Style,布局和绘制树的生成顺序

图10:DOM + Style,布局和绘制树的生成顺序

如果要为元素设置动画,则浏览器必须在每个帧之间运行这些操作。 我们的大多数显示器每秒刷新屏幕60次(60 fps); 当你在每一帧移动屏幕时,动画对人眼来说会很平滑。 但是如果动画错过了其中的帧,则页面将发生闪烁。

图11:时间轴上的动画帧

图11:时间轴上的动画帧

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

图12:时间轴上的动画帧,但JavaScript阻止了一帧

图12:时间轴上的动画帧,但JavaScript阻止了一帧

你可以将JavaScript操作划分为小块,并使用 requestAnimationFrame() 安排在每个帧上运行。 有关此主题的更多信息,请参阅优化JavaScript执行。 你也可以在 Web Workers 中运行 JavaScript 来避免阻塞主线程

图13:在动画帧的时间轴上运行的较小的JavaScript块

图13:在动画帧的时间轴上运行的较小的JavaScript块

合成

你会如何绘制一个页面?

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

图14:简单光栅化过程

图14:简单光栅化过程

也许处理这种情况的一种简单的方法是在视口(viewport)内部使用栅格部件。 如果用户滚动页面,则移动光栅帧,并通过更多光栅填充缺少的部分。 这就是Chrome首次发布时处理栅格化的方式。 但是,现代浏览器运行一个称为合成的更复杂的过程。

什么是合成

合成是一种将页面的各个部分分层,分别栅格化,并在一个被称为合成器线程的独立线程中合成为页面的技术。 如果发生滚动,由于图层已经被栅格化,所以它所要做的就是合成一个新帧。 通过移动图层和合成新帧,可以用相同的方式实现动画。

图15:合成过程的示意动画

图15:合成过程的示意动画

你可以使用浏览器开发者工具的“layout”面板中查看你的网站如何划分为多个图层

分为几层

为了找出哪些元素需要放在哪些层中,主线程通过遍历布局树以创建层树(此部分在DevTools性能面板中称为“Update Layer Tree”)。 如果页面某些应该是单独图层(如滑入式侧面菜单)的部分但是没有分配到图层,那么你可以使用CSS中的will-change属性提示浏览器。

图16:主线程生通过遍历布局树来成层树

图16:主线程生通过遍历布局树来成层树

也许你想要为每个元素提供图层,但是过多的图层进行合成可能会导致比每帧光栅化页面的小部分更慢的操作,因此测量应用程序的渲染性能至关重要。 有关主题的更多信息,请参阅Stick to Compositor-Only Properties and Manage Layer Count

光栅和复合关闭主线程

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

图17:栅格线程创建tile位图并发送到GPU

图17:栅格线程创建tile位图并发送到GPU

合成器线程可以优先考虑不同的aster线程,以便视口(或附近)内的事物可以先被光栅化。 图层还具有多个不同分辨率的倾斜度,可以处理放大操作等内容。

一旦tile被光栅化,合成器线程会收集称为绘制四边形(draw quads )的tile信息来创建合成器帧(compositor frame)

绘制四边形 包含信息,例如图块在内存中的位置以及在考虑页面合成的情况下绘制图块的页面中的位置。
合成器帧 表示页面帧的绘制四边形的集合。

然后通过IPC将合成器帧提交给浏览器进程。这时可以从UI线程添加另一个合成器帧以用于浏览器UI更改,或者从其他渲染器进程添加扩充数据。 这些合成器帧被发送到GPU用来在屏幕上显示。 如果发生滚动事件,合成器线程会创建另一个合成器帧并发送到GPU。

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

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

合成的好处是它可以在不涉及主线程的情况下完成。 合成线程不需要等待样式计算或 JavaScript 执行。 这就是合成动画是平滑性能的最佳选择的原因。 如果需要再次计算布局或绘图,则必须涉及主线程。

总结

在本文中,我们研究了从解析到合成的渲染通道。

在本系列的下一篇文章中,我们将更详细地介绍合成器线程,并了解当用户进行鼠标移动和单击等操作时会发生什么。