欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

访谈:脚本标签加载和执行时间总结

最编程 2024-07-17 20:20:29
...

引子

大家在做Web端的性能优化的时候,基本上都会涉及到HTML的加载解析过程,其中如何控制Javascript的加载和执行时机尤为重要。

这里就涉及到多种Javascript的加载方法,有同步的、异步的,也有动态添加的,那他们分别在什么时候会开始加载,又会在什么时候开始执行,这边就做个简单的总结。当然,如果你懒得看过程,可以直接滑到最后看总结

注意,本文以Chrome 94版本为基础来测试,不代表所有浏览器行为

Demo

实践是检验真理的唯一标准,就像听过很多道理,依赖过不好这一生一样,还是要整个Demo出来直观感受一下,没准会和想象的有偏差。这里就简单用Node.js最基础的功能写了一个Demo,代码在Script-Load(github.com/waiter/Scri…

可以直接把代码clone下来,yarn或者npm install安装一下依赖,就可以yarn start或者npm run start来启动一个简单的Node服务器,然后就可以通过浏览器访问http://localhost:3000来查看各种示例了

另外,为了更方便的模拟脚本的加载时间,以明确加载顺序以及执行时机,这边在所有的Javascript都是直接打到Node.js,让Node.js等待一段时间后再返回。其涉及的代码也比较简单,大体过程如下

const wait = (time: number) => new Promise(resolve => {
  setTimeout(resolve, time);
});

const makeScript = (jsName: string, needWait: number = 0) => {
  const waitStr = needWait > 0 ? ` wait: ${needWait}` : '';
  return `console.log('${jsName}${waitStr}');`;
}

const server = http.createServer(async (req, res) => {
  // ...一些逻辑
  if (reqPath.endsWith('.js')) {
    const needWait = (query.wait && +query.wait) || 0;
    if (needWait > 0) {
      await wait(needWait);
    }
    res.write(makeScript(jsName, needWait));
  } else {
    // 其他逻辑
  }
  res.end();
});

这样前端就可以直接使用类似/7.head-child.js?wait=1000这样的请求来加载耗时1s的JS了。

另外,我们在研究web优化时,也会有两个重要的事件:

  • DOMContentLoaded:HTML解析完毕
  • load:资源加载完毕

这里也在所有的示例HTML中增加了这两个事件的监听,方便查看执行时机

document.addEventListener('DOMContentLoaded', (event) => {
  console.log('DOMContentLoaded');
});
window.addEventListener('load', (event) => {
  console.log('loaded');
});

同步加载

首先简单说明一下这里的同步加载的概念,主要含义是其会阻塞HTML的继续解析。我们知道HTML解析过程是从上到下解析的,而遇到需要同步加载的script标签的时候,会等待script加载完成,并执行完成后,再继续解析HTML。

那这里也简单写个示例看看是否符合预期:

<script src="/1.normal1.js?wait=1000"></script>
<script src="/2.normal2.js?wait=500"></script>
<script src="/3.normal3.js?wait=500"></script>

其执行结果也很简单:

1.normal1.js wait: 1000
2.normal2.js wait: 500
3.normal3.js wait: 500
DOMContentLoaded
loaded

按照顺序执行,并且在DOMContentLoaded之前都执行完毕了,那么来看下其加载时机:

1638408890508.jpg

你会发现,这3个脚本的加载时机竟然都是一样的,都是在HTML获取到之后,马上就开始加载。这里其实涉及到浏览器的一个优化,它会预先找到HTML中的所有script标签,并直接开始加载,并不是在解析到该标签时才开始加载。这样算是浏览器一种优化,等HTML解析到对应标签后,JS已经加载好了,直接执行即可,这样可以节省大量的时间,提升用户体验。当然,从上面的执行结果看,其执行时机还是保持原有逻辑的。

async

script标签也支持asyncdefer属性来异步加载,即不阻塞HTML的解析。

对于async,先偷懒引用一段MDN上的介绍

For classic scripts, if the async attribute is present, then the classic script will be fetched in parallel to parsing and evaluated as soon as it is available.

For module scripts, if the async attribute is present then the scripts and all their dependencies will be executed in the defer queue, therefore they will get fetched in parallel to parsing and evaluated as soon as they are available.

This attribute allows the elimination of parser-blocking JavaScript where the browser would have to load and evaluate scripts before continuing to parse. defer has a similar effect in this case.

对于我们现在要研究的,简单来说就是异步加载,加载完了就立即执行

那这里也简单上个示例来看看

<script src="/1.async1.js?wait=3000" async></script>
<script src="/2.normal1.js?wait=500"></script>
<script src="/3.async2.js?wait=100" async></script>
<script src="/4.normal2.js?wait=400"></script>

结果是

2.normal1.js wait: 500
4.normal2.js wait: 400
DOMContentLoaded
3.async2.js wait: 100
1.async1.js wait: 3000
loaded

这里的3.async2.js的执行时机和预期的不太一样啊,它不是应该在4.normal2.js之前执行么?看下加载时机?

1638413030767.jpg

也被浏览器优化的提前加载了,也就是说当2.normal1.js执行完成后,3.async2.js4.normal2.js都已经加载完成了,这里表现出同步脚本执行优先级似乎比异步的高(并不确定)。那如果把4.normal2.js的加载时间拉长呢

<script src="/1.async1.js?wait=3000" async></script>
<script src="/2.normal1.js?wait=500"></script>
<script src="/3.async2.js?wait=100" async></script>
<script src="/4.normal2.js?wait=600"></script>

其执行结果就符合预期了

2.normal1.js wait: 500
3.async2.js wait: 100
4.normal2.js wait: 600
DOMContentLoaded
1.async1.js wait: 3000
loaded

defer

script标签还支持defer来实现异步加载

This Boolean attribute is set to indicate to a browser that the script is meant to be executed after the document has been parsed, but before firing DOMContentLoaded.

Scripts with the defer attribute will prevent the DOMContentLoaded event from firing until the script has loaded and finished evaluating.

Scripts with the defer attribute will execute in the order in which they appear in the document.

This attribute allows the elimination of parser-blocking JavaScript where the browser would have to load and evaluate scripts before continuing to parse. async has a similar effect in this case.

大体也就是异步加载,等到HTML解析完成,但是在DOMContentLoaded事件前,按照顺序执行

那也写个简单的示例来看看吧

<script src="/1.defer1.js?wait=3000" defer></script>
<script src="/2.normal1.js?wait=500"></script>
<script src="/3.defer2.js?wait=100" defer></script>
<script src="/4.normal2.js?wait=400"></script>

执行结果为

2.normal1.js wait: 500
4.normal2.js wait: 400
1.defer1.js wait: 3000
3.defer2.js wait: 100
DOMContentLoaded
loaded

符合预期,另外,JS加载时机也被浏览器优化成一开始就全部加载了。

结合async来看下

<script src="/1.normal1.js?wait=400"></script>
<script src="/2.defer1.js?wait=200" defer></script>
<script src="/3.async1.js?wait=600" async></script>
<script src="/4.defer2.js?wait=700" defer></script>
<script src="/5.normal2.js?wait=500"></script>

执行结果为

1.normal1.js wait: 400
5.normal2.js wait: 500
2.defer1.js wait: 200
3.async1.js wait: 600
4.defer2.js wait: 700
DOMContentLoaded
loaded

也挺正常的,没啥好说的

createElement

还有一种比较常用的动态加载script的方法,就是使用createElement动态创建script标签,然后再添加到HTML中。为了做简单测试,这边在HTML也简单加了一个方法

window.loadScript = function(url, async) {
  var po = document.createElement('script');
  po.async = async;
  po.src = url;
  document.body.appendChild(po);
};

其中async属性默认为true,这边为了方便调试,就把这个参数也传入了。有了创建方法,这边再写个示例试试

<script>loadScript('/1.create1.js?wait=3000', true);</script>
<script src="/2.normal1.js?wait=500"></script>
<script>loadScript('/3.create2.js?wait=100', true);</script>
<script src="/4.normal2.js?wait=700"></script>

看下执行结果

2.normal1.js wait: 500
3.create2.js wait: 100
4.normal2.js wait: 700
DOMContentLoaded
1.create1.js wait: 3000
loaded

其和直接写带asyncscript标签表现类似,不过其加载时机会更直观一些,因为它没法被浏览器优化,可以看下图

1638438150281.jpg

那接着再来看create出来的script标签的async属性,看名字和之前普通script标签的一样,那是不是这个属性设置为false就可以实现动态创建同步脚本了呢?试试

<script>loadScript('/1.create1.js?wait=3000', false);</script>
<script src="/2.normal1.js?wait=500"></script>
<script>loadScript('/3.create2.js?wait=100', false);</script>
<script src="/4.normal2.js?wait=700"></script>
<script>
  setTimeout(function() {
    loadScript('/5.create3.js?wait=3000', false);
  }, 1000);
</script>

然后执行结果呢?

2.normal1.js wait: 500
4.normal2.js wait: 700
DOMContentLoaded
1.create1.js wait: 3000
3.create2.js wait: 100
5.create3.js wait: 3000
loaded

都在DOMContentLoaded后才执行,没有阻断HTML解析啊!那找找文档吧

In older browsers that don't support the async attribute, parser-inserted scripts block the parser; script-inserted scripts execute asynchronously in IE and WebKit, but synchronously in Opera and pre-4 Firefox. In Firefox 4, the async DOM property defaults to true for script-created scripts, so the default behavior matches the behavior of IE and WebKit.

To request script-inserted external scripts be executed in the insertion order in browsers where the document.createElement("script").async evaluates to true (such as Firefox 4), set async="false" on the scripts you want to maintain order.

也就是说,async="false"只是可以让create出来的script标签按照顺序执行,其加载过程还是异步的

另外,上面这个例子,从另外一个角度说明HTMLonload事件会等之前开始加载的资源加载完成后才触发

document.write

除了上面的加载JS的方法,还有一个不太常用的document.write,这块之前确实没怎么用过,但是也一起研究一下。

window.writeScript = function(url, type) {
  document.write('<scr' + 'ipt '+ type +' src="'+ url + '"></scr' + 'ipt>');
};

加个创建的方法,注意这里特意把script拆开了,主要是防止浏览器直接把这个当做script标签来解析。

另外,也加了一个阻塞JS进程的方法,来实现延时的效果

window.waitTime = function(time) {
  var start = new Date().getTime();
  while (new Date().getTime() - start < time) {}
  console.log('wait: ' + time);
};

准备好了之后,开始测试

<script src="/1.normal1.js?wait=1000"></script>
<script>
  writeScript('/2.write-normal1.js?wait=500', '');
  waitTime(600);
  writeScript('/3.write-normal2.js?wait=500', '');
  console.log('write end');
</script>
<script src="/4.normal3.js?wait=500"></script>

看看执行结果

1.normal1.js wait: 1000
wait: 600
write end
2.write-normal1.js wait: 500
3.write-normal2.js wait: 500
4.normal3.js wait: 500
DOMContentLoaded
loaded

因为使用document.write写入的是普通的script标签,所以其也表现出了普通script标签的同步特性,阻塞了浏览器的继续解析,但是不会阻塞当前script标签内的代码执行,可以看上面的write end会先于2.write-normal1.js打印即可明白

另外,因为这个也算是动态创建的script标签,浏览器当然也没办法做优化

1638440381651.jpg

document.write除了能写入普通的script标签,当然也能写入带asyncscript标签

<script>writeScript('/1.write-async1.js?wait=3000', 'async');</script>
<script src="/2.normal1.js?wait=500"></script>
<script>writeScript('/3.write-async2.js?wait=100', 'async');</script>
<script src="/4.normal2.js?wait=400"></script>

执行结果为

2.normal1.js wait: 500
4.normal2.js wait: 400
DOMContentLoaded
3.write-async2.js wait: 100
1.write-async1.js wait: 3000
loaded

还有带deferscript标签也不在话下

<script>writeScript('/1.write-defer1.js?wait=3000', 'defer');</script>
<script src="/2.normal1.js?wait=500"></script>
<script>writeScript('/3.write-defer2.js?wait=100', 'defer');</script>
<script src="/4.normal2.js?wait=400"></script>

执行结果为

2.normal1.js wait: 500
4.normal2.js wait: 400
1.write-defer1.js wait: 3000
3.write-defer2.js wait: 100
DOMContentLoaded
loaded

这样看起来document.write应该很好用啊,当你不想要使用浏览器的优化,但是又想阻塞HTML解析之类的完全可以这样用。

那它有什么局限么?看个例子

<script src="/1.normal1.js?wait=200&url=%2F2.write1.js%3Fwait%3D100"></script>
<script src="/3.async1.js?wait=200&url=%2F4.write2.js%3Fwait%3D100" async></script>
<script src="/5.defer1.js?wait=200&url=%2F6.write3.js%3Fwait%3D100" defer></script>
<script>loadScript('/7.create1.js?wait=100&url=%2F8.write4.js%3Fwait%3D100', false);</script>
<script>
  setTimeout(function() {
    writeScript('/9.normal2.js?wait=200', '');
  }, 300);
</script>

上面的JS请求又增加了一个url的参数,其是为了让返回的JS中带document.write。Node里面的逻辑为

const makeScript = (jsName: string, needWait: number = 0, url?: string, type?: string) => {
  const waitStr = needWait > 0 ? ` wait: ${needWait}` : '';
  let script = `console.log('${jsName}${waitStr}');`;
  if (url) {
    script += `document.write('<script ${type || ''} src="${url}"></script>');`;
  }
  return script;
}

那上面的例子执行结果是什么呢?

1638441193279.jpg

看着比较复杂,还有warning在里面,其核心局限就在:

  • 无法在异步脚本中使用,使用了没啥效果,但给了个warning
  • 不应该在DOMContentLoaded后使用,使用了你会发现你的HTML被完全覆盖了

小测试

既然上面的都已经看完了,不妨来做个简单的小测试,看看自己是否理解了里面的内容

<script src="/1.normal1.js?wait=500"></script>
<script src="/2.async1.js?wait=600" async></script>
<script src="/3.defer1.js?wait=500" defer></script>
<script>loadScript('/4.create1.js?wait=4000', false);</script>
<script src="/5.defer2.js?wait=1000&url=%2F6.write1.js%3Fwait%3D1000" defer></script>
<script>loadScript('/7.create2.js?wait=1000', false);</script>
<script>writeScript('/8.write-async1.js?wait=200', 'async');</script>

上面这个执行结果是啥?答案在下面

????请点击这行字看答案????


如果上面未展示图片,可以点击:p1-juejin.byteimg.com/tos-cn-i-k3…

总结

加载形式 浏览器优化加载 同步 有序 HTML解析后可用
普通Script标签 ———
普通Script标签async ———
普通Script标签defer ———
createElement
createElement且async为false
document.write普通
document.write普通async
document.write普通defer

PS: 代码在Script-Load(github.com/waiter/Scri…