Web 性能优化:异步加载脚本
本文通过几个例子详述脚本对页面渲染的影响,以及浏览器正在加载提示
(标签页旋转按钮、页面停止渲染、光标停止响应)的行为。
介绍如何使用异步脚本载入策略提前 load
事件,提前结束浏览器的正在加载提示。TL;DR:
- 脚本会阻塞 DOM 渲染,因此可以把不必要首屏载入的脚本异步载入。
- 载入方式一:使用类似 requirejs 的方案,或在
load
事件后再插入外链脚本。 - 载入方式二:XHR 获取内容后 Eval(不安全,且跨域不可用)。
- 载入方式三:使用
<script>
的async
和defer
属性。
本文用来探讨 HTML 渲染机制中,如何不阻塞地加载脚本。对于生产环境,建议直接使用类似 AMD 这样的成熟方案。
DOM 渲染流程
要理解异步脚本载入的用处首先要了解浏览器渲染DOM的流程,以及各阶段用户体验的差别。 一般地,一个包含外部样式表文件和外部脚本文件的HTML载入和渲染过程是这样的:
- 浏览器下载HTML文件并开始解析DOM。
- 遇到样式表文件
link[rel=stylesheet]
时,将其加入资源文件下载队列,继续解析DOM。 - 遇到脚本文件时,暂停DOM解析并立即下载脚本文件。
- 下载结束后立即执行脚本,在脚本中可访问当前
<script>
以上的DOM。 - 脚本执行结束,继续解析DOM。
- 整个DOM解析完成,触发
DOMContentLoaded
事件。
此外,虽然浏览器会并行地下载资源文件(样式表、图片),但通常会限制并发下载数,一般为3-5个。 资源文件的下载也可以进行优化,请参考:Web 性能优化:prefetch, prerender。
脚本加载阻塞 DOM 渲染
脚本载入真的会暂停DOM渲染吗?非常真切。
比如下面的HTML中,在脚本后面还有一个<h1>
标签。
<!DOCTYPE html>
<html>
<body>
<h1>Hello</h1>
<script src="/will-not-stop-loading.js"></script>
<h1>World!</h1>
</body>
</html>
我们编写服务器端代码(见本文最后一章),让/will-not-stop-loading.js
始终处于等待状态。
此时页面的显示效果:
脚本等待下载完成的过程中,后面的World
不会显示出来。直到该脚本载入完成或超时。
试想如果你在<head>
中有这样一个下载缓慢的脚本,整个<body>
都不会显示,
势必会造成空白页面持续相当长的时间。
所以较好的实践方式是将脚本放在<body>
尾部。
很多被墙的网站加载及其缓慢就是因为DOM主体前有脚本被挡在墙外了。
异步加载脚本:插入外链脚本标签
浏览器“载入中”的提示会让用户感觉网页慢!事实上我们应该关心的网页性能就是用户感受的性能。
这个“载入中”的提示消失的时机基本就是 load
事件发生的时机。所以问题就变成了如何提前 load
事件。
除了懒加载图片、视频(Web 上已经有大量教程)之外,延迟加载非必须的页面脚本也很有效:
document.addEventListener('load', function(){
var s = document.createElement('script');
s.src = "/will-not-stop-loading.js";
document.body.appendChild(s);
});
这意味着正在进行的DOM渲染过程完全结束后(此时浏览器忙提示当然会消失),才会调用上述函数。
其中/will-not-stop-loading.js
仍处于pending
状态,但浏览器忙提示已经消失。如图:
注意直接在页面脚本中 append
一个 <script>
不起作用,新插入的脚本仍然会阻塞 DOM 渲染。
即使在 DOMContentLoaded
事件时插入 <script>
也不起作用,
因为 DOMContentLoaded
事件发生在 load
事件之前。
异步加载脚本:XHR+Eval
我们知道XHR可以用来执行异步的网络请求,XHR Eval方法的原理便是通过XHR下载整个脚本,通过eval()
函数来执行这个脚本。
$.get('/path/to/sth.js').done(eval);
因为XHR的下载过程是异步的,所以这个过程中浏览器图标不会显示『忙提示』。 JS的执行时间很短暂,可以认为页面始终不会停止响应。 XHR有跨域问题,因此该方法只适用于资源位于同一域名的情况(或者开启CORS响应头字段)。
因为eval()
方法是不安全的,可以创建一个<script>
标签,并把XHR获取的脚本注入进去。
再把 <script>
标签插入 DOM 它的内容就会执行。
异步加载脚本:Defer/Async
这是 HTML5 中标准的属性,用来在 HTML 标记中声名式地指定异步加载脚本。 除了 Opera 之外的浏览器基本都有支持。这个机制包括两个属性:defer和async。 例如:
<script src="one.js" async></script> <!--异步执行-->
<script src="one.js" defer></script> <!--延迟执行-->
这两者有什么区别呢?请看下图(图片来自peter.sh):
- 正常执行(无任何属性):在下载和执行脚本时HTML停止解析
- 设置
defer
:在下载脚本时HTML仍然在解析,HTML解析完成后再执行脚本。延迟执行不会阻塞渲染,额外的好处是脚本执行时页面已经渲染结束。 - 设置
async
:在下载脚本时 HTML 仍然在解析,下载完成后暂停HTML解析立即执行脚本。
参考代码
本文所做实验服务器端都使用Node.js写成:
const http = require("http");
const fs = require('fs');
const port = 4001;
var server = http.createServer(function(req, res) {
switch (req.url) {
case '/':
var html = fs.readFileSync('./index.html', 'utf8');
res.setHeader("Content-Type", "text/html");
res.end(html);
break;
case '/will-not-stop-loading.js':
break;
}
});
server.listen(port, e => console.log(`listening to port: ${port}`));
- MDN Element.dataset: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
- jQuery.getScript http://api.jquery.com/jQuery.getScript/
本文采用 知识共享署名 4.0 国际许可协议(CC-BY 4.0)进行许可,转载注明来源即可: https://harttle.land/2016/05/18/async-javascript-loading.html。如有疏漏、谬误、侵权请通过评论或 邮件 指出。