现代 Web 应用中 <script> 加载的完整指南:async、defer 与 ES Modules
发布: (2026年1月7日 GMT+8 10:14)
11 min read
原文: Dev.to
Source: Dev.to
请提供您希望翻译的正文内容,我将按照要求保留源链接、格式以及代码块,只翻译文本部分。
目录
为什么脚本加载策略很重要
脚本标签可能阻塞 HTML 解析,延迟交互,并改变事件时机。选择正确的策略:
- 改善核心网页指标(尤其是 FID/INP 和 LCP)
- 防止竞争条件和脆弱的依赖关系
- 通过
import/export和代码拆分实现可扩展的架构
四种加载脚本的方式
1️⃣ Classic(阻塞)
- 在下载 以及 执行时阻塞 HTML 解析。
- 执行顺序遵循 HTML 中的顺序。
- 仅在解析期间确实需要立即执行时使用。
2️⃣ async(classic)
- 并行下载;一旦准备好就立即执行。
- 执行顺序不可预测(取决于下载完成顺序)。
- 适用于独立脚本:分析、广告、信标等。
3️⃣ defer(classic)
- 并行下载;在 HTML 完全解析后、
DOMContentLoaded之前执行。 - 保持 HTML 顺序。
- 当不能使用模块时,作为应用代码的最佳 classic 选择。
4️⃣ ES 模块(type="module")
- 默认类似 defer 的入口脚本,并具备依赖感知加载。
- 作用域化(避免意外全局变量)、严格模式、
import/export、动态import()、顶层await、导入映射。 - 现代应用的默认选择。
解析、执行和事件的交互方式
| 加载类型 | 解析影响 | 执行时机 | 顺序保证 |
|---|---|---|---|
| Classic(blocking) | 阻止 HTML 解析在该标签处 | 下载完成后立即执行 | HTML 顺序 |
async | 不 阻止解析 | 脚本下载完成后立即执行 | 无(下载顺序) |
defer | 不 阻止解析 | 解析完成后、DOMContentLoaded 之前执行 | HTML 顺序 |
| Module(entry) | 不 阻止解析 | 解析完成后、DOMContentLoaded 之前执行(类似 defer) | 依赖图顺序 |
事件时机
DOMContentLoaded在所有 defer 和模块入口脚本执行完毕之后触发,并且在任何在DOMContentLoaded之前启动的async脚本完成后触发。load在 所有 资源(图片、CSS 等)加载完成后触发。
选择正确的方法
- 应用代码 →
type="module"(default)。 - 当模块不可用时的传统脚本 →
defer。 - 独立的第三方脚本 →
async。 - 避免 阻塞传统脚本——它们会延迟渲染和交互。
ES 模块深度解析
导入和导出
// app.js
export function init() {
console.log('App started');
}
// main.js
import { init } from './app.js';
init();
内联模块
import { init } from '/js/app.js';
init();
按需代码的动态 import()
const btn = document.querySelector('#showChart');
btn.addEventListener('click', async () => {
const { renderChart } = await import('./chart.js');
renderChart();
});
顶层 await
// config.js
export const cfg = await fetch('/api/config')
.then(r => r.json());
// main.js
import { cfg } from './config.js';
console.log('Config:', cfg);
模块作用域与严格模式
// my-module.js
const secret = 42; // Not on window
export const api = { hello: 'world' };
// Expose globally only if you must:
window.myApi = { doSomething() { /* … */ } };
裸标识符的导入映射
{
"imports": {
"lodash": "/vendor/lodash-es/lodash.js",
"@app/": "/static/app/"
}
}
import _ from 'lodash';
import util from '@app/util.js';
console.log(_.chunk([1,2,3,4], 2), util);
Web Worker 中的模块
// main thread
const worker = new Worker('/js/worker.js', { type: 'module' });
// worker.js
import { heavyCompute } from './heavy.js';
self.onmessage = e => postMessage(heavyCompute(e.data));
性能技巧
明智地使用资源提示
缓存与打包
- 使用 长期缓存,并为生产资产添加内容哈希。
- 即使在 HTTP/2/3 下,打包/分块也能降低请求开销并提升缓存命中率。
- 利用动态
import()实现对大型、少用模块的懒加载。
代码拆分与懒加载
// router.js – load route component only when needed
export async function loadDashboard() {
const { Dashboard } = await import('./pages/dashboard.js');
return new Dashboard();
}
压缩与压缩
- 将 JavaScript 以 gzip 或 Brotli (
br) 方式提供。 - 使用 esbuild、SWC 或 Terser 等工具进行压缩。
避免重复工作
- 不要 同时以 classic 方式和模块方式加载相同脚本。
- 当加载顺序重要时,将第三方库合并为单个 bundle。
DOM 时序模式
| 模式 | 何时使用 | 示例 |
|---|---|---|
DOMContentLoaded listener | 需要完整 DOM 但不依赖外部资源的代码 | document.addEventListener('DOMContentLoaded', initApp); |
load listener | 依赖图像、字体或其他媒体的代码 | window.addEventListener('load', () => { /* layout calculations */ }); |
requestIdleCallback | 可以延迟到主线程空闲时执行的非关键工作 | requestIdleCallback(() => { prefetchData(); }); |
setTimeout(..., 0) | 微任务队列刷新;在现代异步模式下很少需要 | setTimeout(() => console.log('next tick'), 0); |
Security and compliance
type="module"脚本会自动启用 strict mode 并拥有 module‑scope(不会意外产生全局变量)。- 对第三方脚本使用 Subresource Integrity (SRI):
- 设置 Content‑Security‑Policy(
script-src)以白名单方式列出受信任的来源,并禁止unsafe-inline。 - 在需要时,优先使用基于
nonce或 hash 的 CSP 来处理内联模块。
常见陷阱
- 在同一个脚本上混用
async和defer– 以最后出现的属性为准;避免混淆。 - 依赖模块中的全局变量 – 模块有作用域;只通过
export暴露需要的内容。 - 忘记给动态创建的
<script>元素添加type="module"– 默认是 classic。 - 在模块开始加载后使用
document.write– 在某些浏览器中会中止模块加载。 - 认为
modulepreload在所有浏览器都可用 – 为旧浏览器提供回退的<script>。
复制‑粘贴模板
经典阻塞(很少需要)
异步第三方
延迟加载经典应用包
ES 模块入口点
带 CSP nonce 的内联模块
import { init } from '/js/app.js';
init();
Import map(HTML5)
{
"imports": {
"react": "/libs/react/react.production.min.js",
"react-dom": "/libs/react/react-dom.production.min.js"
}
}
最终要点
- 默认使用 ES 模块 来编写任何新代码;它们提供类似 defer 的加载方式、严格模式以及清晰的依赖图。
- 仅在必须支持不理解模块的环境中使用
defer来兼容传统脚本。 - 将
async留给真正独立的第三方资源,这些资源不依赖 DOM 就绪或加载顺序。 - 将合适的加载策略与 资源提示、缓存 以及 CSP/SRI 结合,以最大化性能和安全性。
通过有意识地选择合适的脚本加载技术,您将提供更快、更可靠且更安全的 Web 体验。
执行顺序和示例
async vs defer
模块和依赖顺序
// entry.js
import './a.js'; // a.js imports b.js
console.log('entry');
// a.js
import './b.js';
console.log('a');
// b.js
console.log('b');
执行顺序: b → a → entry
DOM‑Timing 模式
使用 defer / 模块安全访问 DOM
// app.js
document.querySelector('#btn').addEventListener('click', () => {
console.log('clicked');
});
必须等待 DOM 的异步脚本
// metrics.js
window.addEventListener('DOMContentLoaded', () => {
// Safe to query the DOM
});
安全与合规
标头、CORS、MIME
- 使用正确的 MIME 类型提供 JavaScript:
application/javascript。 - ES 模块遵循 CORS;跨域导入需要适当的 CORS 头。
- 避免使用
file://URL 加载模块;请使用本地 HTTP(S) 服务器。
CSP 与子资源完整性 (SRI)
企业备注: 在添加第三方 CDN、库或分析工具之前,请确认它们符合贵组织的安全、隐私和合规性指南。
常见陷阱
浏览器导入时缺少 .js 扩展名
// ❌ 错误
import { x } from './utils';
// ✅ 正确
import { x } from './utils.js';
import‑map 放置位置
…
…
其他注意事项
- 假设
async能保持顺序。实际上不能——不要在多个 async 脚本之间链式逻辑以期望顺序执行。 - 在被广泛共享的模块中使用顶层
await会延迟整个应用。实际可行时请使用懒加载。
复制‑粘贴模板
使用模块的现代应用 + 传统回退
Modern Module App
{
"imports": {
"@app/": "/js/"
}
}
使用 defer 的经典方式
Classic Defer
Click
用于独立第三方脚本的 Async
Async Example
关键要点
- 首选
type="module"用于现代开发:可扩展的架构、更安全的作用域以及内置的代码拆分。 - 使用
defer在模块不可行时用于传统脚本。 - 仅在独立、顺序无关的脚本上使用
async。 - 注意 事件时机、缓存和安全(CSP、SRI、CORS)。
- 随着应用规模扩大,使用
modulepreload、动态导入和 import maps。
如果计划使用第三方 CDN 或库,请确保它们符合贵组织的内部安全和合规要求。