Serenader

Learning by sharing

不同脚本加载方式对 DOMContentLoaded 事件以及 Load 事件的影响

<script> 标签正常加载

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
</head>
<body>
  <div>body</div>
</body>
</html>
结果:
  • 阻塞 DOMContentLoaded 事件
  • 阻塞 Load 事件
/image/66eeb473-ff4a-4601-b65c-079e6e3294a2/e5731cbc-c431-4cce-a96b-380aa5426a50_Untitled.png

<script async> 标签异步加载

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <script src="https://code.jquery.com/jquery-3.4.1.min.js" async></script>
</head>
<body>
  <div>body</div>
</body>
</html>
结果:
  • 不阻塞 DOMContentLoaded 事件
  • 阻塞 Load 事件
/image/66eeb473-ff4a-4601-b65c-079e6e3294a2/00c0d5b7-bba4-4266-93dd-fbd4d0340616_Untitled.png

<script defer> 标签延迟加载

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <script src="https://code.jquery.com/jquery-3.4.1.min.js" defer></script>
</head>
<body>
  <div>body</div>
</body>
</html>
结果:
  • 阻塞 DOMContentLoaded 事件
  • 阻塞 Load 事件
/image/66eeb473-ff4a-4601-b65c-079e6e3294a2/64e1b1aa-367e-44b4-98fd-9576662c0277_Untitled.png

<script async defer> 异步延迟加载双管齐下

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <script src="https://code.jquery.com/jquery-3.4.1.min.js" defer async></script>
</head>
<body>
  <div>body</div>
</body>
</html>
结果:
  • 不阻塞 DOMContentLoaded 事件
  • 阻塞 Load 事件
/image/66eeb473-ff4a-4601-b65c-079e6e3294a2/4ef43944-a240-4986-8460-70428b9ccc33_Untitled.png

<link rel="preload"> 配合 onload 事件

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <link rel="preload" as="script" href="https://code.jquery.com/jquery-3.4.1.min.js" onload="load(this)" />
</head>
<body>
  <script>
    function load(link) {
      var script = document.createElement('script');
      script.src = link.href;
      document.body.appendChild(script);
      script.onload = function() {
        console.log('dynamic script loaded');
      }
    }
  </script>
  <div>body</div>
</body>
</html>
结果:
  • 不阻塞 DOMContentLoaded 事件
  • 不阻塞 Load 事件
/image/66eeb473-ff4a-4601-b65c-079e6e3294a2/b053f141-a279-4602-a5c8-2e8dca4ac0ca_Untitled.png

<link rel="preload"> 配合 DOMContentLoaded 事件

<!DOCTYPE html>
<html>
<head>
  <title></title>
  <link rel="preload" as="script" href="https://code.jquery.com/jquery-3.4.1.min.js" />
</head>
<body>
  <script>
    function load() {
      var script = document.createElement('script');
      script.src = 'https://code.jquery.com/jquery-3.4.1.min.js';
      document.body.appendChild(script);
      script.onload = function() {
        console.log('dynamic script loaded');
      }
    }
    window.addEventListener('DOMContentLoaded', function() {
      load();
    });
  </script>
  <div>body</div>
</body>
</html>
结果:
  • 不阻塞 DOMContentLoaded 事件
  • 阻塞 Load 事件
/image/66eeb473-ff4a-4601-b65c-079e6e3294a2/172b6934-f9a7-413d-b651-4f6fcaf110b8_Untitled.png

原理分析

在分析原理之前,先来看一下 DOMContentLoaded 以及 Load 的定义:
  • DOMContentLoaded: 当页面 HTML 下载并且解析完则会触发这个事件。此时 CSS、图片等资源可能还没加载完。
  • Load:当页面的所有依赖资源都加载完时则会触发这个事件。

普通的 <script> 标签加载

浏览器在下载解析 HTML 内容的时候,如果碰到了普通的 <script> 标签,则会立即下载这个脚本资源并且立即执行。此时会暂停 HTML 页面继续解析。所以普通的 <script> 标签会直接阻塞 DOMContentLoaded 以及 Load 事件。

<script async>

根据 MDN 的文档说明, <script async> 是告诉浏览器尽可能地异步加载这个脚本。异步加载意味着页面 HTML 的下载解析并不会受到影响,所以当浏览器解析 HTML 内容时如果解析到一个 <script async> 脚本,则浏览器会立即下载这个脚本,但是此时会继续解析剩下的 HTML 内容。如果浏览器解析完整个 HTML 内容则会触发 DOMContentLoaded 事件。而触发此事件的时候,<script async> 的脚本不一定加载完,在上面的例子里面则可以看到,async 脚本是在 DOMContentLoaded 事件触发之后才加载完的。也就是说,async 脚本的加载不会影响 DOMContentLoaded 事件。
但是因为 <script async> 本质上是 HTML 所依赖的资源,所以根据 Load 事件的定义, Load 事件需要在所有资源都加载完才会触发,包括 <script async> 资源。因此也就可以解释为什么 <script async> 会影响 Load 事件的触发。
另外一个小知识点,就是用 JS 动态创建的 <script> 脚本,默认都是会以 async 的方式去加载的。

<script defer>

根据定义,<script defer> 是让浏览器在解析到 defer 脚本的时候,立即下载该资源,但是此时仍然继续解析剩下的 HTML 内容。但是跟 async 不同的是,defer 脚本的执行时机是在 domInteractive 之后,domContentLoaded 之前。所以,即使页面 HTML 内容已经解析完,但是此时如果还没下载解析 defer 脚本的话,浏览器不会触发 DOMContentLoaded 事件,直到 defer 脚本都下载完,并且执行完,才会触发 DOMContentLoaded 事件。因此 defer 脚本会影响 DOMContentLoaded 事件触发时机,进而影响 Load 事件时机。

<script defer async>

如果为一个脚本资源同时配置了 defer 和 async 属性时,如果浏览器支持 async 属性的话则 async 优先级高于 defer,具体表现为跟 async 脚本一致。如果浏览器不支持 async 属性则会直接忽略这个属性,表现为和 defer 脚本一致。

<link rel="preload">

Preload,顾名思义,本质上是用来预加载资源的。它有个特性就是资源的预加载不会影响 Load 事件。因此这就解释了为什么使用 Preload 去加载资源不会使 DOMContentLoaded 以及 Load 事件触发时机变长。
当然,只使用 <link rel="preload"> 是不能达到我们要的目的的,preload 只会帮我们下载资源,但是它不会执行。好在,它支持 onload 事件。这样的话,配合 onload 事件,我们可以编写代码,手动构造一个 <script> 请求去真正加载这个资源。由于已经使用 preload 预加载这个资源了,所以手动构造的 <script> 标签实际上并不会再发出请求,而是会直接拿缓存结果(一个有趣的事情是,即使你在 Chrome 控制台上的 Network 面板上开启了 Disable Cache ,preload 后的资源请求仍然会直接从缓存里面拿,而不是重新发请求。这个跟 Prefetch 有本质差别)。
所以这时候我们相当于实现了一个不影响 DOMContentLoaded 、Load 事件触发时机的资源加载方案。
但是在上面最后一个例子里面,如果我们在 DOMContentLoaded 的时候就直接手动创建 <script> 标签去加载,尽管这个时候有 preload ,但是最终结果仍然是会影响 Load 事件。这是因为,用脚本创建的 <script> 标签本质上会以 async 的方式去加载,所以就又回到了 <script async> 的方案,因为它本质上属于页面的依赖,所以 Load 事件需要等到页面所有依赖都加载完才会触发,因此也就可以看到,这种方式实际上也会影响 Load 事件。

结论

如果想要在不影响当前页面正常加载性能的前提下,去加载某个脚本资源的话,最优的方式是使用 Preload + onload 的方案来处理,这种方案不会影响页面的 DOMContentLoaded 事件以及 Load 事件,因此使用 window.performance.timing 统计出来的页面加载性能会更加准确。
但是需要注意的是,使用 Preload + onload 的方案来加载脚本资源,它的加载完成时机是不确定的。如果在你的业务代码上需要引用到这个脚本所导出的方法或者变量,那么你需要注意,在你的业务代码执行的那个时刻,有可能这个脚本还没下载完成。
所以如果你对代码执行顺序有非常高的要求的话,那么可能以上几种方式都不太适合,你需要仔细分析这当中的代码执行顺序,否则很有可能会导致意想不到的结果。但是假如你不关心这个资源的代码执行顺序,纯粹只需要在某个时间点去执行它,那么使用这种方案无疑是最优的。只不过你需要关注一下 preload 的兼容性问题。
一些经典的使用场景:
  • 异步加载统计脚本
  • 异步加载第三方非必须库

Bonus Time

上面提到了,浏览器在解析到普通的 <script> 标签时会停止解析接下来的 HTML 内容,等待脚本下载完成并且执行完毕再继续解析接下来的 HTML 内容。但是如果你查看 Chrome 的 Network 面板的话,你会发现,其实并不是这样:
<!DOCTYPE html>
<html>
<head>
  <title></title>
  <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
  <script src="./b.js"></script>
</head>
<body>
  <div>body</div>
</body>
</html>
/image/66eeb473-ff4a-4601-b65c-079e6e3294a2/5a0b44d7-1f2e-4a50-afcb-e4036a712625_Untitled.png
假如在 HTML 里面声明了两个 <script> 标签,按照之前的说法,浏览器应该是先下载并执行完 jQuery 的脚本再去下载和执行 b.js 文件,但是观看 Network 面板却发现 jQuery 脚本和 b.js 脚本几乎同时开始下载了,这是为什么呢?
实际上,这是浏览器做的一个优化。浏览器有一个叫做 preloader 的功能,它会分析 HTML 在 tokenization 阶段的产物来得出 HTML 所可能需要的资源,以及资源的 URL 。HTML 进行词法分析后的结果会直接传递给 HTML 解析器进行解析,与此同时,preloader 分析出来的资源 URL 以及资源对应的类型也会一并发送给浏览器的 fetcher ,所以浏览器可以做到在真正解析到具体 <script> 标签之前就提前下载它。通常来说,像是 JS 文件,CSS 文件,以及 <img> 标签所引用的图片等资源都会进行提前下载。
回到我们这个例子,当浏览器下载完 HTML 文件,并且进行词法分析后,知道了这个页面引用了两个 JS 资源,所以浏览器在开始解析 HTML 内容的时候就已经开始下载这两个资源了,因此有了上面这个图。
为了验证 preloader 分析出来的资源下载时机比 HTML 解析时机更快,我们可以编写这样的 DEMO :
<!DOCTYPE html>
<html>
<head>
  <title></title>
  <script>
    var script = document.createElement('script');
    script.src = './a.js';
    document.head.appendChild(script);
  </script>
  <script src="./b.js"></script>
</head>
<body>
  <div>body</div>
</body>
</html>
由于浏览器解析 HTML 是自上而下的,那么从上面的代码来看,假如不考虑 Preloader ,它应该是先解析行内 script 标签,然后立即创建一个 <script> 标签去下载 a.js 脚本,接着解析到 <script src="./b.js"> 的时候才会去下载 b.js 脚本。
然而因为有 Preloader 的存在,b.js 脚本下载时机会比 a.js 更快:
/image/66eeb473-ff4a-4601-b65c-079e6e3294a2/8ea606e1-13e2-4320-a3db-e94a9d7e41a0_Untitled.png
这也就说明了 preloader 分析出来的资源下载时机会比 HTML 解析时机更早。

Reference