浏览器渲染原理及性能优化技巧与实践
作者:梁京桢
发布日期:2020-08-21
0 引子
在复杂应用开发中,JavaScript单线程特性是最大的问题。当JavaScript忙于执行时,在浏览器上的用户交互会变得迟钝,甚至无响应。例如下面这个任务:

示例中,创建了一个20000行、6列的表格,表格中每个单元格都包含一个文本节点,所有DOM节点加起来总共有240000个。直接运行这个脚本,浏览器会有一段时间挂起,用户在此期间无法正常操作。

上图是在Chrome浏览器中运行示例脚本的性能分析图,浏览器挂起大约3秒,严重影响用户使用体验。那有没有什么办法避免这一问题呢?
解决性能问题之前,我们有必要先了解一下JavaScript的主要运行环境——浏览器及渲染原理。
1 JavaScript应用的运行环境
当我们开发一款JavaScript应用时,并不能单靠这些JS代码就能运行了,而是需要依赖它的执行环境。JS最初的运行环境是浏览器,虽然现在JS应用可能在很多环境中执行,比如Nodejs、WebView、GNOME等,但基本上也是借鉴了浏览器环境。所以我们将基于浏览器环境工作原理及组成展开讨论,希望能对改进JS应用质量以及解决实际问题提供帮助。
2 浏览器主要组件

浏览器组件主要包括:
(1) 用户界面:包括地址栏、前进/后退按钮、书签等。除了显示网页内容的主窗口外,其他部分都属于用户界面。
(2) 浏览器引擎:在用户界面和渲染引擎之间传送指令
(3) 渲染引擎:负责显示网页内容。渲染引擎解析HTML和CSS,并把解析后的内容显示在屏幕上。
(4) 网络:用于网络调用,比如XHR请求。其接口与平台无关,并为所有平台提供底层实现。
(5) JS引擎:用于解析和执行JavaScript代码。
(6) 用户界面后端:用于绘制基本的窗口小部件,例如复选框和窗口。后端暴露了平台无关的通用接口,其底层使用操作系统的用户界面方法。
(7) 数据存储:浏览器用于应用数据存储在本地,例如cookie,localStorage、indexDB、WebSQL和文件系统等。
由于渲染引擎既负责HTML和CSS的解析和显示,同时也是JavaScript应用的主要交互对象,因此本文将以渲染引擎为切入点,通过分析其渲染原理,进而提出相关的前端优化方法及优化实践。
3 渲染原理
3.1 渲染引擎
首先简单看下渲染引擎。渲染引擎可显示HTML、XML、文档和图片。借助插件,还可显示其他类型的的文档,例如PDF。
同JS引擎类似,不同的浏览器使用不同的渲染引擎,比较流行的有FireFox的Gecko,Safari的WebKit,以及Chrome(28+)和Opera(15+)的Blink。
3.2 渲染的基本流程
渲染引擎先从网络层获取请求文档的内容,然后执行下图所示的流程:

3.3 构建DOM树
渲染引擎首先对HTML文档进行解析,并对解析后的元素转换为DOM树的节点。例如下面的HTML文档:

解析后的DOM树如下:

3.4 构建CSSOM
CSSOM表示CSS对象模型(CSS Object Model)。当浏览器构建DOM树的时候,在head节点下遇到了link标签,指向外部样式表文件example.css。样式表可能会影响页面的渲染,所以浏览器会立即触发对该样式表资源的请求。假设样式表内容如下:

渲染引擎将样式表转换为 CSSOM树:

浏览器会从根节点开始,依次递归应用节点上设置的样式。比如例子中的span标签,其中的文字颜色被设置为红色,而p元素的的子元素span同时应用了更具体的样式不会被显示(display:none)。上图这棵树中只展示了我们自己定义的样式,实际上浏览器还提供了一套默认的样式——用户代理样式,当开发者没有显示提供样式的时候使用。
3.5 构建渲染树
有了CSSOM,结合HTML的可视化指令,就可以创建渲染树了。
那什么是渲染树?渲染树就是指将被绘制到屏幕上的可见元素有序构建出来的一棵树,渲染树确保浏览器绘制工作有序进行。
浏览器创建渲染树的步骤大致如下:
(1)从 DOM 树的根节点开始,遍历每个可见节点。一些节点是不可见的(比如,script 标签,meta 标签等等),然后会被忽略,因为它们并不会在渲染的输出中显示。一些节点通过样式隐藏然后也会被忽略。比如以上例子中的 span 节点,因为为其显式设置了 display: none 的样式。
(2)浏览器为每个可见节点应用相对应的 CSSOM 规则并应用这些样式规则。
(3)释放出包含内容及其经过计算的样式的可见节点。
4 布局
渲染器在创建完成并添加到渲染树时,并不包含位置和大小信息。计算这些值的过程称为布局,或者也叫重排。
HTML采用基于流的布局模型,所以大多数情况下只要一次遍历就能计算出几何信息。处于流中靠后位置的元素通常不会影响靠前位置元素的几何特征,因此布局可以按照从左到右、从上到下的顺序遍历文档,但是也是例外情况,比如HTML表格的计算就需要不止一次的遍历。
坐标系相对于根框架建立,使用上坐标和左坐标。
布局是递归进行的。它从根渲染器(对应HTML文档的元素)开始,然后递归遍历部分或所有的框架层次结构,为第一个需要计算的渲染器计算几何信息。
根渲染器的位置左边是(0,0),其尺寸为浏览器窗口可见区域(即视口)的大小。
布局又可分为全局布局和增量布局两种。
全局布局指触发了整个渲染树范围的布局,触发的原因可能为:
1. 影响所有渲染器的全局样式修改,如字体大小更改,
2. 屏幕大小调整。
而增量布局则一般是某个细小的局部渲染器或其子代发生变更而触发的,例如,来自网络的额外内容添加到DOM树之后 ,新的渲染器附加到了渲染树中。
4.1 绘制渲染树
这个阶段,系统遍历整个渲染树并调用渲染器的paint()方法,将渲染器的内容显示在屏幕上。
和布局类似,绘图也分全局和增量两种。
1. 全局绘制指整个树被重新绘制。
2. 增量只更改部分渲染器而不会影响到整颗树。更改后的渲染器将其在屏幕上的对应矩形区域设为无效。这会导致操作系统把它看成是一个需要重绘的区域并生成一个 paint 事件。操作系统会智能地把几个区域合并成一个以提升渲染性能。。
绘制过程是渐进式的。为了更好的用户体验,渲染引擎会尝试尽快在屏幕上显示内容。它不会等到所有的HTML被解析,才开始构建和布局渲染树。内容的一部分将被解析并显示出来,而其余部分则在这一过程中继续从网络中获取。
4.2 脚本和样式表的处理顺序
当解析器遇到 <script> 标签的时候会立即解析和执行该标签里面的代码。整个文档的解析会停止直到脚本执行完毕。意即该过程是同步的。
当 script 引用的是一个外部资源,必须首先获取该资源(也是同步的)。所有的解析会停止直到获取该脚本资源。
HTML5 添加了一个选项来异步加载该资源,这样就可以使用另外的线程来解析和执行该资源。IE 可以使用 defer 属性,其它可以使用 async 属性。IE10 以下使用 defer 属性,IE10 以上也可以使用 async 属性。
5 渲染引擎的线程
渲染引擎采用单线程。几乎所有操作(除了网络操作)都是在单线程中进行的。在Firefox和Safari中,该线程就是浏览器的主线程;而在Chrome中,该线程是标签进程的主线程。
浏览器的主线程是事件循环,并且是一个无限循环,永远处于接受处理状态,等待事件(如布局和绘制事件)发生并进行处理。
6 渲染性能优化
从浏览器的渲染过程可以看出,若要提高应用性能,主要应关注下面五个方面的内容:
(1)JavaScript-之前的文章中有介绍了编写不阻塞 UI ,高效的代码等等。谈到渲染时候,需要考虑 JavaScript 代码是如何和页面上的 DOM 元素进行交互的。JavaScript 会在界面上做很多的更改,特别是在单页应用中。
(2)样式计算-这个过程即应用样式规则到匹配选择器的元素上。一旦定义了样式规则,它们会应用于对应的元素,然后计算每个元素的最终样式。
(3)布局-一旦浏览器了解元素应用的样式规则,它会开始计算元素所占用的空间和其在浏览器屏幕上的显示位置。根据网页的布局模型定义一个元素的布局会影响到其它的元素。比如, 标签的宽度会影响到其子孙元素的宽度等等。这即意味着布局过程是相当耗时的。绘制是在多个图层完成的。
(4)绘制-该阶段即开始填充实际像素。这一过程包括绘制文字,颜色,图片,边框,阴影等所有每个元素的可见部分。
(5)合成-因为页面部分被绘制成潜在的多层,它们必须在屏幕以正确的顺序进行绘制,这样页面渲染才会正常。这是至关重要的,特别是对于那些重叠元素。
6.1 JavaScript优化
JavaScript 经常会在浏览器端触发视觉改变。尤其是在构建 SPA 的过程中会更多。
这里有一些优化 JavaScript 中部分代码来提升渲染效率的建议:
(1)避免使用 setTimeout 或者 setInterval 来进行视觉的更改。这些会在帧的某个时间点调用 callback,有可能是在帧的末尾。这样就会造成卡顿。必须在帧的开始触发视觉更改。
(2)把耗时的 JavaScript 移入WebWorker中执行。
(3)把一个大型的任务分割为多个小任务,然后根据不同的任务性质在 requestAnimationFrame ,setTimeout 或 setInterval 中执行。
6.2 CSS优化
通过添加和移除元素及更改属性等等修改 DOM 会导致浏览器重新计算元素样式及大多数情况整个页面或者部分页面的布局。
使用以下方法来优化渲染:
(1)减少选择器的复杂度。选择器复杂度会占用超过计算所需元素样式的 50% 的时间,剩余时间即构建样式本身。
(2)减少必须产生样式计算的元素的个数。本质上,直接更改少数元素的样式而不是使整个页面的样式失效。
6.3 布局优化
布局是很耗费浏览器性能的。考虑以下优化方案:
(1)尽可能减少布局的数量。当更改样式的时候,浏览器检查样式更改是否需要重新计算布局。一般而言更改诸如 width, height, left, top 等和几何学相关的属性会需要布局。所以,尽可能避免修改它们。
(2)尽可能使用 flexbox 来进行布局而不是老式的布局模型。它会渲染得更快并且会极大地提升网络应用的性能。
(3)避免强制同步布局。需要记住的是当运行 JavaScript的时候,上一帧的老的布局值是已知的且可以被查询得到。当访问 box.offsetHeight 这并不会造成性能问题。然而,如果在访问它之前更改它的样式(比如为元素动态添加样式类),浏览器将不得不首先应用样式更改然后运行布局计算样式。这将会非常耗时和耗资源,所以尽力避免这样做。
6.4 绘图优化
这经常会是所有任务中最耗时的,所以尽量避免触发绘制。优化方案:
(1)更改除 transfroms 或者 opacity 外的属性会触发绘制,所以尽量避免此类操作
(2)避免经常触发布局,因为更改元素的几何信息会更改元素的展示效果,从而触发绘制
(3)通过提升层和动画编排来减少绘制区域
7 优化实践
7.1 大数据量表格处理优化
现在回到引节提出的大数据量表格带来的性能问题。根据浏览器渲染原理,我们知道,示例脚本之所以会使浏览器较长时间处于挂起状态,是由于脚本创建了大量DOM节点并且频繁执行DOM操作导致渲染引擎耗费大量时间重新计算样式、执行布局造成的。这一原因也可以从下面图中证实:

图中显示计算样式耗费约600毫秒,而布局耗费了1.53秒,整个HTML解析时间大约2.43秒。GPU也表明系统在频繁地进行计算。
现在看下如何进行优化。
根据前面的优化技巧,我们可以把一个大型的任务分割为多个小任务,然后根据不同的任务性质在setTimeout 或 setInterval 中执行以提升渲染效率。因此 我们对原示例代码作下修改:

我们将原操作分解为了10次小操作,每次分别创建DOM节点,而浏览器也将从原来的一次布局绘制变为10次。下图展示了这一修改带来的变化:

可以看到,白屏时间缩短到250毫秒,样式计算、布局、绘图等也由原先的一次变成了多次执行,GPU计算次数也大大减少,用户体验有了显著提升。
8 参考资料
浏览器渲染原理及性能优化技巧与实践
0 引子
在复杂应用开发中,JavaScript单线程特性是最大的问题。当JavaScript忙于执行时,在浏览器上的用户交互会变得迟钝,甚至无响应。例如下面这个任务:
示例中,创建了一个20000行、6列的表格,表格中每个单元格都包含一个文本节点,所有DOM节点加起来总共有240000个。直接运行这个脚本,浏览器会有一段时间挂起,用户在此期间无法正常操作。
上图是在Chrome浏览器中运行示例脚本的性能分析图,浏览器挂起大约3秒,严重影响用户使用体验。那有没有什么办法避免这一问题呢?
解决性能问题之前,我们有必要先了解一下JavaScript的主要运行环境——浏览器及渲染原理。
1 JavaScript应用的运行环境
当我们开发一款JavaScript应用时,并不能单靠这些JS代码就能运行了,而是需要依赖它的执行环境。JS最初的运行环境是浏览器,虽然现在JS应用可能在很多环境中执行,比如Nodejs、WebView、GNOME等,但基本上也是借鉴了浏览器环境。所以我们将基于浏览器环境工作原理及组成展开讨论,希望能对改进JS应用质量以及解决实际问题提供帮助。
2 浏览器主要组件
浏览器组件主要包括:
(1) 用户界面:包括地址栏、前进/后退按钮、书签等。除了显示网页内容的主窗口外,其他部分都属于用户界面。
(2) 浏览器引擎:在用户界面和渲染引擎之间传送指令
(3) 渲染引擎:负责显示网页内容。渲染引擎解析HTML和CSS,并把解析后的内容显示在屏幕上。
(4) 网络:用于网络调用,比如XHR请求。其接口与平台无关,并为所有平台提供底层实现。
(5) JS引擎:用于解析和执行JavaScript代码。
(6) 用户界面后端:用于绘制基本的窗口小部件,例如复选框和窗口。后端暴露了平台无关的通用接口,其底层使用操作系统的用户界面方法。
(7) 数据存储:浏览器用于应用数据存储在本地,例如cookie,localStorage、indexDB、WebSQL和文件系统等。
由于渲染引擎既负责HTML和CSS的解析和显示,同时也是JavaScript应用的主要交互对象,因此本文将以渲染引擎为切入点,通过分析其渲染原理,进而提出相关的前端优化方法及优化实践。
3 渲染原理
3.1 渲染引擎
首先简单看下渲染引擎。渲染引擎可显示HTML、XML、文档和图片。借助插件,还可显示其他类型的的文档,例如PDF。
同JS引擎类似,不同的浏览器使用不同的渲染引擎,比较流行的有FireFox的Gecko,Safari的WebKit,以及Chrome(28+)和Opera(15+)的Blink。
3.2 渲染的基本流程
渲染引擎先从网络层获取请求文档的内容,然后执行下图所示的流程:

3.3 构建DOM树
渲染引擎首先对HTML文档进行解析,并对解析后的元素转换为DOM树的节点。例如下面的HTML文档:
解析后的DOM树如下:
3.4 构建CSSOM
CSSOM表示CSS对象模型(CSS Object Model)。当浏览器构建DOM树的时候,在head节点下遇到了link标签,指向外部样式表文件example.css。样式表可能会影响页面的渲染,所以浏览器会立即触发对该样式表资源的请求。假设样式表内容如下:

渲染引擎将样式表转换为 CSSOM树:

浏览器会从根节点开始,依次递归应用节点上设置的样式。比如例子中的span标签,其中的文字颜色被设置为红色,而p元素的的子元素span同时应用了更具体的样式不会被显示(display:none)。上图这棵树中只展示了我们自己定义的样式,实际上浏览器还提供了一套默认的样式——用户代理样式,当开发者没有显示提供样式的时候使用。
3.5 构建渲染树
有了CSSOM,结合HTML的可视化指令,就可以创建渲染树了。
那什么是渲染树?渲染树就是指将被绘制到屏幕上的可见元素有序构建出来的一棵树,渲染树确保浏览器绘制工作有序进行。
浏览器创建渲染树的步骤大致如下:
(1)从 DOM 树的根节点开始,遍历每个可见节点。一些节点是不可见的(比如,script 标签,meta 标签等等),然后会被忽略,因为它们并不会在渲染的输出中显示。一些节点通过样式隐藏然后也会被忽略。比如以上例子中的 span 节点,因为为其显式设置了 display: none 的样式。
(2)浏览器为每个可见节点应用相对应的 CSSOM 规则并应用这些样式规则。
(3)释放出包含内容及其经过计算的样式的可见节点。
4 布局
渲染器在创建完成并添加到渲染树时,并不包含位置和大小信息。计算这些值的过程称为布局,或者也叫重排。
HTML采用基于流的布局模型,所以大多数情况下只要一次遍历就能计算出几何信息。处于流中靠后位置的元素通常不会影响靠前位置元素的几何特征,因此布局可以按照从左到右、从上到下的顺序遍历文档,但是也是例外情况,比如HTML表格的计算就需要不止一次的遍历。
坐标系相对于根框架建立,使用上坐标和左坐标。
布局是递归进行的。它从根渲染器(对应HTML文档的元素)开始,然后递归遍历部分或所有的框架层次结构,为第一个需要计算的渲染器计算几何信息。
根渲染器的位置左边是(0,0),其尺寸为浏览器窗口可见区域(即视口)的大小。
布局又可分为全局布局和增量布局两种。
全局布局指触发了整个渲染树范围的布局,触发的原因可能为:
1. 影响所有渲染器的全局样式修改,如字体大小更改,
2. 屏幕大小调整。
而增量布局则一般是某个细小的局部渲染器或其子代发生变更而触发的,例如,来自网络的额外内容添加到DOM树之后 ,新的渲染器附加到了渲染树中。
4.1 绘制渲染树
这个阶段,系统遍历整个渲染树并调用渲染器的paint()方法,将渲染器的内容显示在屏幕上。
和布局类似,绘图也分全局和增量两种。
1. 全局绘制指整个树被重新绘制。
2. 增量只更改部分渲染器而不会影响到整颗树。更改后的渲染器将其在屏幕上的对应矩形区域设为无效。这会导致操作系统把它看成是一个需要重绘的区域并生成一个 paint 事件。操作系统会智能地把几个区域合并成一个以提升渲染性能。。
绘制过程是渐进式的。为了更好的用户体验,渲染引擎会尝试尽快在屏幕上显示内容。它不会等到所有的HTML被解析,才开始构建和布局渲染树。内容的一部分将被解析并显示出来,而其余部分则在这一过程中继续从网络中获取。
4.2 脚本和样式表的处理顺序
当解析器遇到 <script> 标签的时候会立即解析和执行该标签里面的代码。整个文档的解析会停止直到脚本执行完毕。意即该过程是同步的。
当 script 引用的是一个外部资源,必须首先获取该资源(也是同步的)。所有的解析会停止直到获取该脚本资源。
HTML5 添加了一个选项来异步加载该资源,这样就可以使用另外的线程来解析和执行该资源。IE 可以使用 defer 属性,其它可以使用 async 属性。IE10 以下使用 defer 属性,IE10 以上也可以使用 async 属性。
5 渲染引擎的线程
渲染引擎采用单线程。几乎所有操作(除了网络操作)都是在单线程中进行的。在Firefox和Safari中,该线程就是浏览器的主线程;而在Chrome中,该线程是标签进程的主线程。
浏览器的主线程是事件循环,并且是一个无限循环,永远处于接受处理状态,等待事件(如布局和绘制事件)发生并进行处理。
6 渲染性能优化
从浏览器的渲染过程可以看出,若要提高应用性能,主要应关注下面五个方面的内容:
(1)JavaScript-之前的文章中有介绍了编写不阻塞 UI ,高效的代码等等。谈到渲染时候,需要考虑 JavaScript 代码是如何和页面上的 DOM 元素进行交互的。JavaScript 会在界面上做很多的更改,特别是在单页应用中。
(2)样式计算-这个过程即应用样式规则到匹配选择器的元素上。一旦定义了样式规则,它们会应用于对应的元素,然后计算每个元素的最终样式。
(3)布局-一旦浏览器了解元素应用的样式规则,它会开始计算元素所占用的空间和其在浏览器屏幕上的显示位置。根据网页的布局模型定义一个元素的布局会影响到其它的元素。比如, 标签的宽度会影响到其子孙元素的宽度等等。这即意味着布局过程是相当耗时的。绘制是在多个图层完成的。
(4)绘制-该阶段即开始填充实际像素。这一过程包括绘制文字,颜色,图片,边框,阴影等所有每个元素的可见部分。
(5)合成-因为页面部分被绘制成潜在的多层,它们必须在屏幕以正确的顺序进行绘制,这样页面渲染才会正常。这是至关重要的,特别是对于那些重叠元素。
6.1 JavaScript优化
JavaScript 经常会在浏览器端触发视觉改变。尤其是在构建 SPA 的过程中会更多。
这里有一些优化 JavaScript 中部分代码来提升渲染效率的建议:
(1)避免使用 setTimeout 或者 setInterval 来进行视觉的更改。这些会在帧的某个时间点调用 callback,有可能是在帧的末尾。这样就会造成卡顿。必须在帧的开始触发视觉更改。
(2)把耗时的 JavaScript 移入WebWorker中执行。
(3)把一个大型的任务分割为多个小任务,然后根据不同的任务性质在 requestAnimationFrame ,setTimeout 或 setInterval 中执行。
6.2 CSS优化
通过添加和移除元素及更改属性等等修改 DOM 会导致浏览器重新计算元素样式及大多数情况整个页面或者部分页面的布局。
使用以下方法来优化渲染:
(1)减少选择器的复杂度。选择器复杂度会占用超过计算所需元素样式的 50% 的时间,剩余时间即构建样式本身。
(2)减少必须产生样式计算的元素的个数。本质上,直接更改少数元素的样式而不是使整个页面的样式失效。
6.3 布局优化
布局是很耗费浏览器性能的。考虑以下优化方案:
(1)尽可能减少布局的数量。当更改样式的时候,浏览器检查样式更改是否需要重新计算布局。一般而言更改诸如 width, height, left, top 等和几何学相关的属性会需要布局。所以,尽可能避免修改它们。
(2)尽可能使用 flexbox 来进行布局而不是老式的布局模型。它会渲染得更快并且会极大地提升网络应用的性能。
(3)避免强制同步布局。需要记住的是当运行 JavaScript的时候,上一帧的老的布局值是已知的且可以被查询得到。当访问 box.offsetHeight 这并不会造成性能问题。然而,如果在访问它之前更改它的样式(比如为元素动态添加样式类),浏览器将不得不首先应用样式更改然后运行布局计算样式。这将会非常耗时和耗资源,所以尽力避免这样做。
6.4 绘图优化
这经常会是所有任务中最耗时的,所以尽量避免触发绘制。优化方案:
(1)更改除 transfroms 或者 opacity 外的属性会触发绘制,所以尽量避免此类操作
(2)避免经常触发布局,因为更改元素的几何信息会更改元素的展示效果,从而触发绘制
(3)通过提升层和动画编排来减少绘制区域
7 优化实践
7.1 大数据量表格处理优化
现在回到引节提出的大数据量表格带来的性能问题。根据浏览器渲染原理,我们知道,示例脚本之所以会使浏览器较长时间处于挂起状态,是由于脚本创建了大量DOM节点并且频繁执行DOM操作导致渲染引擎耗费大量时间重新计算样式、执行布局造成的。这一原因也可以从下面图中证实:

图中显示计算样式耗费约600毫秒,而布局耗费了1.53秒,整个HTML解析时间大约2.43秒。GPU也表明系统在频繁地进行计算。
现在看下如何进行优化。
根据前面的优化技巧,我们可以把一个大型的任务分割为多个小任务,然后根据不同的任务性质在setTimeout 或 setInterval 中执行以提升渲染效率。因此 我们对原示例代码作下修改:
我们将原操作分解为了10次小操作,每次分别创建DOM节点,而浏览器也将从原来的一次布局绘制变为10次。下图展示了这一修改带来的变化:
可以看到,白屏时间缩短到250毫秒,样式计算、布局、绘图等也由原先的一次变成了多次执行,GPU计算次数也大大减少,用户体验有了显著提升。
8 参考资料
1. 《浏览器工作原理:新式网络浏览器幕后揭秘》
2. 《How JavaScript works:the rendering engine and tips to optimize its performance》
3. 《JavaScript忍者秘籍》