给前端以福利,给编程以复利。大家好,我是大家的林语冰。
00. 写在前面
本系列的前面两篇博客中,我们科普了多进程架构和导航流程。这是探讨浏览器工作原理的博客系列第 3 部分。在本文中,我们将探讨渲染器进程内部的技术细节。
渲染器进程涉及 Web 性能的方方面面。由于渲染器进程内部发生了很多事情,因此本文只是一个宏观视角。
免责声明 本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考。英文原味版请传送 Inside look at modern web browser (part 3)。
01. 渲染器进程处理网页内容
渲染器进程负责选项卡内发生的所有事情。在渲染器进程中,主线程处理发送给用户的大部分代码。如果你使用了 Web Worker 或 Service Worker,有时 JavaScript 的部分工作是由 worker 线程处理的。
合成器和光栅线程也在渲染器进程内运行,高效丝滑地渲染页面。渲染器进程的核心职责是将 HTML、CSS 和 JavaScript 转换为用户可以交互的网页。
02. 解析
02-1. 构建 DOM
当渲染器进程收到导航的提交消息,并开始接收 HTML 数据时,主线程开始解析文本字符串(HTML),并将其转换为 DOM(文档对象模型)。
DOM 是浏览器页面的内部表示,也是 Web 开发者可以通过 JavaScript 进行交互的数据结构和 API。
将 HTML 文档解析为 DOM 是由 HTML 标准定义的。向浏览器提供 HTML 永远不会引发错误。
举个栗子,缺少 </p> 闭合标签也是有效的 HTML。诸如 Hi! <b>I'm <i>Chrome</b>!</i> 之类的错误标签将被解读为 Hi! <b>I'm <i>Chrome</i></b><i>!</i> 。这是因为 HTML 规范旨在优雅地处理这些错误。
02-2. 子资源加载
网站通常使用图像、CSS 和 JavaScript 等外部资源。这些文件需要从网络或缓存加载。主线程可以在解析构建 DOM 时一一请求它们,但为了加快速度,“预加载扫描器”是并发运行的。
如果 HTML 文档中存在诸如 <img> 或 <link> 之类的内容,那么预加载扫描器会查看 HTML 解析器生成的标签,并将请求发送到浏览器进程中的网络线程。
02-3. JavaScript 可能阻塞解析
当 HTML 解析器找到 <script> 标签时,它会暂停 HTML 文档的解析,并必须加载、解析和执行 JavaScript 代码。
为什么呢?因为 JavaScript 可以使用诸如 document.write() 之类的”黑魔法“,改变文档的形状,这会改变整个 DOM 结构。这就是为什么 HTML 解析器必须等待 JavaScript 运行,才能恢复 HTML 文档的解析。
03. 向浏览器明示你要如何加载资源
Web 开发者可以通过多种方式向浏览器发送提示,更好地加载资源。
如果你的 JavaScript 不使用 document.write() ,你可以将 async 或 defer 属性添加到 <script> 标签中。然后浏览器异步加载并运行 JavaScript 代码,并且不会阻塞解析。
如果条件合适的话,你也可以使用 JavaScript 模块。<link rel="preload"> 也是一种通知浏览器当前导航肯定需要该资源、并且你希望尽快下载的方式。
04. 样式计算
拥有 DOM 不足以了解页面的外观,因为我们可以在 CSS 中设置页面元素的样式。
主线程会解析 CSS,并确定每个 DOM 节点的计算样式。这是有关基于 CSS 选择器对每个元素应用哪种样式的信息。
即使你不提供任何 CSS,每个 DOM 节点也有一个计算样式。
举个栗子,<h1> 标签显示得比 <h2> 标签大,并且为每个元素定义了边距。这是因为浏览器就有一个默认的样式表。
05. 布局
现在渲染器进程知道文档的结构和每个节点的样式,但这还不足以渲染页面。
请想象一下,你正试图和女朋友微信语音,并向她描述一幅画。 “有一个红色的大圆圈,和一个蓝色的小方块”,这并不足以让你的女朋友知道这幅画到底是什么样子。
布局是寻找元素几何形状的过程。主线程遍历 DOM 和计算样式并创建布局树,其中包含 x y 坐标和边界框大小等信息。
布局树可能与 DOM 树的结构类似,但它只包含与页面上可见内容相关的信息。如果应用 display: none,那么该元素就不是布局树的一部分。但是,具有 visibility: hidden 的元素位于布局树中。
举一反一,如果应用了具有诸如 p::before{content:"Hi!"} 之类的伪类,它也会包含在布局树中,即使它不在 DOM 中。
确定页面的布局极具挑战。即使是最简单的页面布局,比如从上到下的区块流,也必须考虑字体有多大,以及在哪里换行,因为这些会影响段落的大小和形状;这也会影响下一段的位置。
CSS 可以使元素单侧浮动、屏蔽溢出,以及改变书写方向。可以想象,这个布局阶段的任务无比艰巨。
06. 绘制
拥有 DOM、样式和布局仍然不足以渲染页面。
假设你正在尝试拷贝一幅画。你知道元素的大小、形状和位置,但你仍然必须判断绘制它们的顺序。
举个栗子,你可能会为某些元素设置 z-index,在这种情况下,按照 HTML 中编写的元素顺序绘制将导致错误渲染。
在绘制步骤中,主线程遍历布局树,创建绘制记录。
绘制记录是对绘制过程的记录,“先背景,后文字,再矩形”。如果你使用过 JavaScript 绘制过 <canvas> 元素,那么你可能会熟悉此过程。
更新渲染管道的成本很高。
在渲染管道中最重要的一点是,在每一步中,都会使用先前操作的结果来创建新数据。
举个栗子,如果布局树中发生某些变化,那么需要为文档的受影响部分重新生成绘制顺序。
如果你要对元素进行动画处理,那浏览器必须在每一帧之间运行这些操作。我们的大多数显示器每秒刷新屏幕 60 次;当你在屏幕上每帧移动物体时,动画对于人眼来说会显得平滑。
但是,如果动画错过了中间的帧,那么页面将出现“卡顿现象”。
即使你的渲染操作与屏幕刷新保持同步,这些计算也会在主线程上运行,这意味着,当你的应用程序运行 JavaScript 时,它可能会被阻塞。
你可以将 JavaScript 操作分成小块,并使用 requestAnimationFrame() 安排在每一帧运行。你还可以在 Web Workers 中运行 JavaScript,避免阻塞主线程。
07. 合成
07-1. 如何绘制页面?
既然浏览器知道了文档的结构、每个元素的样式、页面的几何形状以及绘制顺序,那么它如何绘制页面呢?
将这些信息转换成屏幕上的像素称为 光栅化(rasterizing)。
也许处理这个问题的一个简单方案是在视口内对部分进行光栅化。如果用户滚动页面,则移动光栅框架,并通过光栅更多来填充缺失的部分。这就是 Chrome 首发时处理光栅化的方案。
然而,现代浏览器运行一个更复杂的过程,称为 合成(compositing)。
07-2. 什么是合成
合成是一种将页面的各个部分分成图层、分别光栅化,并在称为合成器线程的单独线程中合成为页面的技术。
如果发生滚动,由于图层已经光栅化,它所要做的就是合成一个新帧。可以通过移动图层并合成新帧以相同的方式实现动画。
07-3. 分层
为了找出哪些元素需要位于哪些图层中,主线程会遍历布局树来创建图层树,这在 DevTools 性能面板中称为“更新图层树”。
如果页面的某些部分应该是单独的图层,比如滑入式侧面菜单,但没有获得单独的图层,那么你可以使用 CSS 中的 will-change 属性向浏览器提示。
你可能会想为每个元素提供图层,但是与每帧光栅化页面的一小部分相比,跨过多数量的图层进行合成可能会导致操作速度变慢,因此衡量应用程序的渲染性能至关重要。
07-3. 主线程的光栅和合成
一旦创建了图层树并确定了绘制顺序,主线程就会将该信息提交给合成器线程。然后合成器线程光栅化每一层。
图层可能像页面的整个长度一样大,因此合成器线程将它们划分为图块,并将每个图块发送到光栅线程。光栅线程对每个图块进行光栅化,并将其存储在 GPU 内存中。
合成器线程可以优先考虑不同的光栅线程,以便可以首先对视口内或附近的事物进行光栅化。
图层还具有针对不同分辨率的多个平铺,以处理诸如放大操作之类的操作。一旦图块被光栅化,合成器线程就会收集称为绘制四边形的图块信息,来创建合成器框架。
然后,合成器框架通过 IPC 提交给浏览器进程。此时,可以从 UI 线程添加另一个合成器框架,进行浏览器 UI 更改,或者从其他渲染器进程添加进行扩展。
这些合成器帧被发送到 GPU,将其显示在屏幕上。如果出现滚动事件,合成器线程将创建另一个合成器帧发送到 GPU。
合成的优势在于,它是在不涉及主线程的情况下完成的。合成器线程不需要等待样式计算或 JavaScript 执行。
这就是为什么仅合成动画被认为是获得流畅性能的最佳选择。如果需要重新计算布局或绘制,那必然涉及主线程。
高潮总结
在这篇文章中,我们深度学习了从解析到合成的渲染管道。
在本系列的最后一篇博客中,我们将更详细地了解合成器线程,并了解当 mouse move 和 click 等用户输入进入时,浏览器幕后的技术细节。
参考文献
粉丝互动
本期话题是:浏览器如何构建渲染树和绘制页面的?你可以在本文下方自由言论,文明科普。
欢迎持续关注“前端俱乐部”,给前端以福利,给编程以复利。
坚持阅读的小伙伴可以给自己点赞!谢谢大家的点赞,掰掰~