script的async和defer

一个问题引发的思考

之前做 vue 项目时,遇到生产环境站点打开缓慢,首屏白屏时间特别长(10s+)的问题。作为组内的前端担当,这个优化的工作自然落在了我的身上。

要解决问题,首先需要弄清楚问题。通过分析首页加载流程,发现时间主要花在下载 main.js(1.5M)上了。使用 vue-cli 自带的分析工具,分析打包结构后发现,main.js 将第三方库文件也一同打包了进去。弄清楚问题后,就是拆分 main.js 文件,将第三方库文件从 cdn 引入。再在 index.html 加入加载动画。成功将首屏时间降低到 1s 内。

我研究到的优化首页加载速度的方法,主要方向就是下面这几种

  1. 找运维增加前端服务器的带宽
  2. 优化页面导入的脚本,让首屏尽可能快的下载完所需的资源
  3. 增加首屏加载动画,给用户一定的反馈

从这个问题引发了我的思考,为什么 js 加载时间长,会导致页面白屏?有时候我们打开页面,明明页面已经展示出来了,或者展示了一部分出来,为什么浏览器标签上一直会有一个loading 图标在转圈。这些都和浏览器的解析和加载页面的机制有关系。

浏览器 UI 线程和 js 线程

浏览器 UI 线程又叫 GUI 线程,用来解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和重绘。

js 线程又叫 js 引擎线程,用来解析 js 脚本,运行 js 代码。

由于 js 是可以操作 DOM 的,UI 线程也是用来操作 DOM 的,为了避免两个线程同时操作相同 DOM 对象,浏览器就将两个线程做成互斥的。当 js 脚本执行时,挂起 UI 线程,当 UI 线程渲染页面时,挂起 js 线程。

<!-- 同步执行 -->
<script>
    console.log('hello, script');

    document.addEventListener('DOMContentLoaded', function() {
        console.log('DOMContentLoaded');
    });

    window.addEventListener('load', function() {
        console.log('load');
    });
</script>

<div>hello, </div>
<!-- 此处js文件会阻塞, 这段时间内UI线程被挂起,后面的DOM元素都不会被渲染 -->
<script >
    console.log("hello, async");
    var count = 0;
    for(var i = 0; i < 1000000000; i++) {
            count += 1;
    }
    console.log(count);
</script>
<div>world</div>

<!-- 下载js时间过长,导致UI线程被挂起,依然会阻塞DOM的渲染 -->
<script src="/async.js?timeout=5"></script>
<div>被阻塞的另一个div...</div>

<script>
    console.log('end, script');
</script>

从上面的例子可以看到,如果 js 的下载和执行,都会阻塞 UI 线程渲染 DOM。这就是首页白屏的原因。

那有什么办法可以让 js 线程不阻塞 UI 线程呢,答案就是script标签上的asyncdefer属性

async和defer

script 标签上的asyncdefer属性都属于 html5 标准,用来优化 script 的下载。

async

script标签设置async后,会异步下载脚本,此时已经不会阻塞 UI 线程继续渲染之后的 DOM 元素。脚本下载完成后会被尽快解析和执行(不清楚暂停 UI 线程的时机)。async脚本会阻塞load事件的触发,和DOMContentLoaded事件无关。

defer

script标签设置defer后,会异步下载脚本,此时已经不会阻塞 UI 线程继续渲染之后的DOM 元素。当 UI 线程解析完 HTML,触发DOMContentLoaded事件之前,会按顺序执行defer脚本。defer脚本会阻塞DOMContentLoaded事件的触发。

<!-- defer async -->
<script>
    console.log('hello, script');

    document.addEventListener('DOMContentLoaded', function() {
        console.log('DOMContentLoaded');
    });

    window.addEventListener('load', function() {
        console.log('load');
    });
</script>

<script src="/async.js?timeout=5" async></script>
<script src="/defer.js?timeout=3" defer></script>

<script>
    console.log('end, script');
</script>

DOMContentLoaded和load

DOMContentLoadedload是文档在加载结束时触发的两个事件,当 HTML 加载和解析完成时,会在document上触发DOMContentLoaded事件。不包括样式表,图片和iframe的加载。当文档被加载完成,所有依赖的资源也下载完成时,会在window上触发load事件。

有时候我们打开页面,明明页面已经展示出来了,或者展示了一部分出来,为什么浏览器标签上一直会有一个 loading 图标在转圈。是因为浏览器也监听了页面的资源下载,当所有资源下载完成,load事件触发后,标签上的 loading 动画才会消失。

意外的收获

在验证asyncdefer时,发现 chrome 浏览器在加载 HTML 时会提前下载 js 文件,即使 UI 线程还没解析到script标签。

被提前下载的 js 文件会一直显示为 pending 状态,知道 UI 线程按顺序解析到该script标签,如果 js 文件下载完成,状态会立即变成 200。

发布于 2021-08-18 15:13