CSS与JS对DOM的阻塞行为

2,112 阅读5分钟

在探讨 CSS、JS 对阻塞行为前,先建立如下的 html,后续的探讨都在这个 html 的基础上进行。

html 文件如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <style>
      div {
        width: 100px;
        height: 100px;
        background: blue;
      }
    </style>
  </head>
  <body>
    <div />
  </body>
</html>

可以预见的是 html 加载完毕后页面会呈现一个蓝色的正方形。

demo 地址

JS 的阻塞行为

  • <script src="script.js"></script>

    对于没有 async 和 defer 属性的 script,当浏览器解析到 script 标签时会立即加载并执行脚本,这会阻止 dom 的解析,也就说在 script 加载执行完成前 script 标签后的 dom 都不会解析。

    加载脚本阻止 dom 解析

    如下,head 内添加了一个内联脚本,一个外部脚本(sleep.js,为一个空文件),外部脚本将在服务端延迟 5 秒后返回。当 document.readyState 变为 interactive 可交互时,表明文档已解析完成,接近于 DOMContentLoaded 事件的触发。

    <head>
      <script>
        console.log('start');
        document.onreadystatechange = function () {
          if (document.readyState === "interactive") {
            console.log('DOMContentLoaded', document.body.children);
          }
        }
      </script>
      <script src="/sleep.js"></script>
    </head>
    

    效果如下: css不阻止DOM解析效果图

    可以看到,页面刷新,start 首先执行,5 秒后 DOMContentLoaded 才执行,也就是说 js 的加载会阻止 dom 的解析。事实上多数浏览器在 js 加载执行时都会停止解析文档,因为 js 可能操作 dom。

    执行脚本阻止 dom 解析

    如下,head 内添加了两个内联脚本,第二个内联脚本将执行至少 5 秒钟。

    <head>
      <script>
        console.log('start');
        document.onreadystatechange = function () {
          if (document.readyState === "interactive") {
            console.log('DOMContentLoaded', document.body.children);
          }
        }
      </script>
      <script>
        var now = Date.now();
        var isRun = true;
        while(isRun) {
          var time = Date.now();
          if (time - now > 5000) {
            isRun = false;
          }
        }
        console.log('body', document.body);
        console.log('end');
      </script>
    </head>
    

    效果如下: css不阻止DOM解析效果图 可以看到,页面刷新,start 首先执行,5 秒后 DOMContentLoaded 才执行,脚本执行完毕前 body 为 null,也就是说 js 的执行会阻止 dom 的解析。

  • <script async src="script.js"></script>

    async 属性会使脚本后续文档的加载渲染和脚本的加载执行并行进行。async 脚本在下载完成后立即执行,所以不能保证脚本的执行顺序,以乱序执行为主。此外,async 不支持内联脚本。

    加载 async 脚本不阻止 dom 解析

    如下,将"加载脚本阻止 dom 解析"例子中的脚本改成 async。

      <script async src="/sleep.js"></script>
    

    页面刷新,可以看到 DOMContentLoaded 立即打印了,也就说带有 async 属性的脚本加载时不会阻塞 dom 的解析。

    执行 async 脚本不阻止 dom 解析

    添加如下代码到 sleep.js

    var now = Date.now();
    var isRun = true;
    while(isRun) {
      var time = Date.now();
      if (time - now > 5000) {
        isRun = false;
      }
    }
    console.log('body', document.body);
    console.log('end');
    

    如下: 以 async 的方式加载 sleep.js,服务端立即返回 sleep.js。

    <head>
      <script>
        console.log('start');
        document.onreadystatechange = function () {
          if (document.readyState === "interactive") {
            console.log('DOMContentLoaded', document.body.children);
          }
        }
      </script>
      <script async src="/sleep.js"></script>
    </head>
    

    页面刷新,可以看到 DOMContentLoaded 立即打印了,也就说带有 async 属性的脚本执行时不会阻塞 dom 的解析。

  • <script defer src="script.js"></script>

    defer 会使脚本后续文档的加载渲染和脚本的加载并行进行,但 defer 脚本的执行要在所有元素解析完成之后 DOMContentLoaded 事件触发前完成,它是按着脚本加载顺序进行执行。

    加载 defer 脚本不阻止 dom 解析

    将 "加载 async 脚本不阻止 dom 解析例子" 中 async 换成 defer

     <script defer src="/sleep.js"></script>
    

    页面刷新,可以看到 DOMContentLoaded 立即打印了,也就说带有 defer 属性的脚本加载时不会阻塞 dom 的解析。

    执行 defer 脚本不阻止 dom 解析

    将 "执行 async 脚本不阻止 dom 解析例子" 中 async 换成 defer。

      <script>
        console.log('start');
        document.onreadystatechange = function () {
          if (document.readyState === "interactive") {
            console.log('DOMContentLoaded', document.body.children);
          }
        }
      </script>
     <script defer src="/sleep.js"></script>
    

    页面刷新,可以看到 DOMContentLoaded 立即打印了,也就说带有 defer 属性的脚本执行时不会阻塞 dom 的解析。

CSS 的阻塞行为

在 html 的 head 标签内加上 script 标签和 css 的 link,main.css 在服务器端延迟 5 秒后返回。

<head>
  <script>
    document.onreadystatechange = function () {
      if (document.readyState === "interactive") {
        console.log('DOMContentLoaded', document.body.children);
      }
    }
  </script>
  <link rel="stylesheet" href="/main.css" />
</head>

main.css 文件如下:

div {
  background: red;
}

效果如下:

css不阻止DOM解析效果图

可以看到,页面刷新时,立即打印出了 DOMContentLoaded,尽管 main.css 是在延迟 5 秒后返回的,也就是说在 css 加载完成之前 dom 就已经解析完成了,css 的加载并不会阻止 dom 的解析。此外,我们并没有看到蓝色的正方形,而一直是一个红色的正方形,这意味着浏览器在 css 加载解析完成前没有渲染它后面的 dom(如果不是,则先看到蓝色的正方形,再看到红色的正方形),而是在 css 加载解析后再进行渲染,也就是说 css 会阻塞页面的渲染。这种策略是能够说得通的,试想如果先呈现出一个样子,一会又变一下,体验会比较差,而且多次渲染也浪费性能。 另一方面,在最初的测试时 script 是 放在 link 后边的,如下:

<head>
  <link rel="stylesheet" href="/main.css" />
  <script>
    document.onreadystatechange = function () {
      if (document.readyState === 'interactive') {
        console.log('DOMContentLoaded', document.body.children);
      }
    };
  </script>
</head>

结果是,等到 main.css 加载完成后才打印了 DOMContentLoaded,这似乎和 css 不阻止 dom 解析相悖。我们来做另一个测试,如下:

将 css 文件放在两个脚本之间。

<head>
  <script>
    console.log('excute js1');
    document.onreadystatechange = function () {
      if (document.readyState === 'interactive') {
        console.log('DOMContentLoaded', document.body.children);
      }
    };
  </script>
  <link rel="stylesheet" href="/main.css" />
  <script>
    console.log('excute js2');
  </script>
</head>

可以看到,页面刷新时,css 前的脚本立即执行了(输出 excute js1),而 css 后的脚本在 css 5 秒返回后才开始执行(输出 excute js2),接着输出 DOMContentLoaded。事实上,在解析到脚本时,如果在之前已经存在 css 则需要解析脚本前的所有 css 样式,这里引用了外部的 css 文件,则需要等待它下载完成,并解析生成 CSSOM 对象之后,才能执行 JavaScript 脚本,因为 script 可能去修改 style 信息。 由此可以知道,css 的加载解析不会阻止 dom 的解析并不准确,如果 css 后还有脚本时,就会由于阻塞脚本的执行进而导致阻塞 dom 的解析。

结论

  • 没有 async 和 defer 属性的 script 加载或执行都会阻塞 dom 的解析。
  • 带有 async 或 defer 属性的 script 加载或执行都不会阻塞 dom 的解析。
  • async 的脚本加载完毕后立即执行,不保证执行顺序,而 defer 脚本在 dom 解析完毕后才执行,基本能保证按着脚本加载顺序执行。
  • css 的加载解析会阻塞后续的 script 执行。
  • css 的加载解析会阻塞 dom 的渲染。

需要说明的是以上所有结论在不同浏览器不同的版本,所采取的策略并不完全一致,比如脚本加载执行时,chrome(v:74)会继续下载 link 指定的文件(chrome,当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件),而 safari(v:12.0.2)link 文件的下载会被阻塞。