script 的执行顺序

现在的网页中,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 解析到此脚本时:

  1. 浏览器开始下载脚本,并继续解析和渲染后面的 HTML;
  2. 当下载完成时,立即执行脚本。

这时脚本的执行时机是不确定的,和下载的时间有关,与 DOMContentLoaded 无关。也因此,多个异步加载的脚本之间的执行顺序是不固定的,谁先下载完成谁就先执行

<script defer>

defer 的作用是让脚本提前下载,但是延迟到 DOM 加载完成之后执行。即当 HTML 解析到此脚本时:

  1. 浏览器开始下载脚本,并继续解析和渲染后面的 HTML;
  2. 等待 DOM 加载完成;
  3. 依次运行 defer 的脚本,如果尚未下载完成,则阻塞等待,否则立即执行。

这时脚本的执行时机是明确的,在 DOMContentLoaded 触发时,按照出现的顺序依次执行。

<script async defer>

asyncdefer 各司其职,单独出现我们都可以理解。然而某些情况下,我们会遇到 asyncdefer 同时出现,这就很奇怪了。

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 的方式。即:

  1. 浏览器加载 module 入口脚本,并继续解析和渲染后面的 HTML;
  2. 分析静态依赖,并并发加载所有依赖的模块;
  3. 等待 DOM 加载完成;
  4. 从入口开始执行代码。

<script type="module" async>

加载 ES module 脚本时,指定使用 async 方式,则脚本会在异步下载完成后立即执行。

小结

规范里的这张图很好地总结了各种加载方式的执行时机:

async defer

简单地说,就是:

  • 不论是 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 插入。

问题来了

实际中我们还可能会碰到外链脚本和内联脚本混合使用的方式,比如加载一个库之后,要触发一个初始化的指令,如何才能让这些脚本依次加载呢?相信有了上面的基础,这个问题就不难解决了。

参考


© 2020