console.info

本文翻译自 Google 的官方文档,该系列共 4 篇文章,从内部观察现代浏览器 (Chrome),同时解答了浏览器的内部架构,讲述了浏览器从输入 url 到页面呈现的全过程。

原文链接: inside-browser-part3


前言

这是 从内部观察现代浏览器 (Chrome) 系列文章的第三篇,在先前文章中,我们了解了浏览器的多进程架构以及一次导航到底发生了什么。在这篇文章中,我们将会了解到渲染进程到底做了什么。

渲染进程直接与 Web 页面的性能挂钩。由于渲染进程涉及到很多内容,处理了很多逻辑,并不是一篇文章能够完整描述的,这篇文章仅是一个概述。如果你想更深入的了解,可以查看the Performance section of Web Fundamentals这篇文章。

主要构成

所有选项卡内部的逻辑,都由渲染进程处理。在渲染进程中,主线程处理了绝大部分的网页代码。由 WorkerService Worker 注册的 javascript 代码会有单独的 Worker 线程处理。Compositor (合成器)和 Raster (光栅)线程确保了页面快速平滑的呈现。

渲染进程最重要的任务就是将 HTML CSS Javascript 代码转换成可以和用户交互的界面。

渲染进程拥有的几个主要的线程
渲染进程拥有的几个主要的线程

解析

构建 DOM

当渲染进程接收到导航的确认信息,并开始接收响应数据(HTML data)时,主线程就会开始解析数据(HTML data),生成 DOM(Document Object Model) 对象。

DOM 是浏览器对页面和数据结构的表示,通过浏览器提供的 API ,用户可以获取到这些 DOM 节点。

浏览器根据HTML 标准来解析 HTML 文档。你可能注意到了,浏览器解析 HTML 文档从不会报错。比如,当缺少一个结束标签(</p>)时,浏览器会补上;错误嵌套发生时 Hi! <b>I'm <i>Chrome</b>!</i>(b 标签应该在 i 标签之后结束),会被浏览器解析成 Hi! <b>I'm <i>Chrome</i></b><i>!</i> 。这是因为 HTML 规范优雅地处理了这些错误。如果你对这个过程好奇的话,可以通过阅读 An introduction to error handling and strange cases in the parser 来了解。

加载子资源

一个网页生成时通常会使用额外的资源(比如图片、CSSJavaScript),这些资源文件都会通过网络加载(或通过缓存)。当主线程在解析数据并构建 DOM 树时,会逐一找出这些文件并加载。为了加速页面的显示,"预加载扫描(preload scanner)" 会同时在后台运行。如果页面上有 <img> 或是 <link> 之类的需要加载的资源,当解析器生成相应的标签时,预加载器就会通知浏览器进程中的网线线程去加载资源。

主线程解析 HTML 并生成 DOM 树
主线程解析 HTML 并生成 DOM 树

阻塞解析

当解析器碰到 \<script\> 标签时,它会停止继续解析剩余的 HTML 文档,加载相应的 javascript 代码并执行。

为什么?

因为 javascript 代码能够改变文档结构。比如 document.write() 改变了整个文档的结构。这就是为什么 HTML 解析器需要等待 javascript 代码执行结束,才能继续进行 HTML 解析的原因。如果想进一步了解在 javascript 执行过程中发生了什么,可以查看the V8 team has talks and blog posts on this

提示浏览器如何加载资源

web 开发者有多种方式让浏览器更加智能的加载资源,如果 javascript 代码没有使用 document.write() ,开发者可以在 <script> 标签上添加 asyncdefer 属性,那么浏览器就会通过异步的形式来加载相应的资源,并且不会阻塞解析器的执行。同时在适当情况下可以使用 JavaScript module ,还可以通过 <link rel="preload"> 来告诉浏览器,该资源在当前的导航中会被用到,需要尽快的加载。你可以通过阅读 Resource Prioritization – Getting the Browser to Help You. 来了解具体的情况。

样式计算

仅仅有了 DOM 树结构,浏览器并不能确定页面的呈现,因为我们还可以通过 CSS 为元素设置更丰富样式。主线程会解析 CSS 并通过 CSS 选择器来确定出每个 DOM 节点的样式信息。你可以通过 DevTools 来查看 DOM 节点的样式信息。

主线程解析 CSS 并应用样式
主线程解析 CSS 并应用样式

即使页面没有使用任何的 CSS ,每个 DOM 节点也会有默认的样式信息。比如,H1 标签就比 H2 标签大;每个元素都有不同的 marginpadding 。可以通过 Chrome 源码 来查看 Chrome 的默认样式信息。

布局

在渲染进程知道了文档的 DOM 树和结点的样式信息后,它任然不能将页面呈现在显示屏上。试想一下,如果你通过手机向你的朋友描述一副图画。"这里有一个红色的圆,哪里有一个蓝色的方块",不足以传递这幅画的内容,因为你的朋友仍然不知道到这个圆的具体大小和位置信息。

通过电话告诉朋友一幅画
通过电话告诉朋友一幅画

确定 DOM 树节点的几何信息这一过程叫布局。主线程会遍历所有的 DOM 节点,根据样式信息计算并创建布局树(布局树上的节点拥有坐标信息和几何信息),但是 DOM 上一些不呈现的节点将不会出现在布局树上。比如当一个元素拥有 display: none 的属性,就不属于布局树上的节点。但是如果某个元素使用了伪元素比如 p::before{content:"Hi!"} ,虽然伪节点并不在 DOM 树上,但却是属于布局树上的节点。

主线程通过计算 DOM 树和样式信息确定布局树
主线程通过计算 DOM 树和样式信息确定布局树

确定页面的布局是一项具有挑战的任务。即使最简单的布局,比如块级元素从上到下的布局也必须考虑元素内部字体的大小,该如何换行,因为这些都会影响到该元素的形状和大小。甚至可能影响到下一个块级元素的位置。

段落的盒模型因为换行而产生变化。

CSS 能够设置元素的浮动,定位,而这些设置又会覆盖其他元素;甚至还会影响文本的显示方式等。所以布局是一个极大的工程。在 Chrome ,有一整个团队去解决这个问题,如果深入了解布局内容,可以查看 few talks form BlinkOn Conference

绘画

拥有 DOM 树,样式信息和已经生成的布局树仍不足够渲染出一个页面。这么说吧,如果你想要复制一幅画,光知道上面的信息当然是不够的,你还得知道画中各个元素的绘画先后顺序,因为后画的元素会覆盖先画的元素。页面的呈现也是如此。

小女孩确定绘画的先后顺序
小女孩确定绘画的先后顺序

比如,某些元素有用 z-index 属性,那么如果从上到下绘画元素,明显是错误的。

依次绘画元素导致错误的结果
依次绘画元素导致错误的结果

在绘画这个步骤中,主线程遍历整个布局树,生成一系类的绘制记录。绘制记录记录了绘画的步骤,比如:"先画背景,接着画出文字,最后画出一个矩形"。如果你曾经使用过 canvas 进行绘画,那么你对这个步骤应该很熟悉。

主线程通过编辑布局树生成绘画记录
主线程通过编辑布局树生成绘画记录

更新消耗

需要着重关注的一点:由于从 DOM & CSS 合成布局树,到布局树生成绘画记录是一系列的过程,在这个过程中,每一步都需要前一步来生成数据。如果 DOMCSS 结构发生改变,那么还需要通过以上步骤生成受影响部分的绘制记录。

DOM& 样式信息、布局树、绘制记录顺序生成

当网页开发者为元素设置了动画,那么浏览器需要在每一帧之间执行这些操作。大多数显示屏的每秒刷新 60 次。如果开发者对每一帧都进行了处理,那么页面效果在用户眼中就会变的平滑流畅。但是如果浏览器的某一帧没有进行处理或者被忽略掉了,那么呈现在用户眼中的效果就是卡顿。

动画效果
动画效果

即使页面绘制的过程可以确保在一帧内完成,但是计算代码也是运行在主线程中,这就意味着页面的绘制可能会被 javascript 代码所阻塞。

动画顺序执行,但是被 javascript 代码所阻塞
动画顺序执行,但是被 javascript 代码所阻塞

为了解决这种情况,开发者可以将代码切割成几小块,用 requestAnimationFrame() 顺序调用。想更深入的了解可以查看 Optimize JavaScript Execution 这篇文章。当然使用 Web Work 也是一个不错的选择。

将代码切割成几小块执行
将代码切割成几小块执行

合成

如何绘制一个页面

到目前为止浏览器知道了文档的结构,每个元素的样式信息几何形状,以及绘制顺序。那么它是如何绘制的呢?

光栅化 - 将几何信息转换为屏幕上的像素的过程。

一种简单的方式就是将视窗内(页面在浏览器上呈现的部分)的内容光栅化,如果用户滚动页面,则移动光栅,填充缺失的部分。这就是 Chrome 首次发布时处理光栅化的方式。 但是,现代浏览器的处理方式更为复杂,被称为合成。

使用简单方式处理动画

什么是合成

合成就是一种将页面的各个部分层,并分别对它们进行光栅化,并由单独的线程负责负责将他们组成一个页面。当滚动行为发生时,由于各层元素已经光栅化,合成线程仅需要将这些层合成,从新合成一个新帧即可。通过移动分层,也可以实现动画效果。

通过合成实现动画

你可以通过 Chrome 中的 DevTools 了解浏览器的具体分成信息。

分层

为了确定页面元素属于哪个分层,主线程会遍历布局树,同时创建分层树。如果页面上的某部分确定是属于单独一个分层(例如侧滑菜单),你可以通过 will-change 属性来告知浏览器。

主线程遍历布局树生成分层树
主线程遍历布局树生成分层树

理想情况下,我们可以给每个元素一个单独的分层,但是如果对过多的图层进行合并,却可能会导致合成缓慢,甚至不如不分层。因此,测量页面的呈现性能非常重要。可以通过阅读 Stick to Compositor-Only Properties and Manage Layer Count 这篇文章,了解如何提升页面渲染性能。

分离栅格化和合成过程

当分层树以及绘制顺序确定后,主线程会通知合成线程进行页面合成。合成线程会将每个分层光栅化。一个分层可能会有整个页面那么大,因此合成线程会将分层内容进行切割分块的发送给光栅线程,光栅线程处理后,将数据存储在 GPU 内存中。

光栅线程生成信息传入到 GPU 中
光栅线程生成信息传入到 GPU 中

合成线程可以对不同的光栅线程进行优先级排序,视口(或附近)的元素会优先被光栅化。图层还具有多个不同分辨率的倾斜度,可以处理放大等内容。

一旦元素被光栅化,就会被合成线程收集,在元素都被光栅化后,合成线程就会创建一个合成帧。

当合成线程生成合成帧后,会通过 IPC 通知浏览器进程。于此同时,另一些来着 UI 线程或是扩展进程的合成帧也会被发送到 GPU 从而在屏幕上展示。当页面触发了滚动事件,合成线程就会生成另一个合成帧,并发送到 GPU

合成线程生成合成帧,并发送到 GPU
合成线程生成合成帧,并发送到 GPU

合成线程这样设置的原因在于:它不在主线程中执行,合成进程不需要等待 DOM 信息的计算以及 javascript 的执行。这就是为什么仅合成动画被认为是平滑性能的最佳选择。如果元素的信息必须通过计算获得,那么就必须在主线程执行后,合成线程才能继续生成合成帧。

总结

在这篇文章中,我们了解到了页面渲染从解析到合成的步骤。希望你现在有兴趣去了解更多关于网站优化的内容。

在下一篇文章中,我们将更加详细的介绍合成线程发生的具体内容,并将会知道当用户触发 mouse move click 事件时,会发生什么。