深入了解现代 web 浏览器(第三部分)
本文为译文,内容尽可能保持和原文的一致性,如果翻译有误,欢迎批评指正。
原文作者:Mariko Kosaka
原文标题:Inside look at modern web browser (part 3)
原文链接:https://developers.google.com/web/updates/2018/09/inside-browser-part3
渲染器进程的内部工作原理
这是关于浏览器如何工作的 4 部分博客系列的第 3 部分。之前我们介绍了多进程架构和导航流。在这篇文章中,我们将看看渲染器进程内部发生了什么。
渲染器过程涉及 Web 性能的许多方面。由于渲染器进程内部发生了很多事情,这篇文章只是一个概括性的概述。如果你想深入挖掘,Web 基础知识的性能部分有更多资源。
渲染器进程处理 Web 内容
渲染器进程负责选项卡内发生的所有事情。在渲染器进程中,主线程处理你发送给用户的大部分代码。如果你使用 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 规范的“解析器中的错误处理和奇怪情况简介”部分。
子资源加载
网站通常使用图像、CSS 和 JavaScript 等外部资源。这些文件需要从网络或缓存加载。主线程可以在解析构建 DOM 的过程中找到它们后一一请求,但为了加快速度,“预加载扫描器”是并发运行的。如果 HTML 文档中存在 <img> 或者 <link> 之类的内容,则预加载扫描器会查看 HTML 解析器生成的令牌,并将请求发送到浏览器进程中的网络线程。
图 2:主线程解析 HTML 并构建 DOM 树
JavaScript 可以阻止解析
当 HTML 解析器遇到 <script> 标签是,它会暂停 HTML 文档的解析,并必须加载、解析和执行 JavaScript 代码。为什么?因为 JavaScript 可以使用诸如 document.write() 之类的东西来改变文档的形状,这回改变整个 DOM 结构(HTML 规范中的解析模型概述有一个很好的图表)。这就是 HTML 解析器必须等待 JavaScript 运行才能继续解析 HTML 文档的原因。如果你对 JavaScript 执行中发生的事情感到好奇,V8 团队有演讲以及有相关博客。
提示浏览器你希望如何加载资源
Web 开发人员可以通过多种方式向浏览器发送提示以很好地加载资源。如果你的 JavaScript 不使用 document.write() ,你可以向 <script> 标签添加 async 或者 defer 属性。这样子浏览器会异步加载和运行 JavaScript 代码,并且不会阻止解析,如果合适,你也可以使用 JavaScript 模块。<link rel="preload"> 是一种通知浏览器当前导航肯定需要该资源并且你希望尽快下载的方式。你可以在资源优先级 - 让浏览器帮助你阅读更多相关信息。
样式计算
拥有 DOM 并不足以知道页面是什么样子,因为我们可以在 CSS 中设置页面元素的样式。主线程解析 CSS 并确定每个 DOM 节点的计算样式。这是关于基于 CSS 选择器将哪种样式应用于每个元素的信息。你可以在 DevTools 的计算部分看到此信息。
图 3:主线程解析 CSS 添加计算样式
布局
现在渲染器进程知道文档的结构和每个节点的样式,但这还不足以渲染页面。想象一下,你正试图通过电话向你的朋友描述一幅画。“有一个大的红色圆圈和一个小的蓝色方块”不足以让你的朋友知道这幅画究竟是什么样子。
图 4:一个人站在一幅画面前,电话线与与另一个人相连
布局是一个寻找元素几何形状的过程。主线程遍历 DOM 和计算样式,并创建布局树其中包含 x y 坐标和边界框大小等信息。布局树可能和 DOM 树的结构相似,但是它只包含与页面上可见的内容相关的信息。如果 display: none 被应用,该元素不是布局树的一部分(但是,一个元素使用 visibility: hidden 那么这个元素还是布局树的一部分)。相同的,如果应用了内容类似于 p:before{content: "Hi!"} 的伪类,即使它不在 DOM 中,它也会包含在 布局树中。
图 5:主线程使用计算样式遍历 DOM 树并生成布局树
确定页面的布局是一项具有挑战性的任务。即使是简单的页面布局,比如从上到下的快流,也必须考虑字体有多大以及在哪里运行,因为这些会影响段落的大小和形状;这会影响下一个段落需要放在哪里。
CSS 可以是元素浮动到一侧,屏蔽溢出项,并改变书写的方向。可想而知,这个布局阶段任务艰巨。在 Chrome 中,整个工程师团队负责布局,如果你想查看他们工作的详细信息,BlinkOn Conference 的一些演讲被记录下来并且非常有趣。
https://player.bilibili.com/player.html?bvid=bv11M4y137gQ
图 6:由于换行符更改而移动的段落框布局
绘制
拥有 DOM、样式和布局仍然不足以渲染页面。假设你正在尝试复制一幅画,你知道元素的大小、形状和位置,但你仍然需要判断你绘制它们的顺序。
图 7:一个人在画布前拿着画笔,不知道是先画圆还是先画正方形
例如,可能会为某些元素设置 z-index,在这种情况下,按照 HTML 中编写的元素顺序绘制将导致不正确。
图 8:页面元素按 HTML 标记的顺序出现,导致渲染错误,因为没有考虑 z-index
在此绘制步骤中,主线程遍历布局树以创建绘制记录。绘制记录是对“先背景,后文字,再矩形”的绘画过程的记录。如果你使用 JavaScript 在 <canvas> 元素上绘图,那么你可能得熟悉这个过程。
图 9:主线程遍历布局树并生成绘制记录
更新渲染管道的成本很高
渲染管线中最重要的一点是,在每一步都使用前一操作的结果来创建新数据。例如,如果布局树中的某些内容发生了变化,则需要为文档的受影响部分重新生成绘制顺序。
https://player.bilibili.com/player.html?bvid=bv1W44y187u8
图 10:DOM+Style、Layout 和 Paint 树的生成顺序
如果你正在为元素设置动画,则浏览器必须在每一帧之间运行这些操作。我们的大多数显示器每秒刷新屏幕 60 次(60fps);当每一帧在屏幕上移动物体时,动画对人眼来说会显得平滑。
图 11:时间线上的动画帧
即使你的渲染操作跟上屏幕刷新,这些计算也在主线程上运行,这意味着当你的应用程序运行 JavaScript 时,它可能会被阻止。
图 12:时间轴上的动画帧,但其中一帧被 JavaScript 阻止
你可以将 JavaScript 操作分成小块,并使用 requestAnimationFrame() 安排在每一帧运行。有关此主题的更多信息,请参阅优化 JavaScript 执行。你还可以在 Web Workers 中运行 JavaScript以避免阻塞主线程。
合成
你会怎么画一个页面?
既然浏览器知道了文档的结构、每个元素的样式、页面的几何形状和绘制顺序,它如何绘制页面?将此信息转换为屏幕上的像素称为光栅化。
也许处理这个问题的一种天真的方法是在视口内对部分进行光栅化。如果用户滚动页面,则移动光栅框架,并通过更多光栅填充缺失的部分。这就是 Chrome 在首次发布时处理光栅化的方式。然而,现代浏览器运行一个更复杂的过程,称为合成。
https://player.bilibili.com/player.html?bvid=bv1mq4y1K7id
图 14:简单光栅化过程的动画
什么是合成?
合成是一种将页面的各个部分分成多个层、单独光栅化它们并在称为合成器线程的单独线程中合成为一个页面的技术。如果发生滚动,因为图层已经被光栅化,它所要做的就是合成一个新的帧。动画可以通过移动图层并合成新帧以相同的方式实现。
你可以使用图层面板在 DevTools 中查看你的网站如何划分为多个图层。
https://player.bilibili.com/player.html?bvid=bv1tA411c7ZP
图 15:合成过程动画
分层
为了找出哪些元素需要在哪些层中,主线程遍历布局树以创建层树(这部分在 DevTools 性能面板中称为“更新层树”)。如果页面的某些部分应该是单独的层(如滑入式侧边菜单)没有得到,那么你可以通过在 CSS 中使用 will-change 属性来提示浏览器。
图 16:主线程遍历布局树生成层树
你可能很想为每个元素提供图层,但是与在每一帧中光栅化页面的小部分相比,在过多的图层上进行合成可能会导致操作更慢,因此,衡量应用程序的渲染性能至关重要。有关主题的更多信息,请参阅坚持仅合成器属性和管理图层计算。
主线程的光栅化和复合化
一旦创建了层树并确定了绘制顺序,主线程就会将该信息提交给合成器线程。然后合成线程光栅化每一层,一个图层可能像页面的整个长度一样大,因此合成器线程将它们分成多个图块并将每个图块发送到光栅线程。光栅线程光栅化每个瓦片并将它们存储在 GPU 内存中。
图 17:创建切片位图并发送到 GPU 的光栅线程
合成器线程可以对不同的的光栅线程进行优先排序,以便可以首先对视口内(或附近)的事物进行光栅化。一个图层也有不同分辨率的平铺来处理诸如放大操作之类的动作。
对切片进行光栅化后,合成器线程会收集成为绘制四边形的切片信息以创建合成器框架。
然后通过 IPC 将合成器框架提交给浏览器进程。此时,可以从用于浏览器 UI 更改的 UI 线程或用于扩展的其他渲染器进程添加另一个合成器框架。这些合成器帧被发送到 GPU 以将其显示在屏幕上。如果出现滚动事件,合成器线程会创建另一个合成器帧以发送到 GPU。
图 18:合成器线程创建合成框架,帧被发送到浏览器进程然后到 GPU
合成的好处是它是在不涉及主线程的情况下完成的,合成器线程不需要等待样式计算或 JavaScript 执行。这就是为什么只合成动画被认为是获得流畅性能的最佳选择。如果需要重新计算布局或绘制,则必须涉及主线程。
总结
在这篇文章中,我们研究了从解析到合成的渲染管道,希望你现在有权阅读有关网站性能优化的更多信息。
在本系列的下一篇也是最后一篇文章中,我们将详细的研究合成器线程,看看当鼠标移动和点击等用户输入进来时会发生什么。
你喜欢这篇文章吗?如果您对以后的帖子有任何问题或建议,我很乐意在下面的评论部分或 Twitter 上的 @kosamari 收到您的来信。
版权声明: 本文为 InfoQ 作者【GKNick】的原创文章。
原文链接:【http://xie.infoq.cn/article/e1fe295a68fdf41129a31c421】。文章转载请联系作者。
评论