网站性能优化

关键渲染路径

What

关键渲染路径(Critical Rendering Path)指的是:
「浏览器所经历的一系列步骤,从而将 HTML、CSS 和 JavaScript 转换为在屏幕上呈现的像素内容」
我们要做的,就是优化关键渲染路径,这样就能提高网页呈现的速度,从而使用户和老板更满意。

网页生成过程

说白了,「关键渲染路径」即「网页生成的过程」,简要概述就是:

  1. 处理 HTML 标记并构建 DOM 树
  2. 处理 CSS 标记并构建 CSSOM 树
  3. 将 DOM 与 CSSOM 合并成一个渲染树
  4. 根据渲染树来布局,以计算每个节点的几何信息
  5. 将各个节点绘制到屏幕上

将 HTML 转为 DOM

文档对象模型(DOM)存储的是「页面内容」及每个节点「属性方面的数据」。DOM 中对象之间的关系展示了父子节点以及兄弟节点。

注意
DOM 的构建过程是逐步实现的,意味着一旦获得了 HTML 就可以开始构建 DOM 了,不需要等待整个 HTML 页面加载完毕后再去构建 DOM 。

将 CSS 转为 CSSOM

合并渲染树

完成了 DOM 树和 CSSOM 树的构建以后,就要将它们合并成一个渲染树。

注意:
某些节点如果通过 CSS 隐藏,那么在渲染树中也会被忽略,例如我们在 HTML 中编写的 span 节点,它就不会出现在渲染树中。因为有一个 CSS 规则在 span 上设置了 display: none 属性。

布局与绘制

在渲染树构建阶段,我们已经计算了哪些节点应该是可见的以及它们的计算样式,所以在布局阶段,我们就需要计算它们在设备视口内的确切位置和大小。这个过程称为布局,或者叫做重排。

最后,既然我们知道了哪些节点可见、它们的计算样式以及几何信息,我们终于可以将这些信息传递给最后一个阶段:将渲染树中的每个节点转换成屏幕上的实际像素。这一步通常称为“绘制”或“栅格化”。到此,浏览器对网页的第一次生成过程已经结束。

而我们的前端网站性能优化,也就是针对整个「关键渲染路径」而言的。只有最大限度的缩短上面第 1 至 5 步耗费的总时间,才能尽快的将内容渲染到屏幕上,此外还能缩短首屏的加载时间。

网页加载的优化

优化 DOM

压缩+删除注释

为了将 HTML 文件尽可能快的传输给浏览器(客户端),我们需要压缩文件的大小,例如下面 html 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
/* reset */
* {
padding: 0;
margin: 0;
list-style: none;
}
</style>
</head>
<body>
<!-- 头部 -->
<header>...</header>
<!-- 正文 -->
<main>...</main>
<!-- 底部 -->
<footer>...</footer>
</body>
</html>

这段代码包含了非常多的注释,这些注释对开发者来说可能有用,然而浏览器在遇到注释时却会忽略它们的,所以根本没必要展示给浏览器,我们完全可以移除它们。另外空格和换行也是个大问题,它会增加我们文件的体积,所以我们需要利用一些工具去压缩它。

上图是在删除掉所有注释,并使用 在线 JS 压缩工具 压缩后的文件大小对比。当然,这种策略同样适用于 CSS 文件,你可以使用类似的步骤对 CSS 进行注释的删除与压缩。

如果你使用 webpack 构建你的应用,你可以使用下面插件来优化 html、css 和 js:

  • optimize-css-assets-webpack-plugin(优化 css)
  • html-webpack-plugin(配置与优化 html)
  • webpack-parallel-uglify-plugin(优化 js)

优化 CSSOM

CSS 在默认情况下是阻塞渲染的一类资源,也就是说浏览器在 CSSOM 构建完成以前是不会渲染任何已处理的内容的,所以我们必须对 CSS 进行精简。
由于浏览器必须同时具有 DOM 以及 CSSOM 才能构建渲染树,所以 HTML 和 CSS 都是阻塞渲染的资源。刚才已经针对 HTML 进行了优化,现在该轮到 CSS 了。

压缩+删除注释

这一点不多赘述,参考上面 html 的优化步骤。

内联 CSS

如果你的 css 文件很小,小到仅有几十甚至几 KB,那么你完全可以将 css 内联进 html ,因为相比使用 link 外链还得发一个请求,内联的代价要小的多得多。

利用 Media 媒体查询

有时候一些 CSS 样式只在特定条件下使用,比如打印网页或者网页在大型显示器上显示时。这个时候我们的 media 媒体查询就能大显身手了,它能让这些特定的 CSS 资源不去阻塞页面的渲染。

1
2
3
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="print.css" media="print" />
<link rel="stylesheet" href="other.css" media="(min-width: 980px)" />

上面的例子在我们的项目中很常见,第一个样式表适用于所有情况,它始终会阻塞渲染。但第二个样式表则不然,它只在打印内容时适用,因此在网页首次加载时,该样式表不需要阻塞渲染。最后一个样式表声明则只在特定环境下被执行,一旦不符合最小宽度大于 980px,则始终不会被加载,这样我们就又少了一个不阻塞页面渲染的 CSS 资源。

注意:

  1. 「阻塞渲染」仅是指浏览器是否需要暂停网页的首次渲染,直至该资源准备就绪。无论哪一种情况,浏览器仍会下载 CSS 资源,只不过不阻塞渲染的资源优先级较低罢了。
  2. CSS 会造成阻塞吗?
    1. css 加载不会阻塞 DOM 树的解析
    2. css 加载会阻塞 DOM 树也就是页面的渲染
    3. css 加载会阻塞后面 js 语句的执行
    4. css 加载不阻塞外部脚本的加载
      了解更多:https://www.cnblogs.com/chenjg/p/7126822.html

优化 JavaScript

默认情况下,无论内联还是外联,浏览器遇到文档中的 JavaScript 时都会暂停 DOM 构建,并立即开始执行 JavaScript ,等到脚本执行完毕后,再继续构建 DOM。所以为了提高页面渲染速度,我们可以让 JavaScript 异步执行。

window.onload

这种解决方案是让脚本在网页加载完毕后再执行。当网页加载完毕后,浏览器会发出 onload 事件,我们可以将 JavaScript 放进 onload 事件的回调函数中,这样当 onload 事件被触发后,就能执行我们的脚本了,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<html>
<head>
<script>
function load() {
init();
doSomethingElse();
}
window.onload = load;
</script>
</head>
<body>
<div>...</div>
</body>
</html>

defer / async

我们还可以向 script 标签添加异步关键字,那样可以让浏览器在等待脚本期间不阻止 DOM 以及 CSSOM 的构建。

而关于 defer 和 async 的区别,一图胜千言:

(出处:https://www.growingwiththeweb.com/2014/02/async-vs-defer-attributes.html)

简单来说,当浏览器遇到 script 脚本的时候:

  1. <script src="script.js"></script>
    没有 deferasync,浏览器会立即加载并执行指定的脚本,“立即”指的是在渲染该 script 标签之下的文档元素之前,即不等待后续载入的文档元素,读到就加载并执行。
  2. <script async src="script.js"></script>
    async,加载和渲染后续文档元素的过程将和 script.js 的加载与执行并行进行(异步)。
  3. <script defer src="myscript.js"></script>
    defer,加载后续文档元素的过程将和 script.js 的加载并行进行(异步),但是 script.js 的执行要在所有元素解析完成之后,DOMContentLoaded 事件触发之前完成。

注意:
HTML5 规定,defer 脚本最后的延迟执行应该按照书写顺序来执行,即下面示例脚本,a.js 一定在 b.js 前面执行:

1
2
3
4
5
6
7
<html>
<head>
<script defer src="./js/a.js"></script>
<script defer src="./js/b.js"></script>
</head>
<body></body>
</html>

但在 「JavaScript 高级程序设计(第三版)」中作者表示:

在现实当中,延迟脚本并不一定会按照顺序执行,也不一定会在 DOMContentLoaded 事件触发前执行,因此最好只包含一个延迟脚本。

我个人是试验过各种浏览器的,Chrome、Firefox、Safari,没有出现作者所说的情况。所以我猜测作者写上这一句话的原因是:即使在 HTML5 规范中有这么一条,不一定所有的浏览器厂商都会遵照这个规定,可能某些浏览器厂商并没有实现这个规范,所以为了安全起见,在开发中使用一个 defer 是非常有必要的。

HTTP 缓存

这部分内容可以点击此处查看

渲染性能的优化

页面不仅要快速加载,而且要顺畅地运行;例如列表页的滚动应与手指的滑动一样快,侧边栏的弹出隐藏动画应如丝绸般顺滑。
所以除了对关键渲染路径的优化以外,我们还得确保自己编写的代码能(包括其他第三方代码)更高效地运行。

像素管道

首先我们要了解下「像素管道」的概念。像素管道是「网页从像素到屏幕」的一系列关键节点:

  • JavaScript:指的是使用 JavaScript 来实现一些视觉变化的效果。(e.g. 给页面添加 DOM 元素)
  • 样式计算:指的是根据「匹配选择器」计算出元素所应用的 CSS 规则的过程。
  • 布局:指的是浏览器根据元素所应用的规则计算它们要占据的空间大小及其在屏幕的位置。
  • 绘制:指的是填充像素的过程。(e.g. 绘制文本、颜色、边框和阴影,基本上包括元素的每个可视部分)绘制一般是在多个层上完成的。
  • 合成:由于页面的各部分可能被绘制到多层,所以它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。

我们在编写代码的时候要时刻注意这五个关键节点,因为其中的每一个部分都可能为我们的应用带来“卡顿”。

使用 requestAnimationFrame

试想一下,假设现在浏览器正在执行有关样式方面的工作,然后出现了需要处理的 JavaScript 。这个时候浏览器会马上停下手中的活儿,转而执行插进来的 JavaScript ,然而新来的 JavaScript 是有可能导致刚才处理的样式工作重新返工的(配合上面的「管道图」来看,相当于 style 阶段完成后却执行了 JavaScript ,导致丢帧),这样一来浏览器就很有可能丢失了刚才处理完的这一帧,从而导致卡顿现象。所以没错,当你在使用 JavaScript 编写一段动画时,可以使用 RequestAnimationFrame API,该 API 能够提升我们的动画流畅度,因为它会安排 JavaScript 尽早在每一帧的开始执行,这样尽量给浏览器留出足够的时间来运行代码,然后是样式过程——>布局过程——>绘制过程——>渲染层合并过程。示例:

1
2
3
4
5
6
7
8
9
function animationWidth() {
var div = document.getElementById('box');
div.style.width = parseInt(div.style.width) + 1 + 'px';

if (parseInt(div.style.width) < 200) {
requestAnimationFrame(animationWidth);
}
}
requestAnimationFrame(animationWidth);

避免重排与重绘

What

  • 重排:当 DOM 的变化影响了元素的几何信息(DOM 对象的位置和尺寸大小),浏览器需要重新计算元素的几何属性,将其安放在界面中的正确位置,这个过程叫做重排。
  • 重绘:当一个元素更改了非几何属性(e.g. 背景、文本或阴影),但没有改变布局,重新把元素外观绘制出来的过程,叫做重绘。

浏览器的渲染队列

思考以下代码将会触发几次渲染?

1
2
3
4
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';

根据我们上文的定义,这段代码理论上会触发 4 次重排+重绘,因为每一次都改变了元素的几何属性。但实际上最后只触发了一次重排,这都得益于浏览器的渲染队列机制
当我们修改了元素的几何属性,导致浏览器触发重排或重绘时。它会把该操作放进渲染队列,等到队列中的操作到了一定的数量或者到了一定的时间间隔时,浏览器就会批量执行这些操作。

强制刷新队列

1
2
3
4
5
6
7
8
div.style.left = '10px';
console.log(div.offsetLeft);
div.style.top = '10px';
console.log(div.offsetTop);
div.style.width = '20px';
console.log(div.offsetWidth);
div.style.height = '20px';
console.log(div.offsetHeight);

这段代码会触发 4 次重排+重绘,因为在console中你请求的这几个样式信息,无论何时浏览器都会立即执行渲染队列的任务,即使该值与你操作中修改的值没关联。
因为队列中,可能会有影响到这些值的操作,为了给我们最精确的值,浏览器会立即重排+重绘
强制刷新队列的 style 样式请求

  1. offsetTop, offsetLeft, offsetWidth, offsetHeight
  2. scrollTop, scrollLeft, scrollWidth, scrollHeight
  3. clientTop, clientLeft, clientWidth, clientHeight
  4. getComputedStyle(), 或者 IE 的 currentStyle

所以我们在开发中,应该谨慎的使用这些 style 请求,注意上下文关系,避免一行代码一个重排,这对性能是个巨大的消耗。

重排优化建议

分离读写操作

1
2
3
4
5
6
7
8
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';
console.log(div.offsetLeft);
console.log(div.offsetTop);
console.log(div.offsetWidth);
console.log(div.offsetHeight);

还是上面触发 4 次重排+重绘的代码,这次只触发了一次重排:
在第一个console的时候,浏览器把之前上面四个写操作的渲染队列都给清空了。剩下的 console,因为渲染队列本来就是空的,所以并没有触发重排,仅仅拿值而已。

样式集中改变

1
2
3
4
div.style.left = '10px';
div.style.top = '10px';
div.style.width = '20px';
div.style.height = '20px';

虽然现在大部分浏览器有渲染队列优化,不排除有些浏览器以及老版本的浏览器效率仍然低下。

建议通过改变 class 或者 csstext 属性集中改变样式

1
2
3
4
5
6
7
8
9
// bad
var left = 10;
var top = 10;
el.style.left = left + 'px';
el.style.top = top + 'px';
// good
el.className += ' theclassname';
// good
el.style.cssText += '; left: ' + left + 'px; top: ' + top + 'px;';

缓存布局信息

1
2
3
4
5
6
7
8
// bad 强制刷新 触发两次重排
div.style.left = div.offsetLeft + 1 + 'px';
div.style.top = div.offsetTop + 1 + 'px';
// good 缓存布局信息 相当于读写分离
var curLeft = div.offsetLeft;
var curTop = div.offsetTop;
div.style.left = curLeft + 1 + 'px';
div.style.top = curTop + 1 + 'px';

离线改变 dom

  • 隐藏要操作的 dom
    在要操作 dom 之前,通过 display 隐藏 dom ,当操作完成之后,才将元素的 display 属性为可见,因为不可见的元素不会触发重排和重绘。
1
2
3
dom.display = 'none';
// 修改dom样式
dom.display = 'block';
  • 通过使用 DocumentFragment 创建一个 dom 碎片,在它上面批量操作 dom ,操作完成之后,再添加到文档中,这样只会触发一次重排。
  • 复制节点,在副本上工作,然后替换它!

position 属性为 absolute 或 fixed

position 属性为 absolute 或 fixed 的元素,重排开销比较小,不用考虑它对其他元素的影响

优化动画

  • 可以把动画效果应用到 position 属性为 absolute 或 fixed 的元素上,这样对其他元素影响较小
    动画效果还应牺牲一些平滑,来换取速度,这中间的度自己衡量:
    比如实现一个动画,以 1 个像素为单位移动这样最平滑,但是 reflow 就会过于频繁,大量消耗 CPU 资源,如果以 3 个像素为单位移动则会好很多。
  • 启用 GPU 加速
    此部分来自优化 CSS 重排重绘与浏览器性能
    GPU(图像加速器):
    GPU 硬件加速是指应用 GPU 的图形性能对浏览器中的一些图形操作交给 GPU 来完成,因为 GPU 是专门为处理图形而设计,所以它在速度和能耗上更有效率。
    GPU 加速通常包括以下几个部分:Canvas2D,布局合成, CSS3 转换(transitions),CSS3 3D 变换(transforms),WebGL 和视频(video)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
* 根据上面的结论
* 将 2d transform 换成 3d
* 就可以强制开启 GPU 加速
* 提高动画性能
*/
div {
transform: translate3d(10px, 10px, 0);
}

/* 又或者使用 will-change 属性来创建新层 */
div {
will-change: transform;
}

参考资源