现在的网页中,JavaScript 越来越重了,文件的大小越来越大,执行的时间也越来越长,对用户体验有着举足轻重的影响,使我们不得不重视 JavaScript 的执行时机。
script 的执行方式主要有两种,一种是直接使用 <script>
标签,写到 HTML 中;另一种是通过一个已经在执行的 script 动态地插入一个新的 <script>
元素。
静态HTML标签
<script>
<div>some content</div>
<script src="app.js"></script>
<div>some other content</div>
众所周知,HTML 是从上至下解析的。当它解析遇到 <script>
标签,就会停下来执行里面的代码,然后再继续向下解析。
这时脚本的执行时机是确定的,而且会阻塞后面的 HTML 的渲染。
为了尽可能让脚本的执行不阻塞页面的渲染,最佳实践一般会建议将 <script>
放到页面的最下方,<body>
的最后面。
<script async>
async 的作用是让脚本异步下载和执行。即当 HTML 解析到此脚本时:
- 浏览器开始下载脚本,并继续解析和渲染后面的 HTML;
- 当下载完成时,立即执行脚本。
这时脚本的执行时机是不确定的,和下载的时间有关,与 DOMContentLoaded
无关。也因此,多个异步加载的脚本之间的执行顺序是不固定的,谁先下载完成谁就先执行。
<script defer>
defer 的作用是让脚本提前下载,但是延迟到 DOM 加载完成之后执行。即当 HTML 解析到此脚本时:
- 浏览器开始下载脚本,并继续解析和渲染后面的 HTML;
- 等待 DOM 加载完成;
- 依次运行 defer 的脚本,如果尚未下载完成,则阻塞等待,否则立即执行。
这时脚本的执行时机是明确的,在 DOMContentLoaded
触发时,按照出现的顺序依次执行。
<script async defer>
async
和 defer
各司其职,单独出现我们都可以理解。然而某些情况下,我们会遇到 async
和 defer
同时出现,这就很奇怪了。
HTML规范 里有提到,当出现 async
时,<script>
都会按照 async
的方式去执行;不存在 async
且有 defer
时,才会按照 defer
的方式执行。
同时出现的意义在于,有些浏览器可能尚未支持 async
,但是已经支持 defer
了,这时就可以降级到 defer
模式,不阻塞后续解析,体验更好。
其实主要就是为了优化 IE 浏览器下的体验。从 caniuse 可知:IE >= 10 才开始支持 async
,而 IE 6~9 就已经支持 defer
了。
<script type="module">
加载 ES module 脚本时,默认采用的是 defer
的方式。即:
- 浏览器加载 module 入口脚本,并继续解析和渲染后面的 HTML;
- 分析静态依赖,并并发加载所有依赖的模块;
- 等待 DOM 加载完成;
- 从入口开始执行代码。
<script type="module" async>
加载 ES module 脚本时,指定使用 async
方式,则脚本会在异步下载完成后立即执行。
小结
规范里的这张图很好地总结了各种加载方式的执行时机:
简单地说,就是:
- 不论是
async
还是defer
,都不会阻塞 HTML 的解析; async
为异步执行,什么时候下载完了,就什么时候执行;defer
是延迟执行,等下载完成且 DOM 加载完成后才依次执行;
动态插入 <script>
动态插入脚本指的是通过 JavaScript 来创建和插入 <script>
。这里又分两种情况,直接执行脚本内容和通过链接执行脚本。
内联
看一个简单的例子:
console.log(1);
const script = document.createElement('script');
script.textContent = 'console.log(3)';
script.async = true; // 这一行是无效的document.body.append(script);
console.log(2);
这时脚本一定是同步执行的,即使设置 script.async = true
也是无效的。上面的代码会得到:
1
3
2
所以,通过字符串动态插入脚本时,和调用一个函数的效果类似,后面的代码会先暂停,等待插入的脚本执行完成后继续。
外链
通过链接插入脚本时,因为多了一个下载的步骤,所以脚本一定是异步执行的。值得一提的是,即使是 dataURL、blobUrl 的本地数据链接,也会异步加载,只是他们的下载时间基本可以忽略。
但是这个时候,script.async
就有用了。
这里的 async
控制的是这些动态插入的脚本的执行顺序,他们会并发下载,而且下载过程中并不会阻塞 HTML 的解析。默认情况下,script.async = true
。
举个例子:
console.log(1);
const script = document.createElement('script');
script.src = '2s.js'; // 加载需 2s,内容是 `console.log('2s')`
script.async = true; // 这一行可以省略document.body.append(script);
const script = document.createElement('script');
script.src = '1s.js'; // 加载需 1s,内容是 `console.log('1s')`
script.async = true; // 这一行可以省略document.body.append(script);
console.log(2);
在浏览器中运行,我们会得到:
1
2
1s
2s
这里的两个动态脚本都会被插入到 DOM 中,然后并发下载,1s.js
下载完成后立即执行,所以控制台会看到 1s
先出现。
如果我们指定 script.async = false
,则这些脚本会在异步加载后,按照插入到 DOM 的顺序同步地执行。
console.log(1);
const script = document.createElement('script');
script.src = '2s.js'; // 加载需 2s,内容是 `console.log('2s')`
script.async = false;document.body.append(script);
const script = document.createElement('script');
script.src = '1s.js'; // 加载需 1s,内容是 `console.log('1s')`
script.async = false;document.body.append(script);
console.log(2);
在浏览器中运行,我们会得到:
1
2
2s
1s
这时虽然 1s.js
先下载完成,但是它会一直等到 2s.js
执行完成了才执行,这就是 async = false
的意义所在。
小结
对于动态插入的脚本:
- 如果是内联脚本,则类似于函数调用,同步插入到当前代码中执行。
-
如果是外链脚本,则插入的脚本会并发下载,异步加载,不阻塞页面渲染。
- 默认情况下为 async 方式加载,各个脚本独立下载和执行,执行时机仅受下载时间影响。
- 如果
async = false
,则在并发下载完成后,所有async = false
的脚本按照插入到 DOM 的顺序再依次执行。
怎么选择?
看到这么多复杂的用法,我们应该怎么选择呢?
一般来说,写到 HTML 中肯定是最好的方式,因为只有这样浏览器才能在第一时间去下载脚本,减少等待时间。然后如果希望不阻塞渲染,就使用 async
方式加载;如果依赖 DOM 加载完成或者有执行顺序的要求,则使用 defer
方式加载。
实际应用中我们有很多脚本是在运行时按需加载的,这时就得使用动态插入的方式了。插入多个外链脚本时,如果没有依赖关系,则使用默认的 async
方式插入即可;如果有顺序依赖,可以使用 async = false
插入。
问题来了
实际中我们还可能会碰到外链脚本和内联脚本混合使用的方式,比如加载一个库之后,要触发一个初始化的指令,如何才能让这些脚本依次加载呢?相信有了上面的基础,这个问题就不难解决了。