JavaScript 脚本加载

最后更新: 2025-09-19 20:13:39 +0800 +0800
原文以英文撰写。您正在阅读AI翻译版本。

HTML 解析器、脚本获取器和 JavaScript 引擎可以并发工作,但不同脚本类型和属性具有不同的执行约束。应根据依赖关系图和就绪契约选择模式,而不要仅仅为了性能提示添加 async 或 defer。

理解阻塞解析器的经典脚本 #

不带 async 或 defer 的外部经典脚本会在其元素所在位置阻塞解析器。浏览器会先获取并执行脚本,然后再解析后续标记:

html
<script src="/library.js"></script>
<script src="/application.js"></script>

第二个脚本可以读取第一个脚本创建的全局变量,因为文档顺序就是执行顺序。内联经典脚本也会在解析器到达其位置时立即运行,因此只能访问截至该位置已经解析的 DOM。

当脚本必须调用 document.write,或必须在解释后续标记之前建立状态时,阻塞解析器可能是有意为之。应用程序代码应避免这类依赖,因为网络延迟会同时推迟 DOM 构建和首次渲染。

延迟执行有序的经典脚本 #

defer 适用于外部经典脚本。脚本获取会与 HTML 解析并行进行,然后延迟脚本会在解析完成后、DOMContentLoaded 之前按文档顺序执行:

html
<script defer src="/library.js"></script>
<script defer src="/application.js"></script>

第二个脚本保留对第一个脚本的依赖,同时不会阻塞解析器。defer 属性对内联经典脚本不起作用;应改用模块或显式的就绪回调。

延迟脚本仍可能因为获取缓慢或同步执行而推迟 DOMContentLoaded。脚本所需的样式表也可能推迟其执行,因为脚本代码可能依赖计算后的样式。

异步加载独立脚本 #

带有 async 的外部经典脚本会在准备就绪后立即执行。获取过程不会阻塞解析,但如果响应在解析仍在进行时到达,执行脚本会暂停解析器:

html
<script async src="https://analytics.example/sdk.js"></script>

多个异步脚本之间不保证执行顺序。它们不得相互依赖,不得依赖完整的 DOM,也不得假设自己会在 DOMContentLoaded 之前运行。DOMContentLoaded 不会等待这些脚本,但窗口的 load 事件会等待属于文档未完成加载工作的脚本。

如果后续代码依赖异步初始化,请使用集成方提供的显式回调或 promise。在某个网络条件下观察到全局变量存在,并不能建立有效的顺序契约。

将 ES 模块作为依赖关系图加载 #

type="module" 会同时改变加载和执行方式:

html
<script type="module" src="/main.js"></script>

模块脚本默认延迟执行,内联模块也不例外。静态 import 声明会形成一张图,其依赖项会在模块求值前获取。模块使用严格模式,拥有自己的顶层作用域,并且针对每个解析后的模块标识只求值一次,而不是针对每个 <script> 元素求值一次。

模块获取使用 CORS。跨源模块及其所有跨源依赖项都必须返回适当的 Access-Control-Allow-Origin 响应,并且必须使用 JavaScript 兼容的媒体类型。模块获取失败时不会回退到经典脚本行为。

向模块脚本添加 async 后,其依赖图可以在准备就绪后立即求值,从而移除通常的文档顺序行为:

html
<script type="module" async src="/independent-widget.js"></script>

顶层 await 会暂停依赖于等待模块的其他模块的求值。不要把 DOM 就绪视为应用程序初始化完成的证明;当消费者必须等待异步模块工作完成时,应导出或发布一个初始化 promise。

动态插入脚本 #

由 JavaScript 创建的脚本元素默认具有强制异步行为:

js
function loadScript(src) {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = src;
    script.addEventListener("load", resolve, { once: true });
    script.addEventListener("error", reject, { once: true });
    document.head.append(script);
  });
}

返回的 promise 定义了一个可用的完成边界:

js
await loadScript("/library.js");
await loadScript("/application.js");

加载器可以在插入前设置 script.async = false,从而将动态插入的经典脚本放入有序队列。当失败需要停止依赖脚本的加载时,显式的 promise 顺序控制更加清晰。动态 import() 是对应的模块机制,并且只有在所请求的模块依赖图完成求值后才会解析。

在 DOMContentLoaded 之后动态添加脚本不会再次触发该事件。在窗口 load 事件之前添加会延迟加载的工作可能推迟 load;在事件发生后添加则无法重新触发该事件。

选择正确的就绪事件 #

DOMContentLoaded 表示解析已完成,并且解析器延迟脚本已经运行。它不表示图像、异步脚本、动态启动的应用程序工作或所有自定义元素都已就绪:

js
function start() {
  // The parsed DOM is available here.
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", start, { once: true });
} else {
  start();
}

对于可能在该事件之后才开始运行的模块和动态加载代码,readyState 分支很重要。只有当布局或行为需要图像、框架等加载完成的资源时,才使用 load。当只涉及一个依赖项时,应优先使用资源专用的 promise,例如 image.decode()、document.fonts.ready 或模块自身的初始化 promise。

阻塞解析器的脚本提供即时顺序控制,defer 提供解析后的有序执行,async 提供独立的机会式执行,而模块提供经过 CORS 检查的依赖关系图。使机制与依赖契约相匹配,可以消除在不同缓存或网络条件下会失效的时序假设。

评论

在 GitHub 上阅读或评论(发表评论需要登录)