关键渲染路径优化
浏览器从获取 HTML 到最终在屏幕上显示内容需要完成以下步骤:
- 处理 HTML 标记并构建 DOM 树。
- 处理 CSS 标记并构建 CSSOM 树。
- 将 DOM 与 CSSOM 合并成一个 render tree。
- 根据渲染树来布局,以计算每个节点的几何信息。
- 将各个节点绘制到屏幕上。
经过以上整个流程我们才能看见屏幕上出现渲染的内容,优化关键渲染路径就是指最大限度缩短执行上述第 1 步至第 5 步耗费的总时间,让用户最快的看到首次渲染的内容。
为尽快完成首次渲染,我们需要最大限度减小以下三种可变因素:
- 关键资源的数量。
- 关键路径长度。
- 关键字节的数量。
关键资源是可能阻止网页首次渲染的资源。例如 JavaScript、CSS 都是可以阻塞关键渲染路径的资源,这些资源越少,浏览器的工作量就越小,对 CPU 以及其他资源的占用也就越少。
同样,关键路径长度受所有关键资源与其字节大小之间依赖关系图的影响: 某些资源只能在上一资源处理完毕之后才能开始下载,并且资源越大,下载所需的往返次数就越多。
最后,浏览器需要下载的关键字节越少,处理内容并让其出现在屏幕上的速度就越快。要减少字节数,我们可以减少资源数(将它们删除或设为非关键资源),此外还要压缩和优化各项资源,确保最大限度减小传送大小。
优化DOM
在关键渲染路径中,构建渲染树(Render Tree)的第一步是构建 DOM,所以我们先讨论如何让构建 DOM 的速度变得更快。
HTML 文件的尺寸应该尽可能的小,目的是为了让客户端尽可能早的接收到完整的 HTML。通常 HTML 中有很多冗余的字符,例如:JS 注释、CSS 注释、HTML 注释、空格、换行。更糟糕的情况是我见过很多生产环境中的 HTML 里面包含了很多废弃代码,这可能是因为随着时间的推移,项目越来越大,由于种种原因从历史遗留下来的问题,不过不管怎么说,这都是很糟糕的。对于生产环境的HTML来说,应该删除一切无用的代码,尽可能保证 HTML 文件精简。
总结起来有三种方式可以优化 HTML:缩小文件的尺寸(Minify)、使用gzip压缩(Compress)、使用缓存(HTTP Cache)。
缩小文件的尺寸(Minify)会删除注释、空格与换行等无用的文本。
本质上,优化 DOM 其实是在尽可能的减小关键路径的长度与关键字节的数量。
优化CSSOM
CSS 是构建渲染树的必备元素,首次构建网页时,JavaScript 常常受阻于 CSS。确保将任何非必需的 CSS 都标记为非关键资源(例如打印和其他媒体查询),并应确保尽可能减少关键 CSS 的数量,以及尽可能缩短传送时间。
阻塞渲染的 CSS
除了上面提到的优化策略,CSS 还有一个可以影响性能的因素是:CSS 会阻塞关键渲染路径。
CSS 是关键资源,它会阻塞关键渲染路径也并不奇怪,但通常并不是所有的 CSS 资源都那么的『关键』。
举个例子:一些响应式 CSS 只在屏幕宽度符合条件时才会生效,还有一些 CSS 只在打印页面时才生效。这些 CSS 在不符合条件时,是不会生效的,所以我们为什么要让浏览器等待我们并不需要的 CSS 资源呢?
针对这种情况,我们应该让这些非关键的 CSS 资源不阻塞渲染。
<link href="style.css" rel="stylesheet">
<link href="print.css" rel="stylesheet" media="print">
<link href="other.css" rel="stylesheet" media="(min-width: 40em)">
<link href="portrait.css" rel="stylesheet" media="orientation:portrait">
- 第一个声明阻塞渲染,适用于所有情况。
- 第二个声明只在打印网页时应用,因此网页首次在浏览器中加载时,它不会阻塞渲染。
- 第三个声明提供由浏览器执行的“媒体查询”: 符合条件时,浏览器将阻塞渲染,直至样式表下载并处理完毕。
- 最后一个声明具有动态媒体查询,将在网页加载时计算。根据网页加载时设备的方向,portrait.css 可能阻塞渲染,也可能不阻塞渲染。
最后,请注意“阻塞渲染”仅是指浏览器是否需要暂停网页的首次渲染,直至该资源准备就绪。无论哪一种情况,浏览器仍会下载 CSS 资产,只不过不阻塞渲染的资源优先级较低罢了。
为获得最佳性能,您可能会考虑将关键 CSS 直接内联到 HTML 文档内。这样做不会增加关键路径中的往返次数,并且如果实现得当,在只有 HTML 是阻塞渲染的资源时,可实现“一次往返”关键路径长度。
避免在CSS中使用@import
大家应该都知道要避免使用 @import 加载 CSS
,实际工作中我们也不会这样去加载 CSS,但这到底是为什么呢? 这是因为使用 @import
加载 CSS 会增加额外的关键路径长度。举个例子:
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Demos</title>
<link rel="stylesheet" href="http://127.0.0.1:8887/style.css">
<link rel="stylesheet" href="https://lib.baomitu.com/CSS-Mint/2.0.6/css-mint.min.css">
</head>
<body>
<div class="cm-alert">Default alert</div>
</body>
</html>
上面这段代码使用 link 标签加载了两个 CSS 资源。这两个 CSS 资源是并行下载的。
现在我们改为使用 @import
加载资源,代码如下:
/* style.css */
@import url('https://lib.baomitu.com/CSS-Mint/2.0.6/css-mint.min.css');
body{background:red;}
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Demos</title>
<link rel="stylesheet" href="http://127.0.0.1:8887/style.css">
</head>
<body>
<div class="cm-alert">Default alert</div>
</body>
</html>
代码中使用 link 标签加载一个 CSS,然后在 CSS 文件中使用 @import 加载另一个 CSS。
可以看到两个 CSS 变成了串行加载,前一个 CSS 加载完后再去下载使用 @import 导入的 CSS 资源。这无疑会导致加载资源的总时间变长。从上图可以看出,首次绘制时间等于两个 CSS 资源加载时间的总和。
所以避免使用@import是为了降低关键路径的长度。
优化JavaScript的使用
所有文本资源都应该让文件尽可能的小,JavaScript 也不例外,它也需要删除未使用的代码、缩小文件的尺寸(Minify)、使用 gzip 压缩(Compress)、使用缓存(HTTP Cache)。
- 异步加载 JavaScript
- 避免同步请求
- 延迟解析 JavaScript
- 避免运行时间长的 JavaScript
使用 defer 延迟加载 JavaScript
与 CSS 资源相似,JavaScript 资源也是关键资源,JavaScript 资源会阻塞 DOM 的构建。并且 JavaScript 会被 CSS 文件所阻塞。
当浏览器加载 HTML 时遇到 <script>...</script>
标签,浏览器就不能继续构建 DOM。它必须立刻执行此脚本。对于外部脚本 <script src="..."></script>
也是一样的:浏览器必须等脚本下载完,并执行结束,之后才能继续处理剩余的页面。
这会导致两个重要的问题:
- 脚本不能访问到位于它们下面的 DOM 元素,因此,脚本无法给它们添加处理程序等。
- 如果页面顶部有一个笨重的脚本,它会“阻塞页面”。在该脚本下载并执行结束前,用户都不能看到页面内容
<p>...content before script...</p>
<script src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script> <!-- This isn't visible until the script loads -->
<p>...content after script...</p>
这里有一些解决办法。例如,我们可以把脚本放在页面底部。此时,它可以访问到它上面的元素,并且不会阻塞页面显示内容:
<body> ...all content is above the script...
<script src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script>
</body>
但是这种解决方案远非完美。例如,浏览器只有在下载了完整的 HTML 文档之后才会注意到该脚本(并且可以开始下载它)。对于长的 HTML 文档来说,这样可能会造成明显的延迟。
这对于使用高速连接的人来说,这不值一提,他们不会感受到这种延迟。但是这个世界上仍然有很多地区的人们所使用的网络速度很慢,并且使用的是远非完美的移动互联网连接。
幸运的是,这里有两个 <script>
特性(attribute)可以为我们解决这个问题:defer
和 async
。
defer
特性告诉浏览器不要等待脚本。相反,浏览器将继续处理 HTML,构建 DOM。脚本会“在后台”下载,然后等 DOM 构建完成后,脚本才会执行。 这是与上面那个相同的示例,但是带有 defer 特性:
<p>...content before script...</p>
<script defer src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script> <!-- 立即可见 -->
<p>...content after script...</p>
换句话说:
- 具有 defer 特性的脚本不会阻塞页面。
- 具有 defer 特性的脚本总是要等到 DOM 解析完毕,但在 DOMContentLoaded 事件之前执行。
下面这个示例演示了上面所说的第二句话:
<p>...content before scripts...</p>
<script> document.addEventListener('DOMContentLoaded', () => alert("DOM ready after defer!")); </script>
<script defer src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script>
<p>...content after scripts...</p>
- 页面内容立即显示。
- DOMContentLoaded 事件处理程序等待具有 defer 特性的脚本执行完成。它仅在脚本下载且执行结束后才会被触发。
具有 defer 特性的脚本保持其相对顺序,就像常规脚本一样。
假设,我们有两个具有 defer 特性的脚本:long.js 在前,small.js 在后。
<script defer src="https://javascript.info/article/script-async-defer/long.js"></script>
<script defer src="https://javascript.info/article/script-async-defer/small.js"></script>
浏览器扫描页面寻找脚本,然后并行下载它们,以提高性能。因此,在上面的示例中,两个脚本是并行下载的。small.js 可能会先下载完成。
但是,defer 特性除了告诉浏览器“不要阻塞页面”之外,还可以确保脚本执行的相对顺序。因此,即使 small.js 先加载完成,它也需要等到 long.js 执行结束才会被执行。
当我们需要先加载 JavaScript 库,然后再加载依赖于它的脚本时,这可能会很有用。
注意:defer 特性仅适用于外部脚本,如果
<script>
脚本没有 src,则会忽略 defer 特性。
使用async 延迟加载JavaScript
async 特性与 defer 有些类似。它也能够让脚本不阻塞页面。但是,在行为上二者有着重要的区别。
async 特性意味着脚本是完全独立的:
- 浏览器不会因 async 脚本而阻塞(与 defer 类似)。
- 其他脚本不会等待 async 脚本加载完成,同样,async 脚本也不会等待其他脚本。
- DOMContentLoaded 和异步脚本不会彼此等待:
- DOMContentLoaded 可能会发生在异步脚本之前(如果异步脚本在页面完成后才加载完成)
- DOMContentLoaded 也可能发生在异步脚本之后(如果异步脚本很短,或者是从 HTTP 缓存中加载的)
换句话说,async 脚本会在后台加载,并在加载就绪时运行。DOM 和其他脚本不会等待它们,它们也不会等待其它的东西。
async 脚本就是一个会在加载完成时执行的完全独立的脚本。就这么简单,现在明白了吧?
下面是一个类似于我们在讲 defer 时所看到的例子:long.js 和 small.js 两个脚本,只是现在 defer 变成了 async。 它们不会等待对方。先加载完成的(可能是 small.js)—— 先执行:
<p>...content before scripts...</p>
<script> document.addEventListener('DOMContentLoaded', () => alert("DOM ready!")); </script>
<script async src="https://javascript.info/article/script-async-defer/long.js"></script>
<script async src="https://javascript.info/article/script-async-defer/small.js"></script>
<p>...content after scripts...</p>
- 页面内容立刻显示出来:加载写有 async 的脚本不会阻塞页面渲染。
- DOMContentLoaded 可能在 async 之前或之后触发,不能保证谁先谁后。
- 较小的脚本 small.js 排在第二位,但可能会比 long.js 这个长脚本先加载完成,所以 small.js 会先执行。虽然,可能是 long.js 先加载完成,如果它被缓存了的话,那么它就会先执行。换句话说,异步脚本以“加载优先”的顺序执行。
当我们将独立的第三方脚本集成到页面时,此时采用异步加载方式是非常棒的:计数器,广告等,因为它们不依赖于我们的脚本,我们的脚本也不应该等待它们:
<!-- Google Analytics 脚本通常是这样嵌入页面的 -->
<script async src="https://google-analytics.com/analytics.js"></script>