Chrome 算是程序员的标配了,从全球的市场份额来看,它在全球市场的份额已经超过 60%。
在 Chrome 10 周年之际,官方发布了一个系列文章,用图解的方式,很清晰的讲解了现代浏览器的运行原理。
渲染器进程的内部工作原理
本系列分为 4 个部分,主要讲解关于现代浏览器的运行原理,本文为该系列的第 3 篇。在之前的文章中,我们介绍了多进程架构和导航的完整流程,而在这篇文章中,我们将探究在渲染器进程的内部,到底发生了什么。
渲染器进程涉及到 Web 性能相关的多个方面,由于渲染器进程中处理了很多的逻辑,不是一篇文章可以全面讲解的,因此本文仅作为一个概述。如果你有兴趣深入研究,可以在《Why Performance Matters》这篇文章里找到更多的资料。
渲染器进程处理Web内容
所有选项卡内发生的逻辑,都由渲染器进程负责。在渲染器进程中,主线程处理了服务器发送给用户的大部分代码。如果你使用到 Web Workder 或者Service Worker,那 JavaScript 中的这部分代码,将由工作线程处理。Compositor(合成器) 和 Raster(光栅) 线程也在渲染器内运行,从而实现高效、流畅的渲染页面。
渲染器进程的核心工作是将 HTML,CSS 和 JavaScript 转换为用户可以与之交互的网页。
上图中,描述了具有主线程、工作线程、Compositor 线程、Raster 线程的渲染器进程,以及他们之间的关系。
解析
构建 DOM
当渲染器进程收到一个导航请求,并开始接收 HTML 数据,主线程将开始处理文本字符串(HTML),将其解析成 DOM(Document Object Model)。
DOM 是 Web 页面的内部的逻辑树文档结构,Web 开发人员可以通过 JavaScript 脚本与之交互数据,以及通过标准 API 来操作 DOM 节点。
将 HTML 文档解析成 DOM 是完全依照于 HTML 协议。并且在 HTML 协议中,浏览器不会对错误的 HTML 进行错误提示。例如,缺少结束的 </p> 标签时,这依然是一个有效的 HTML。类似 Hi! <b>I'm <i>Chrome</b>!</i> 中,b 标签在 i 标签之前关闭这样的错误,会被 HTML 理解为 Hi! <b>I'm <i>Chrome</i></b><i>!</i>。这是因为 HTML 规范的主要原则是优雅的处理这些错误,而不是严格检查。
如果你对这些规范感到好奇,可以阅读 HTML 规范中的 “解析器中的错误处理和奇怪案例介绍” 部分。
解析器中的错误处理和奇怪案例介绍:
https://html.spec.whatwg.org/multipage/parsing.html#an-introduction-to-error-handling-and-strange-cases-in-the-parser
子资源加载
一个完整的 Web 站点通常会包含图片、CSS 和 JS 等外部资源,这些文件都需要从网络或者本地缓存中加载。主线程可以在解析构建 DOM 的时候,将他们逐个请求,但是为了加快速度,会同时使用 “预加载扫描(Preload Scanner)”。
如果 “预加载扫描” 发现有类似 <img> 或 <link> 这样的标签时,会由 HTML 解析器对该资源生成一个 Tokens,然后在浏览器进程中,通过网络或者本地缓存来加载资源。
上图描述了,主线程解析 HTML 并构建 DOM 树的过程。
JS 可以阻止解析
当 HTML 解析器遇到 <script> 标签的时候,它会暂停解析 HTML 文档,然后对这个 JS 脚本进行加载、解析和执行。
这么设计的原因,是因为 JS 可以使用类似 document.write() 方法来改变 DOM 的结构。这就是 HTML 解析器在重新解析 HTML 之前,必须等待 JS 脚本执行的原因。
如果你对 JS 执行中发生的事情细节有兴趣,V8 团队有一篇文章深入的对此进行了讲解,有兴趣可以看看。
V8 团队深入研究:
https://mathiasbynens.be/notes/shapes-ics
提示浏览器如何加载资源
HTML 遇到 JS 脚本则暂停对 HTML 的解析,这并不是绝对的。
Web 开发人员可以通过多种方式的配置,告知浏览器如何更优雅的加载资源。如果你的 JS 脚本中,没有使用到类似 document.write() 这样的方法,你可以在 script 标签中添加 async 或 defer 标记,然后浏览器会异步加载和运行此 JS 脚本,不会阻断解析。如果需要,也可以使用 JavaScript Modules,还可以通过 <link rel="preload"> 标签向浏览器明确标记此为重要的资源,将在页面加载完成之后被立刻使用,对于这类资源,它会在页面加载生命周期的早期,被优先加载。
样式渲染(Style)
仅仅解析成 DOM,还不足以完成页面渲染,因为还可以通过在 CSS 中,设置元素的样式来丰富渲染效果。
主线程将解析 CSS,并将效果渲染到指定的 DOM 节点上,关于 CSS 选择器如何定位到指定的 DOM 节点,可以通过 DevTools 来查看相关信息。
上图中,主线程解析 CSS 并添加渲染样式。
即使你不使用任何 CSS 样式,每个 DOM 节点依然存在默认的渲染样式。例如,h1 标签在视觉上就大于 h2 标签,并且每个元素还有默认的边距。这是因为浏览器具有默认样式表。
如果你对 Chrome 的默认 CSS 是什么样的有兴趣,可以在源码中看到具体细节。
Chrome 的默认 CSS:
https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/css/html.css
布局(Layout)
到现在,渲染器进程知道每个 DOM 的结构和样式了,但是这依然不足以渲染页面。想象一下,你正视图通过文字向朋友描述一副画,“有一个大的红色圆圈和一个小的蓝色方块”,这些信息不足以让你的朋友还原这幅画。
这就牵扯到布局(Layout),布局是对元素定位的过程,主线程遍历 DOM 并计算样式,然后创建布局树(Layout Tree),在布局树中,包含 X、Y 坐标和边框大小等信息。布局树是一个与 DOM 树类似的结构,但是它仅仅包含了页面上可见内容相关的信息。
举个例子,如果某个元素设置了 display:none,则该元素将不会出现在布局树中,但是它会出现在 DOM 树中,而如果该元素被设置为 visibility:hidden 则它会存在于布局树中。类似的例子还有 p::before{content:"Hi!"} 这样的伪类,它会存在于布局树中,而不会存在于 DOM 树中。
如上图所示,在主线程中渲染样式,并生成布局树和 DOM 树。
计算页面布局是一个很复杂的工作,即使最简单的从上到下的块流结构,也必须考虑字体的大小以及如何划分每一块,因为它们会影响当前段落的大小和形状,然后影响下一块所在的位置。
CSS 样式可以设置元素浮动到某一侧、隐藏 overflow 的元素,或者改变排版方向。布局是一个非常复杂的工作,在 Chrome 中,有一个完整的工程师团队负责布局。如果你的对他们工作的细节感兴趣,可以参阅 BinkOn 会议的记录。
BinkOn:
https://www.youtube.com/watch?v=Y5Xa4H2wtVA
绘制(Paint)
拥有 DOM、CSS 和 LayoutTree 仍然不足以渲染页面。假设你正在尝试重绘一幅画,你除了需要知道元素的大小、外观和位置之外,还需要知道它们的绘制顺序。
例如:z-index 属性将改变元素的层级,在这种情况下,按 HTML 中编写的元素顺序进行绘制,将导致渲染结果和预期不符。
如上图所示,因为没有正确的考虑 z-index,将导致页面被错误的渲染。
在这个绘制的过程中,主线程遍历布局树,然后创建绘制记录。绘制记录是一个绘制过程的注释,例如“背景优先,然后是文本,***是矩形”。如果你曾经使用 JS 在 <canvas> 上绘制元素,那么你对此过程应该会很熟悉。
如上图所示,主线程遍历布局树,并生成绘制记录。
更新渲染管道的成本很高
渲染管道(Rendering Pipeline)中最重要的任务,就是在每个步骤开始前,根据前一次操作的结果,来创建新的数据。例如,如果布局树中的某些内容发生更改,则需要为文档的受影响部分重新生成“绘制”顺序。
渲染管道(Rendering Pipeline)中最重要的任务,就是在每个步骤开始前,根据前一次操作的结果,来创建新的数据。例如,如果布局树中的某些内容发生变动,则需要为文档中受影响的部分,重新生成“绘制记录”。
为元素设置的动画,浏览器必须在每一帧之间执行这些操作。我们大多数显示器每秒刷新 60 次(60fps),如果你对每一帧都做了处理,那动画对人眼而言就是平滑的,但是如果某些帧没有被处理到或者丢失了,则会导致动画不连贯,出现页面的“卡顿”。
哪怕渲染的计算可以跟上屏幕刷新,可因为此计算过程发生在主线程上,当执行 JavaScript 脚本时,可能导致渲染过程被阻断。
即使渲染的计算可以跟上屏幕的刷新速度,可因为此计算是在主线程上执行的,这就意味着 JS 代码的执行,也可能导致它被阻断。
如上图,时间轴上的动画帧,被 JS 阻止了一帧。
为此,你可以将 JavaScript 操作划分成小块,并在每帧上执行requestAnimationFrame(),还可以在 Web Workers 中运行 JavaScript,以避免阻塞主线程。
如图所示,在动画帧的时间轴上,运行较小的 JavaScript 块。
合成(Compositing)
如何绘制一个页面?
现在浏览器知道文档的结构,每个元素的样式,页面的形状和绘制顺序,它是如何绘制页面的?将此信息转换为屏幕上的像素称为光栅化(rasterizing)。
光栅化是将几何数据经过一系列变换后最终转换为像素,从而呈现在显示设备上的过程。
也许处理这种情况的一种无脑方案,是在视口(ViewPort)内部将每个组件都光栅化。如果用户滚动页面,则移动光栅帧,并通过更多光栅元素填充缺少的部分。
这就是 Chrome ***发布时处理光栅化的方式,但是,现代浏览器运行一个更复杂的被称为合成(Compositing)的进程。
什么是合成(Compositing)
合成是一种将页面的各个元素进行分层,分别光栅化,并在合成器线程中以一个单独的线程合成新页面的技术。如果页面发生滚动,由于图层已经光栅化,因此它需要做的就是合成一个新帧。通过移动图层同时合成新帧,可以以相同的方式实现动画。
你可以在 DevTools 中的 Layout panel 来查看看图层。
分层
为了确定每个元素所在的层,主线程遍历布局树以创建层树(Layer Tree)。如果页面的某元素应该是一个单独的图层(例如侧滑菜单),那么你可以在 CSS 中,使用 will-change 属性提示浏览器。
如上图,在主线程中遍历布局树,并生成层树。
虽然理想情况下,应该为每个元素生成图层,但是对过多的小图层进行合并,可能会比对页面的每帧上栅格化小元素更慢,因此测量应用程序的渲染性能就非常重要。有关主题的更多信息,请参阅 Stick to Compositor-Only Properties 和 Manage Layer Count。
Stick to Compositor-Only Properties 和 Manage Layer Count:
https://developers.google.com/web/fundamentals/performance/rendering/stick-to-compositor-only-properties-and-manage-layer-count
光栅和合成,脱离主线程
一旦创建了层树并确定了绘制顺序,主线程就会将该信息提交给合成器线程。合成器线程会光栅化每个图层,一个图层可能想一个完整的页面那么大,因此合成器线程将他们分成图块,并将每个图块发送到光栅线程。光栅线程格式化每个元素,并将他们存储在 GPU 内存中。
图17:光栅线程创建光栅位图并发送到GPU
合成器线程可以优先考虑不同的光栅线程,以便 ViewPort(或附近)的元素可以被优先光栅化。图层还具有多个不同分辨率的倾斜度,以便对内容的放大等操作。
一旦元素被光栅化,合成器线程会收集被称为 “绘制矩形(Draw Quads)” 的信息,用以创建一个合成帧(Compositor Frame)。
然后通过 IPC 将合成帧提交给浏览器进程。此时,可以从 UI 线程添加另一个合成帧用于浏览器的 UI 更新,或者从其他渲染器进程中添加扩展。这些合成帧被发送到 GPU 中,用以在屏幕上显示。如果触发滚动事件,合成器线程会创建另一个合成帧发送到 GPU。
上图中,合成器线程创建合成帧。将此帧发送到浏览器进程然后发送到 GPU。
合成(Compositor)的好处,是它可以在不影响主线程的情况下完成。合成器线程不需要等待样式计算或者 JS 脚本执行,这就是为什么 “仅合成动画” 被认为是平滑性能的***选择。如果需要再次计算不会或者重新绘制,则必须涉及到主线程。
合成动画:
https://www.html5rocks.com/en/tutorials/speed/high-performance-animations/
小结
在这篇文章中,我们研究了从解析到合成的渲染流程,希望你现在有兴趣探究有关网站性能优化的更多内容。
在下一篇文章中,将更详细的介绍合成器线程,并解释当用户触发 mouse move 和 click 时,会发生什么。
【本文为51CTO专栏作者“张旸”的原创稿件,转载请通过微信公众号联系作者获取授权】
戳这里,看该作者更多好文