图解<script>的defer / async与DOMContentLoaded / Load

3,570 阅读6分钟

我们都知道,<script>标签的作用是将JavaScript代码嵌入到HTML页面中,跟其他标记混合在一起,也可以用于引入保存在外部文件中的JavaScript。它也能在其他语言中使用,比如 WebGL 的 GLSL 着色器语言。

<script>在日常中经常使用到,但是<script>的相关知识你到底了解多少呢?让我们一起看一下吧~

script标签位置

过去,所有<script> 元素都被放在页面的 标签内,如下面的例子所示:

<!DOCTYPE html>
<html>
    <head>
        <title>Example HTML Page</title>
        <script src="example1.js"></script>
        <script src="example2.js"></script>
    </head>
    <body>
        <!-- 这里是页面内容 -->
    </body>
</html>

这种做法的主要目的是把外部的CSS和JavaScript文件都集中放到一起。不过,把所有JavaScript文件都放在<head> 里,也就意味着必须把所有JavaScript代码都下载、解析和解释完成后,才能开始渲染页面(页面在浏览器解析到<body> 的起始标签时开始渲染)。对于需要很多JavaScript的页面,这会导致页面渲染的明显延迟,在此期间浏览器窗口完全空白

为解决这个问题,现代Web应用程序通常将所有JavaScript引用放在<body> 元素中的页面内容后面,如下面的例子所示:

<!DOCTYPE html>
<html>
    <head>
        <title>Example HTML Page</title>
    </head>
    <body>
        <!-- 这里是页面内容 -->
        <script src="example1.js"></script>
        <script src="example2.js"></script>
    </body>
</html>

这样一来,页面会在处理JavaScript代码之前完全渲染页面。用户会感觉页面加载更快了,因为浏览器显示空白页面的时间短了。

script的属性

1. src

可选属性。这个相信大家都很熟悉了,它可以包含要执行的代码的外部文件。指定了 src 属性的script元素标签内不应该再有嵌入的脚本。 可能的值:

  • 绝对 URL - 指向另一个网站(比如 src="www.example.com/example.js"…
  • 相对 URL - 指向本地的一个文件(比如 src="/scripts/example.js")

2.charset

可选属性。使用src属性指定的代码字符集。如果存在,值必须和“utf-8”不区分大小写的匹配。当然声明 charset 是没有必要的,因为页面文档必须使用UTF-8,而 script 元素会从页面文档中继承这个属性。

3.async✨

可选属性。只对外部脚本文件有效。

首先我们要知道,对于普通的引用外部脚本文件的<script>标签,当浏览器解析到这个标签的时候会怎么做呢?

首先,它会先暂停对当前HTML的解析,发送网络请求加载并执行该脚本,执行完后恢复解析HTML。也就是说<script>标签阻塞了浏览器对 HTML 的解析,如果获取 JS 脚本的网络请求迟迟得不到响应,或者 JS 脚本执行时间过长,都会导致白屏,用户看不到页面内容。过程如下图所示

image.png

当浏览器遇到带有 async 属性的<script>时,它又会怎么做呢?

此时它请求该脚本的网络请求是异步的,不会阻塞浏览器解析 HTML,一旦网络请求完成之后(也就是脚本加载完成之后),如果此时 HTML 还没有解析完,浏览器会暂停解析,先让执行 JS 脚本代码,执行完毕后再解析HTML。如下图所示

image.png

如果在脚本请求完成之前,HTML 已经解析完毕了,那就会立即执行 JS 脚本代码。

image.png

所以 async 是不可控的,因为执行时间不确定,如果在异步 JS 脚本中获取某个 DOM 元素,有可能获取到也有可能获取不到。而且如果存在多个 async 的时候,它们之间的执行顺序也不确定,第二个脚本完全有可能先于第一个脚本执行,完全依赖于网络传输结果,谁先到执行谁。

<!DOCTYPE html>
<html>
    <head>
        <title>Example HTML Page</title>
        <script async src="example1.js"></script>
        <script async src="example2.js"></script>
    </head>
    <body>
        <!-- 这里是页面内容 -->
    </body>
</html>

在这个例子中,第二个脚本就有可能先于第一个脚本执行。

正因为如此,异步脚本不应该在加载期间修改DOM。 异步脚本保证会在页面的load 事件之前执行,但可能会在DOMContentLoaded (下文中解释)之前或之后

对于XHTML文档,指定async属性时应该写成async="async"

4.defer✨

可选属性。也只对外部脚本文件有效

当浏览器遇到带有 defer 属性的<script>时,获取该脚本的网络请求也是异步的,不会阻塞浏览器解析 HTML,一旦网络请求完成之后,如果此时 HTML 还没有解析完,浏览器不会暂停解析HTML,而是等待 HTML 解析完毕再执行 JS 代码。

image.png

也就是说浏览器解析到该脚本时,会立即开始下载,但执行应该推迟到页面解析完之后:

<!DOCTYPE html>
<html>
    <head>
        <title>Example HTML Page</title>
        <script defer src="example1.js"></script>
        <script defer src="example2.js"></script>
    </head>
    <body>
        <!-- 这里是页面内容 -->
    </body>
</html>

虽然这个例子中的<script> 元素包含在页面的<head> 中,但它们会在浏览器解析到结束的</html> 标签后才会执行。HTML5规范要求脚本应该按照它们出现的顺序执行,因此第一个推迟的脚本会在第二个推迟的脚本之前执行,而且两者都会在DOMContentLoaded 事件之前执行.

对于XHTML文档,指定defer属性时应该写成defer="defer"

OK,现在重新梳理一下defer和async的区别:

  • 当script中有defer属性时,脚本的加载过程和HTML加载是异步发生的,等到HTML解析完,脚本才开始执行。

  • 当script有async属性时,脚本的加载过程和HTML加载也是异步发生的。但脚本下载完成后会停止HTML解析,执行脚本,脚本解析完继续HTML解析。

  • 当script同时有async和defer属性时,执行效果和async一致。

5.crossorigin

可选属性。用于配置相关请求的CORS(跨域资源共享)设置。默认是不使用CORS的。

crossorigin="anonymous"配置文件请求不必设置凭据标志。

crossorigin="use-credentials"设置凭据标志,意味着出站请求会包含凭据。

6.language

已废弃。大多数浏览器会忽略这个属性,不应再使用。请用type属性代替这个属性。

7.type

可选属性,用于代替language,它表示代码块中脚本语言的内容类型(也称MIME类型 Multipurpose Internet Mail Extensions)。

按照惯例,这个值始终是"text/javascript",尽管"text/javascript""text/ecmascript"都已经废弃了。JavaScript文件的MIME类型通常是"application/x-javascript",不过给type属性这个值可能导致脚本被忽略。在非IE浏览器中有效的其他值还有"application/javascript""application/ecmascript"

浏览器加载 ES6 模块,也使用<script>标签,但是要加入type="module"属性。

<script type="module" src="./foo.js"></script>

上面代码在网页中插入一个模块foo.js,由于type属性设为module,所以浏览器知道这是一个 ES6 模块。

浏览器对于带有type="module"<script>,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了<script>标签的defer属性。

<script type="module" src="./foo.js"></script>
<!-- 等同于 -->
<script type="module" src="./foo.js" defer></script>

如果网页有多个<script type="module">,它们会按照在页面出现的顺序依次执行

<script>标签的async属性也可以打开,这时只要加载完成,渲染引擎就会中断渲染立即执行。执行完成后,再恢复渲染。

<script type="module" src="./foo.js" async></script>

一旦使用了async属性,<script type="module">就不会按照在页面出现的顺序执行,而是只要该模块加载完成,就执行该模块。

ES6 模块也允许内嵌在网页中,语法行为与加载外部脚本完全一致。

<script type="module">
  import utils from "./utils.js";
  // other code
</script>

举例来说,jQuery 就支持模块加载。

<script type="module">
  import $ from "./jquery/src/jquery.js";
  $('#message').text('Hi from jQuery!');
</script>

8.integrity

可选属性。它可以允许对比接收到的资源和指定的签名用于验证子资源的完整性(SRI,Subresource Intergrity)。如果接收到的资源签名和该属性指定的签名不匹配,则页面报错,脚本不执行。

这个属性可以用于确保内容分发网络(CDN,Content Delivery Network)不会提供恶意内容。

9.text

用于设置元素的文本内容。本属性在节点插入到DOM之后,此属性被解析为可执行代码。

DOMContentLoaded与load✨

DOMContentLoaded:当纯HTML被完全加载以及解析时,DOMContentLoaded 事件会被触发,而不必等待样式表,图片或者子框架完成加载。也就是只要页面DOM加载完成就触发,无需等待依赖资源的加载。

load:当整个页面及所有依赖资源如样式表和图片都已完成加载时,将触发load事件。

这里有一个网站能直观地展示这两个事件的区别:testdrive-archive.azurewebsites.net/HTML5/DOMCo…

大家也可以打开控制台的网络板块直观地看一下,图中的竖直的蓝线和红线分别就是DOMContentLoadedload事件。

image.png

同步script与 DOMContentLoaded:

(1)既无js也无css的情况下,HTML文档的解析过程: image.png

由图可见,DOMContentLoaded触发时间为DOM之后

(2)有css无js的情况下,HTML文档的解析过程: image.png

由图可见,DOMContentLoaded触发时间仍为DOM之后,无论此时CSS解析为CSSOM的过程是否完成。

(3)既有css也有js的情况下,HTML文档的解析过程: image.png

由图可见,DOMContentLoaded触发仍为DOM之后。综上,在只有同步的script标签代码中,DOMContentLoaded执行时间如下: image.png

带async的script 与 DOMContentLoaded:

async脚本的加载不计入DOMContentLoaded事件统计,也就是说下图两种情况都是有可能发生的:

  • HTML 还没有被解析完的时候,async脚本已经加载完了,那么 HTML 停止解析,去执行脚本,脚本执行完毕后触发DOMContentLoaded事件。 image.png

  • HTML 解析完了之后,async脚本才加载完,然后再执行脚本,那么在HTML解析完毕、async脚本还没加载完的时候就触发DOMContentLoaded事件。 image.png

带defer的script 与 DOMContentLoaded:

文档解析时,遇到设置了defer的脚本,就会在后台进行下载,但是并不会阻止文档的渲染,当页面解析和渲染完毕后,会等到所有的defer脚本加载完毕并按照顺序执行完毕才会触发DOMContentLoaded事件,也就是说下图两种情况都是有可能发生的:

  • HTML 还没有被解析完的时候,defer脚本已经加载完了,那么 等待HTML 解析完成后执行脚本,脚本执行完毕后触发DOMContentLoaded事件。 image.png

  • HTML 解析完了之后,defer脚本才加载完,然后再执行脚本,脚本执行完毕后触发DOMContentLoaded事件。 image.png

动态加载脚本

除了<script> 标签,还有其他方式可以加载脚本。因为JavaScript可以使用DOM API,所以通过向DOM中动态添加script元素同样可以加载指定的脚本。只要创建一个script 元素并将其添加到DOM即可。

let script = document.createElement('script');
script.src = 'gibberish.js';
document.head.appendChild(script);

当然,在把HTMLElement 元素添加到DOM且执行到这段代码之前不会发送请求。默认情况下,以这种方式创建的<script> 元素是以异步方式加载的,相当于添加了async 属性。不过这样做可能会有问题,因为所有浏览器都支持createElement() 方法,但不是所有浏览器都支持async 属性。

因此,如果要统一动态脚本的加载行为,可以明确将其设置为同步加载:

let script = document.createElement('script');
script.src = 'gibberish.js';
script.async = false;
document.head.appendChild(script);

以这种方式获取的资源对浏览器预加载器是不可见的。这会严重影响它们在资源获取队列中的优先级。根据应用程序的工作方式以及怎么使用,这种方式可能会严重影响性能。要想让预加载器知道这些 动态请求文件的存在,可以在文档头部显式声明它们:

<link rel="preload" href="gibberish.js">

Script标签注意事项

1、使用了src 属性的<script> 元素不应该再在<script></script> 标签中再包含其他JavaScript代码。如果两者都提供的话,则浏览器只会下载并执行脚本文件,从而忽略行内代码

2、<script> 不受浏览器同源策略限制,允许我们跨域请求资源,以取得相应资源。来自外部域的代码会被当成加载它的页面的一部分来加载和解释。这个能力可以让我们通过不同的域分发JavaScript。不过,引用了放在别人服务器上的JavaScript文件时要格外小心,因为恶意的程序员随时可能替换这个文件。在包含外部域的JavaScript文件时,要确保该域是自己所有的,或者该域是一个可信的来源。<script> 标签的integrity 属性是防范这种问题的一个武器,但这个属性也不是所有浏览器都支持。

3、在使用行内JavaScript代码时,要注意代码中不能出现字符串</script> 。比如,下面的代码会导致浏览器报错:

<script>
function sayScript() {
    console.log("</script>");
}
</script>

浏览器解析行内脚本的方式决定了它在看到字符串</script>时,会将其当成结束的标签。想避免这个问题,只需要转义字符“\”即可:

<script>
function sayScript() {
    console.log("<\/script>");
}
</script>

noscript标签

针对早期浏览器不支持JavaScript的问题,需要一个页面优雅降级的处理方案。最终, <noscript> 元素出现,被用于给不支持JavaScript的浏览器提供替代内容。虽然如今的浏览器已经100%支持JavaScript,但对于禁用JavaScript的浏览器来说,这个元素仍然有它的用处。

<noscript> 元素可以包含任何可以出现在<body> 中的HTML元素, <script> 除外。在下列两种情况下,浏览器将显示包含在<noscript> 中的内容:

  • 浏览器不支持脚本;
  • 浏览器对脚本的支持被关闭。 任何一个条件被满足,包含在<noscript> 中的内容就会被渲染。否则,浏览器不会渲染<noscript> 中的内容。

例如用Vue脚手架搭建好的开发环境中的index.html文件中就用到了这个标签:

<noscript>
   <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>

以上就是文章的全部内容啦~

这是我的第一篇掘金文章,如果对你有一点点的帮助的话,希望可以点个赞支持一下😝

参考

JavaScript高级程序设计第四版
阮一峰老师的ES6教程
DOMContentLoaded
图解script标签中的async和defer属性