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 并添加渲染样式。
#p#分页标题#e#即使你不使用任何 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)中最重要的任务,就是在每个步骤开始前,根据前一次操作的结果,来创建新的数据。例如,如果布局树中的某些内容发生变动,则需要为文档中受影响的部分,重新生成“绘制记录”。
#p#分页标题#e#为元素设置的动画,浏览器必须在每一帧之间执行这些操作。我们大多数显示器每秒刷新 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)。
#p#分页标题#e#然后通过 IPC 将合成帧提交给浏览器进程。此时,可以从 UI 线程添加另一个合成帧用于浏览器的 UI 更新,或者从其他渲染器进程中添加扩展。这些合成帧被发送到 GPU 中,用以在屏幕上显示。如果触发滚动事件,合成器线程会创建另一个合成帧发送到 GPU。
上图中,合成器线程创建合成帧。将此帧发送到浏览器进程然后发送到 GPU。
合成(Compositor)的好处,是它可以在不影响主线程的情况下完成。合成器线程不需要等待样式计算或者 JS 脚本执行,这就是为什么 “仅合成动画” 被认为是平滑性能的最佳选择。如果需要再次计算不会或者重新绘制,则必须涉及到主线程。
合成动画:
https://www.html5rocks.com/en/tutorials/speed/high-performance-animations/
小结
在这篇文章中,我们研究了从解析到合成的渲染流程,希望你现在有兴趣探究有关网站性能优化的更多内容。
在下一篇文章中,将更详细的介绍合成器线程,并解释当用户触发 mouse move 和 click 时,会发生什么。
【本文为51CTO专栏作者“张旸”的原创稿件,转载请通过微信公众号联系作者获取授权】