One Page Summary
前两篇文章我们分别介绍了 Chromium 的多进程架构, 以及 Chromium 的 Navigation。在 Navigation 的文章中我们停留在了 Renderer 进程获取到数据的阶段,今天这篇文章我们就来一探究竟。
在浏览器中, 负责把资源转换为"Bitmap"的部分称为渲染引擎。渲染引擎的输入就是网络上加载回来的资源, 输出就是一帧帧的画面给到 Browser, 然后 Browser 进程通知 GPU 进程去渲染, 当产生帧的速率足够快的时候, 页面就在我们眼前动了起来。Chromium 使用 Blink 作为渲染引擎, 顾名思义, Blink 就是像眨眼一样, 每次 blink 都产生新的一帧 (不得不说,这个名字真的很生动)。
当一件事情非常复杂, 并且流程相对固定的时候, 我们可以采用管线化的设计, 及将大任务拆分成许多个小任务然后像流水线一样规划起来。渲染引擎作为一个极为复杂的引擎也有自己的渲染管线, 下图是对渲染管线的概括, 管线中的每个阶段称为 stage, 不同 stage 可能在不同的线程中执行。
Renderer 进程包含一个主进程、一些 Worker 线程、 一个或多个 raster 线程以及一个 compositor 线程。主线程处理大多数我们提供给浏览器解析的代码。如果我们使用了 Web Worker 相关的技术, 那么这部分代码就会运行在 Worker 线程。raster 线程和 compositor 线程用于栅格化和合成, 通过减少重跑整个 pipeline 从而让渲染能够更加的丝滑。
解析
构建 DOM
当 Renderer 进程收到一次 Navigation 的提交时, 就会开始接收 HTML 的数据, 然后进行词法分析, 语法分析, 生成 AST 最终生成Document Object Model (DOM)。DOM 是浏览器的页面内部表示,也是我们可以通过 JavaScript 进行交互的数据结构和 API。
将 HTML 文档解析为 DOM 是由 HTML 标准定义的。平常我们写代码的时候可能会发现,有时候我们写错 HTML 浏览器从来不给我们报错。比如缺少结束标签也可以生效。像 Hi! <b>I'm <i>Chrome</b>!</i>(b 标签和 i 标签的顺序错了)会被修正为Hi! <b>I'm <i>Chrome</i></b><i>!</i> 。这是因为 HTML 规范中有详细描述怎么优雅地处理这些错误。感兴趣的同学可以看看这部分规范。
加载 SubResource
MainResource: HTML资源。
SubResource: 除了 HTML 以外的资源, 像 JS, CSS, 图片这些都是 SubResource。
我们的网页不可能只有 HTML, 我们当然还会使用到许多 SubResource, 这些资源都是需要加载的。最容易想到的做法就是在我们构建 DOM 树的时候遇到一个就去加载一个, 但是为了尽可能早的发起请求, Chromium 会并行的使用 preload scanner 去扫描代码, 这样我们在最早词法分析的阶段就可以知道有哪些 SubResource 需要加载。当 preload scanner 扫描到有 img 或者 link 这样的 token 时, 就会向 Browser 进程里的 network 线程发送资源请求。
What about JavaScript
当编译器解析到 script 标签时会停止解析 DOM 树, 然后去加载, 解析, 执行 JavaScript 代码, 因为 JavaScript 可以改变我们的 HTML, 比如通过 document.write, 如果 JavaScript 改动到了 DOM 那之前的解析就有脏数据了, 所以 Blink 会等 JavaScript 执行完再继续解析 DOM 树。如果有同学想更深入了解 JS 具体执行的时候发生的事可以看看这篇博客 (后面也许可以单独出篇文章讲讲)。
告诉浏览器如何加载资源
但随着前端应用的日益复杂, 我们很难保证文档中间没有script代码, 为了避免带来严重的性能问题, Blink 允许我们为 script 打上 async 或者 defer 属性, 识别到这些属性后浏览器就会异步的加载这些资源也就不会阻塞构建 DOM 树了, 当然如果你喜欢的话你也可以使用 esmodule。
计算样式
有了 DOM 树后还不足以构建我们的页面, 因为我们还需要获取通过 CSS 定义的样式信息。Blink 在主线程中解析页面中的 CSS 元素然后为每个 DOM 节点计算出 computedStyle, computedStyle 包含了每个元素被应用上的样式。即便你没有在 HTML 里写任何的 CSS 代码, 每个节点还是会有自己的 computedStyle, H1 标签的文字要比 H2 标签的要大, b 标签里的文字需要加粗, 因为 Blink 为元素提供了默认的样式。Chromium 的源码里很充分的介绍了各个元素的默认表现。
排版
到目前为止, Renderer 进程已经知道了 document 的结构以及每个节点的样式, 但是这些任然不够我们去渲染一个页面。想象一下你在尝试通过电话给你的朋友描述一些图形, 仅知道 document 的结构以及每个节点的样式我们是没法描述这些图形的。
排版的目的就是去计算出每个元素的几何坐标。Blink 在主线程遍历 DOM 树和 computedStyle 然后创建出一颗 Layout tree。Layout tree 包含 x, y 这样的坐标信息以及排版盒子的尺寸。Layout tree 和 DOM 树有些像, 但是它只包含真正会渲染在页面上的内容。如果一个元素设置了 display: none; 这样的样式, 那么这个元素就不会出现在 Layout tree 中 (注意哦, 如果是 visibility: hidden; 的话那么元素也是会在 Layout tree 中的哦, 这也是这两个样式间的主要差别)。类似的, 我们用伪类去设置一些内容的时候, 这些伪类节点会出现在 Layout tree 中, 即便 DOM 树中并不包含他们。
计算页面的布局是一项具有挑战性的任务。即便是最常见的页面布局,例如从上到下的文档流布局,也必须考虑字体有多大以及在哪里换行,因为这些会影响段落的大小和形状以及下一个段落的位置。
绘制
拥有 DOM树、计算好的样式和布局仍然不足以渲染页面。假设你正在尝试复制一幅画。您知道元素的大小、形状和位置,但是你仍然需要判断绘制它们的顺序。
如果我们不考虑绘制顺序而只是简单的按照节点声明顺序来绘制的话, 当我们为某些元素设置了 z-index的情况下将导致渲染不正确。
绘制的主要流程就是主线程遍历布局树然后创建 paint records。paint records 是对绘画过程的记录, 比如“先画背景,然后画文字,再然后画个矩形”。如果你用过 canvas 的话可能会觉得似曾相识。
更新整个 Render Pipeline 是昂贵的操作
在渲染管线中要掌握的最重要的一点是, 在每一步中, 都会使用先前一个 stage 的结果来创建新数据。例如,假如布局树中发生某些变化,则需要从布局开始重新执行后面的整个过程。
假如你想要对元素进行动画处理, 那么浏览器必须在每一帧之间执行 pipeline。当我们的显示器每秒刷新屏幕 60 次 (60 fps), 你在屏幕上每帧移动物体的时候,动画才会对于人眼来说会显得平滑。但是,如果因为性能原因动生产中间帧的耗时太长的话,那么页面将出现“卡顿”的表现。
即使渲染生成帧的速度跟的上屏幕刷新, 你也要注意这些计算是在主线程上运行, 这意味着当运行 JavaScript 时, 渲染管线可能会因为你的 JavaScript 代码执行过于耗时而被拖慢。
针对这个问题的解决办法是你可以把 JavaScript 操作分成小块,并且使用 requestAnimationFrame() 安排 JavaScript 代码在每一帧运行。想更深入了解可以看看这篇博客。你还可以在 Web Workers 中运行 JavaScript 以避免阻塞主线程。
合成
如果让你来画一个页面, 你会怎么画?
既然浏览器知道了 document 的结构、每个元素的样式、页面的几何形状以及绘制顺序,那么它如何绘制页面呢?将全部这些信息转换为屏幕上的像素称为光栅化。 处理这类问题的一种简单方法就是对在 viewport 内部的内容进行光栅化。如果用户滚动页面,则移动光栅框架,并通过光栅化更多内容来填充缺失的部分。这就是 Chrome 刚发布时处理光栅化的方式。然而, 现代浏览器运行一个更复杂的过程,称为合成。
什么是合成
合成是一种将页面的各个部分分成图层、分别光栅化并在合成线程中合成为页面的技术。如果页面发生滚动,由于图层已经光栅化,它所要做的就是合成一个新帧。可以通过移动图层并合成新帧以相同的方式实现动画。
分层
为了找出哪些元素需要位于哪些层中,主线程会遍历 Layout tree 来创建 Layer tree。如果页面的某些部分应该是单独的层(例如一个抽屉组件), 但是却没有被单独分到一层,那么可以使用 will-change 属性向浏览器提示这个元素需要单独分层。
你可能会想那我直接为每个元素提供单独的图层岂不美哉。但是与每帧光栅化页面的一小部分相比,跨过大量的图层进行合成可能会导致操作速度变慢, 所以测量应用程序的渲染性能至关重要, 以便我们做出更好的权衡。
在主线程外
一旦创建了 Layer tree 并确定了绘制顺序,主线程就会将信息提交给合成线程。然后合成线程光栅化每一个图层。图层可能和整个页面一样大,因此合成线程将它们分成图块并将每个图块发送到光栅线程。光栅线程对每个图块进行光栅化并将其存储在 GPU 内存中。
合成线程可以为不同的光栅线程分配优先级,以便可以首先对视口内(或附近)的内容进行光栅化。图层还具有针对不同分辨率的多个图块,以处理放大操作之类的事情。 一旦对图块进行光栅化,合成器线程就会收集称为 draw quads 的图块信息来创建合成器框架。然后,合成线程通过 IPC 提交给浏览器进程。
合成的好处是它是在不涉及主线程的情况下完成的。合成线程不需要等待样式计算或 JavaScript 执行。这就是为什么仅合成动画被认为是获得流畅性能的最佳选择。如果需要重新计算布局或绘制,则必须涉及主线程。
Wrap Up
到此为止, 我们的 Renderer 进程之旅就结束了。在这篇文章中, 我们介绍了了从解析到合成的渲染管线以及中间的细节。希望现在你已经对渲染引擎有了更深一步的了解, 如果有任何问题, 欢迎向我提问~