JavaScript 资源加载
页面加载事件
HTML 页面的生命周期(从加载到用户离开)包含三个重要事件:
- 事件
DOMContentLoaded
在浏览器已完全加载 HTML,并构建了 DOM 树时触发,但像<img>
和样式表之类的外部资源可能尚未加载完成。表示 DOM 已经就绪,因此处理程序查找 DOM 节点,并初始化接口。 - 事件
load
在页面资源完全加载后触发,不仅浏览器加载完成了 HTML,还加载完成了所有外部资源,如图片,样式等。表示外部资源已加载完成,样式已被应用,图片大小也已知了,处理程序可以获取相关信息。 - 事件
beforeunload/unload
在用户正在离开页面时触发。- 事件
beforeunload
表示用户正在离开,处理程序可以检查用户是否保存了更改,并询问他是否真的要离开。 - 事件
unload
表示用户几乎已经离开了,处理程序仍可启动一些操作,如发送统计数据。
- 事件
Tip
文档属性 document.readyState
提供当前页面的加载状态信息,它有 3 个可能值,分别对应不同的状态(这些状态分别可以触发相应的事件),可以基于该属性值进行相关的处理,类似于基于事件设置处理程序
loading
文档正在被加载。interactive
文档被全部读取。complete
文档被全部读取,并且所有资源(例如图片等)都已加载完成。
function work() { /*...*/ }
if (document.readyState == 'loading') {
// 仍在加载,等待事件,此过程设置监听器依然奏效
document.addEventListener('DOMContentLoaded', work);
} else {
// DOM 已就绪!如果在该状态下设置 DOMContentLoaded 事件监听器已无效,应该直接调用程序
work();
}
Tip
事件 readystatechange
会在页面加载状态发生改变时触发
// 当前状态
console.log(document.readyState);
// 状态改变时打印它
document.addEventListener('readystatechange', () => console.log(document.readyState));
DOMContentLoad
事件 DOMContentLoaded
发生在 document
对象上,必须使用 addEventListener
来捕获它。
Warning
事件 DOMContentLoaded
是在浏览器构建完 DOM 树时触发的,如果将该事件处理程序设置在文档加载完成之后,它就永远不会运行。
<script>
function ready() {
alert('DOM is ready');
// 图片目前尚未加载完成(除非已经被缓存),所以图片的大小为 0x0
alert(`Image size: ${img.offsetWidth}x${img.offsetHeight}`);
}
document.addEventListener("DOMContentLoaded", ready);
</script>
<img id="img" src="https://en.js.cx/clipart/train.gif?speed=1&cache=0">
当浏览器处理一个 HTML 文档,并在文档中遇到 <script>
标签时,就会在继续构建 DOM 之前运行它,相当于脚本 <script>
「阻塞」DOMContentLoaded
的处理程序执行,这是一种防范措施,因为脚本可能想要修改 DOM,甚至对其执行 document.write
操作。由于 DOMContentLoaded
是 DOM 构建完成时才触发的事件,因此其处理程序必须等待页面的脚本执行结束才执行。
但有两种脚本例外,它们不会阻塞 DOMContentLoaded
的处理程序执行
- 具有
async
特性的脚本不会阻塞DOMContentLoaded
事件触发。 - 使用
document.createElement('script')
动态生成并添加到网页的脚本也不会阻塞DOMContentLoaded
事件触发。
Tip
外部样式表不会影响 DOM 的构建,因此事件 DOMContentLoaded
的处理程序不会等待它们。但是如果在样式后面有一个脚本,那么该脚本必须等待样式表加载完成,原因是后面的脚本可能想要获取元素的坐标和其他与样式相关的属性,而如果事件 DOMContentLoaded
等待脚本时,它相当于也在等待脚本前面的样式。
<!-- 一般在 HTML 文档的元素 <head> 中会有这样的结构 -->
<link type="text/css" rel="stylesheet" href="style.css">
<script>
// 在样式表加载完成之前,脚本都不会执行
alert(getComputedStyle(document.body).marginTop);
</script>
Tip
如果页面有一个带有登录名和密码的表单,并且浏览器(Firefox、Chrome、Opera 等都支持)记住了这些值,那么在事件 DOMContentLoaded
触发时,浏览器的默认处理程序会尝试自动填充它们(如果得到了用户允许)。但如果 DOMContentLoaded
被需要加载很长时间的脚本延迟触发,这就可能出现登录名/密码字段不会立即自动填充,而是会等待在页面被完全加载后 DOM 构建完成时再填充。
window onload
当整个页面,包括样式、图片和其他资源被加载完成时,会触发 window
对象上的 load
事件。可以在 window
对象上通过属性 onload
或 window.addEventListener('load', function(event){})
设置处理程序。
<script>
window.onload = function() { // 与此相同是方法 window.addEventListener('load', (event) => {})
alert('Page loaded');
// 此时图片已经加载完成
alert(`Image size: ${img.offsetWidth}x${img.offsetHeight}`);
};
</script>
<img id="img" src="https://en.js.cx/clipart/train.gif?speed=1&cache=0">
window onunload
当访问者离开页面时 window
对象上的 unload
事件就会被触发。我们可以在那里做一些不涉及延迟的操作,如关闭相关的弹出窗口、发送分析数据等。
假设我们收集有关页面使用情况的数据,如鼠标点击,滚动,被查看的页面区域等,当用户要离开的时候,我们希望通过 unload
事件触发相关处理程序将数据保存到我们的服务器上,在规范中描述了一个特殊的方法 navigator.sendBeacon(url, data)
可以满足这种需求,它在后台发送数据(即使浏览器离开页面),使得转换到另外一个页面不会有延迟。
该方法的一些细节
- 请求以 POST 方式发送。
- 我们不仅能发送字符串,还能发送表单以及其他格式的数据,但通常它是一个字符串化的对象。
- 数据大小限制在 64kb。
- 当
sendBeacon
请求完成时,浏览器可能已经离开了文档,所以就无法获取服务器响应(对于分析数据来说通常为空)。
let analyticsData = { /* 带有收集的数据的对象 */ };
window.addEventListener("unload", function() {
navigator.sendBeacon("/analytics", JSON.stringify(analyticsData));
};
Tip
在通用的网络请求中存在一个 keep-alive
标志,用以执行此类「离开页面后」的请求。
Warning
如果要执行取消跳转到另一页面的操作,需要在另一个事件 onbeforeunload
进行处理。
window onbeforeunload
如果访问者触发了离开页面的导航(navigation)或试图关闭窗口,事件 beforeunload
会被触发,可以在处理程序要求进行更多确认,如询问用户是否确定关闭/离开页面。
// 当页面重新加载/准备关闭时,会弹出提示框询问用户是否执行相关操作
window.onbeforeunload = function() {
return false;
};
Tip
由于历史原因事件 beforeunload
的处理程序返回非空字符串也被视为取消事件,会要求用户进行更多确认。在以前浏览器曾经将字符串显示为消息,但是根据 现代规范 所述它的行为已经改变了,因为有些站长通过显示误导性和恶意信息滥用了此事件处理程序。所以目前除了一些旧的浏览器可能仍将其显示为消息,其他浏览器无法自定义显示给用户的消息。
window.onbeforeunload = function() {
return "There are unsaved changes. Leave now?";
};
外部资源加载事件
浏览器允许我们跟踪外部资源的加载,如脚本、iframe(内嵌独立页面)、图片等。
资源加载状态可能触发有两个事件 load
和 error
之一,可以监听相应的事件以设置处理程序。
elem.onload
监听成功加载的状态elem.onerror
监听加载出现error
的状态
Tip
事件 readystatechange
也适用于监听外部资源加载情况,但很少被使用因为 load/error
事件更简单。
onload 和 onerror
假设我们需要加载第三方脚本并调用其中的函数,但需要等到该脚本加载完成之后才能调用它,可以设置 script.onload
监听脚本成功加载的状态,并在处理程序中调用相应的函数。
let script = document.createElement('script');
// 可以从任意域 domain 加载任意脚本
script.src = "https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.3.0/lodash.js"
document.head.append(script);
script.onload = function() {
// 脚本中含有一个辅助函数 "_"
alert(_); // 在脚本加载成功后,该函数可用
};
类似地,可以设置 script.onerror
监听脚本加载出现 error
的状态,并为用户发出反馈。
let script = document.createElement('script');
script.src = "https://example.com/404.js"; // 没有这个脚本
document.head.append(script);
script.onerror = function() {
alert("Error loading " + this.src); // Error loading https://example.com/404.js
};
Warning
onload
/onerror
事件仅跟踪加载过程本身,对于在脚本中处理和执行期间可能发生的 error
超出了这些事件跟踪的范围,即如果脚本成功加载就会触发 onload
事件,即使脚本中有编程 error。如果要跟踪脚本运行时可能抛出的 error ,可以使用 window.onerror
全局处理程序。
Tip
基本上事件 load
和 error
适用于具有外部 src
的任何资源,它们会在资源加载完成/出错时触发,但是有一些例外规则:
- 大多数资源在被(动态)添加到文档中后便开始加载,但是
<img>
是个例外,它要在设置了src
特性后才开始加载。 - 对于
<iframe>
加载完成时会触发iframe.onload
事件,无论是成功加载还是出现 error。
跨域策略
跨域策略是指一个源(域/端口/协议三者)无法获取另一个源 origin 的内容,即来自一个网站的脚本无法访问其他网站的内容,如位于 https://facebook.com
的脚本无法读取位于 https://gmail.com
的用户邮箱的内容,该策略主要是出于 Web 安全考虑。
但是在监听外部资源加载时跨域策略就会就会引起问题,如果加载的脚本时来自其他域,并且该脚本中存在 error,那么我们无法获取 error 的详细信息。对其他类型的资源也执行类似的跨源策略(CORS)。
// 该脚本中包含一个(错误的)函数调用
noSuchFunction();
<script>
window.onerror = function(message, url, line, col, errorObj) {
alert(`${message}\n${url}, ${line}:${col}`);
};
</script>
<!-- 从它所在的同一个网站加载它 -->
<script src="/article/onload-onerror/crossorigin/error.js"></script>
<!-- 输出详细的 error 报告 -->
<!-- Uncaught ReferenceError: noSuchFunction is not defined
https://javascript.info/article/onload-onerror/crossorigin/error.js, 1:1 -->
<script>
window.onerror = function(message, url, line, col, errorObj) {
alert(`${message}\n${url}, ${line}:${col}`);
};
</script>
<!-- 从另一个域中加载相同的脚本 -->
<script src="https://cors.javascript.info/article/onload-onerror/crossorigin/error.js"></script>
<!-- 无法读取跨域信息 -->
<!-- Script error.
, 0:0-->
详细的错误信息有利于调试和解决问题,因为有很多服务使用 window.onerror
在全局监听由用户触发的 error,保存 error 并提供访问和分析 error 的接口供开发者访问。要允许跨源访问需要 <script>
标签具有特性 crossorigin
,并且远程服务器必须提供特殊的响应头。
这里有三个级别的跨源访问
- 无
crossorigin
特性 表示禁止访问。 crossorigin="anonymous"
而且服务器的响应头Access-Control-Allow-Origin
中包含*
或我们的源origin 则允许访问。但浏览器不会将授权信息和 cookie 发送到远程服务器。crossorigin="use-credentials"
而且服务器发的响应头Access-Control-Allow-Origin
中包含我们的源origin 的源,还有Access-Control-Allow-Credentials: true
则允许访问。浏览器会将授权信息和 cookie 发送到远程服务器。
<script>
window.onerror = function(message, url, line, col, errorObj) {
alert(`${message}\n${url}, ${line}:${col}`);
};
</script>
<!-- 设置特性 crossorigin="anonymous" 以跨域获取详细的错误消息 -->
<script crossorigin="anonymous" src="https://cors.javascript.info/article/onload-onerror/crossorigin/error.js"></script>
<!-- 假设服务器响应头提供了 Access-Control-Allow-Origin -->
脚本加载方式
现代的网站中,脚本往往比 HTML 更「重」,它们的大小通常更大,处理时间也更长。当浏览器加载 HTML 时遇到 <script>...</script>
标签(对于外部脚本也一样),默认停止继续构建 DOM,立刻处理此脚本,必须等待脚本下载完并执行结束之后才能继续处理剩余的页面。
这会导致两个重要的问题:
- 脚本将不能访问到位于它们下面的 DOM 元素,无法给它们添加处理程序等。可以将脚本放在页面底部解决该问题,因此一般将脚本置于
</body>
元素之前,⚠️ 但是这种解决方案远非完美,因为浏览器只有在下载了完整的 HTML 文档之后才会处理最后的脚本(开始下载它),对于长的 HTML 文档来说这可能会造成明显的延迟,页面内容显示了但用户却无法与图形组件进行交互(由于脚本并未加载)。 - 如果页面顶部有一个笨重的脚本,它会「阻塞页面」,在该脚本下载并执行结束前,用户都不能看到页面内容。
JavaScript 提供两个元素 <script>
的特性 defer
和 async
可以解决脚本阻塞页面渲染的问题。
特性 | 顺序 | DOMContentLoaded` | 备注 |
---|---|---|---|
async | 加载优先顺序。脚本在文档中的顺序不 重要 —— 先加载完成先执行 | 不相关。可能在文档加载完成前加载并执行完毕。如果脚本很小或者来自于缓存,同时文档足够长,就会发生这种情况。 | / |
defer | 文档顺序(它们在文档中的顺序) | 在文档加载和解析完成之后(如果需要,则会等待),即在 DOMContentLoaded 之前执行。 | 仅适用于外部脚本 |
Tip
在实际开发中,特性 defer
适用于需要整个 DOM 的脚本,和/或脚本的相对执行顺序很重要的时候;特性 async
用于独立脚本,即脚本的相对执行顺序无关紧要。
defer
具有 defer
特性的脚本不会阻塞页面,因为该特性告诉浏览器它应该继续处理页面,并「在后台」同步下载脚本,然后等页面加载完成后(在 DOMContentLoaded
事件之前)再执行此脚本。
<p>...content before scripts...</p>
<script>
document.addEventListener('DOMContentLoaded', () => alert("DOM ready after defer!")); // 最后执行
</script>
<script defer src="https://javascript.info/article/script-async-defer/long.js?speed=1"></script> // 页面渲染后执行
<!-- 立即可见 -->
<p>...content after scripts...</p>
Tip
多个具有 defer
特性的脚本同步下载,然后就像常规脚本一样根据其相对顺序依次执行,即使在后较短的脚本先下载完但也是后执行的。
Warning
defer
特性仅适用于外部脚本,如果 <script>
脚本没有 src
则会忽略 defer
特性。
async
具有特性 async
的脚本是完全独立,即页面加载与异步脚本下载执行互不影响。而且事件 DOMContentLoaded
的处理程序和异步脚本不会彼此等待,事件 DOMContentLoaded
可能在异步脚本执行之前触发(如果异步脚本在页面 DOM 构建完成后才加载完成)也可以在异步脚本之后触发(如果异步脚本很短,或者是从 HTTP 缓存中加载的)。对于其他脚本(可能也是异步脚本)不会等待 async
脚本加载完成,同样 async
脚本也不会等待其他脚本。
异步脚本不会等待彼此,执行顺序与它们在文档中的相对位置无关,它们同步下载先加载完成的脚本先执行,称为「加载优先」顺序。
<p>...content before scripts...</p>
<script>
document.addEventListener('DOMContentLoaded', () => alert("DOM ready!"));
</script>
<!-- 虽然较小的脚本 small.js 排在第二位,但可能会比 long.js 这个长脚本先加载完成,所以 small.js 会先执行 -->
<script async src="https://javascript.info/article/script-async-defer/long.js"></script>
<script async src="https://javascript.info/article/script-async-defer/small.js"></script>
<p>...content after scripts...</p>
Tip
当我们将独立的第三方脚本,如计数器,广告等,集成到页面时,采用异步加载方式是非常棒的,因为它们不依赖于我们的脚本,我们的脚本也不应该等待它们。
动态脚本
可以使用 JavaScript 动态地添加脚本,默认情况下动态脚本的行为是「异步」的。
// 动态添加脚本
let script = document.createElement('script');
script.src = "/article/script-async-defer/long.js";
document.body.append(script); // 当脚本被附加到文档就会立即开始加载
可以将 async
特性显式地修改为 false
,以将脚本的加载顺序更改为文档顺序(就像常规脚本一样)。
function loadScript(src) {
let script = document.createElement('script');
script.src = src;
script.async = false;
document.body.append(script);
}
// long.js 先执行,因为代码中设置了 async=false
loadScript("/article/script-async-defer/long.js");
loadScript("/article/script-async-defer/small.js");