解析与渲染

Web 解析与渲染示意图

Web 解析与渲染

导航过程完成之后,浏览器进程把数据交给了渲染进程,渲染进程负责 tab 内的所有事情,核心目的就是将 HTML/CSS/JS 代码,转化为用户可进行交互的 Web 页面。渲染进程中,包含线程分别是:一个主线程(main thread)、多个工作线程(work thread)、一个合成器线程(compositor thread)、多个光栅化线程(raster thread)。

渲染进程包含的子线程

DOM 构建与资源子加载

当渲染进程接受到导航的确认信息后,开始接受来自浏览器进程的数据,这个时候,主线程会解析数据转化为 DOM(Document Object Model)对象。DOM 为 Web 开发人员通过 JavaScript 与网页进行交互的数据结构及 API。在构建 DOM 的过程中,会解析到图片、CSS、JavaScript 脚本等资源,这些资源是需要从网络或者缓存中获取的,主线程在构建 DOM 过程中如果遇到了这些资源,逐一发起请求去获取,而为了提升效率,浏览器也会运行预加载扫描(preload scanner)程序,如果如果 HTML 中存在 img、link 等标签,预加载扫描程序会把这些请求传递给 Browser Process 的 network thread 进行资源下载。

资源预加载

JavaScript 解析与编译

构建 DOM 过程中,如果遇到 <script> 标签,渲染引擎会停止对 HTML 的解析,而去加载执行 JS 代码,原因在于 JS 代码可能会改变 DOM 的结构(比如执行 document.write()等 API)。不过开发者其实也有多种方式来告知浏览器应对如何应对某个资源,比如说如果在 <script> 标签上添加了 async 或 defer 等属性,浏览器会异步的加载和执行 JS 代码,而不会阻塞渲染。

V8 及 Chrome 团队也是一直致力于提升 JavaScript 引擎的解析与编译性能。较老版本的 Chrome 会等待脚本全部下载完毕后再进行解析,而新版本的 Chrome 中,会使用独立地工作线程来解析 async 或者 deferred 的脚本,而不会再去阻塞主线程。并且在许多真实场景下,V8 解析的速度会远快于下载速度,因此脚本往往在下载以后的数毫秒内即可以完成解析与编译。

V8 多线程解析示意图

样式计算(Style Calculation)

DOM 树只是我们页面的结构,我们要知道页面长什么样子,我们还需要知道 DOM 的每一个节点的样式。主线程在解析页面时,遇到 <style> 标签或者 <link> 标签的 CSS 资源,会加载 CSS 代码,根据 CSS 代码确定每个 DOM 节点的计算样式(computed style)。计算样式是主线程根据 CSS 样式选择器(CSS selectors)计算出的每个 DOM 元素应该具备的具体样式,即使你的页面没有设置任何自定义的样式,浏览器也会提供其默认的样式。

样式计算

布局(Layout)

DOM 树和计算样式完成后,我们还需要知道每一个节点在页面上的位置,布局(Layout)其实就是找到所有元素的几何关系的过程。主线程会遍历 DOM 及相关元素的计算样式,构建出包含每个元素的页面坐标信息及盒子模型大小的布局树(Render Tree),遍历过程中,会跳过隐藏的元素(display: none),另外,伪元素虽然在 DOM 上不可见,但是在布局树上是可见的。

布局

绘制(Paint)

布局 layout 之后,我们知道了不同元素的结构,样式,几何关系,我们要绘制出一个页面,我们要需要知道每个元素的绘制先后顺序,在绘制阶段,主线程会遍历布局树(layout tree),生成一系列的绘画记录(paint records)。绘画记录可以看做是记录各元素绘制先后顺序的笔记。

绘制

合成(Compositing)

文档结构、元素的样式、元素的几何关系、绘画顺序,这些信息我们都有了,这个时候如果要绘制一个页面,我们需要做的是把这些信息转化为显示器中的像素,这个转化的过程,叫做光栅化(rasterizing)。那我们要绘制一个页面,最简单的做法是只光栅化视口内(viewport)的网页内容,如果用户进行了页面滚动,就移动光栅帧(rastered frame)并且光栅化更多的内容以补上页面缺失的部分,如下:

非合成

Chrome 第一个版本就是采用这种简单的绘制方式,这一方式唯一的缺点就是每当页面滚动,光栅线程都需要对新移进视图的内容进行光栅化,这是一定的性能损耗,为了优化这种情况,Chrome 采取一种更加复杂的叫做合成(compositing)的做法。那么,什么是合成?合成是一种将页面分成若干层,然后分别对它们进行光栅化,最后在一个单独的线程合成线程(compositor thread)里面合并成一个页面的技术。当用户滚动页面时,由于页面各个层都已经被光栅化了,浏览器需要做的只是合成一个新的帧来展示滚动后的效果罢了。页面的动画效果实现也是类似,将页面上的层进行移动并构建出一个新的帧即可。

合成

为了实现合成技术,我们需要对元素进行分层,确定哪些元素需要放置在哪一层,主线程需要遍历渲染树来创建一棵层次树(Layer Tree),对于添加了 will-change CSS 属性的元素,会被看做单独的一层,没有 will-change CSS 属性的元素,浏览器会根据情况决定是否要把该元素放在单独的层。

Renderer Process

你可能会想要给页面上所有的元素一个单独的层,然而当页面的层超过一定的数量后,层的合成操作要比在每个帧中光栅化页面的一小部分还要慢,因此衡量你应用的渲染性能是十分重要的一件事情。一旦 Layer Tress 被创建,渲染顺序被确定,主线程会把这些信息通知给合成器线程,合成器线程开始对层次数的每一层进行光栅化。有的层的可以达到整个页面的大小,所以合成线程需要将它们切分为一块又一块的小图块(tiles),之后将这些小图块分别进行发送给一系列光栅线程(raster threads)进行光栅化,结束后光栅线程会将每个图块的光栅结果存在 GPU Process 的内存中。

光栅

为了优化显示体验,合成线程可以给不同的光栅线程赋予不同的优先级,将那些在视口中的或者视口附近的层先被光栅化。当图层上面的图块都被栅格化后,合成线程会收集图块上面叫做绘画四边形(draw quads)的信息来构建一个合成帧(compositor frame)。

  • 绘画四边形:包含图块在内存的位置以及图层合成后图块在页面的位置之类的信息。
  • 合成帧:代表页面一个帧的内容的绘制四边形集合。

以上所有步骤完成后,合成线程就会通过 IPC 向浏览器进程(browser process)提交(commit)一个渲染帧。这个时候可能有另外一个合成帧被浏览器进程的 UI 线程(UI thread)提交以改变浏览器的 UI。这些合成帧都会被发送给 GPU 从而展示在屏幕上。如果合成线程收到页面滚动的事件,合成线程会构建另外一个合成帧发送给 GPU 来更新页面。

合成线程 GPU 更新

合成的好处在于这个过程没有涉及到主线程,所以合成线程不需要等待样式的计算以及 JavaScript 完成执行。这就是为什么合成器相关的动画最流畅,如果某个动画涉及到布局或者绘制的调整,就会涉及到主线程的重新计算,自然会慢很多。