现代 Web 应用中 <script> 加载的完整指南:async、defer 与 ES Modules

发布: (2026年1月7日 GMT+8 10:14)
11 min read
原文: Dev.to

Source: Dev.to

请提供您希望翻译的正文内容,我将按照要求保留源链接、格式以及代码块,只翻译文本部分。

目录

为什么脚本加载策略很重要

脚本标签可能阻塞 HTML 解析,延迟交互,并改变事件时机。选择正确的策略:

  • 改善核心网页指标(尤其是 FID/INPLCP
  • 防止竞争条件和脆弱的依赖关系
  • 通过 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 以 gzipBrotli (br) 方式提供。
  • 使用 esbuildSWCTerser 等工具进行压缩。

避免重复工作

  • 不要 同时以 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‑Policyscript-src)以白名单方式列出受信任的来源,并禁止 unsafe-inline
  • 在需要时,优先使用基于 noncehash 的 CSP 来处理内联模块。

常见陷阱

  1. 在同一个脚本上混用 asyncdefer – 以最后出现的属性为准;避免混淆。
  2. 依赖模块中的全局变量 – 模块有作用域;只通过 export 暴露需要的内容。
  3. 忘记给动态创建的 <script> 元素添加 type="module" – 默认是 classic。
  4. 在模块开始加载后使用 document.write – 在某些浏览器中会中止模块加载。
  5. 认为 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 或库,请确保它们符合贵组织的内部安全和合规要求。

Back to Blog

相关文章

阅读更多 »

Htmx:HTML 的强大工具

Article URL: https://github.com/bigskysoftware/htmx Comments URL: https://news.ycombinator.com/item?id=46524527 Points: 9 Comments: 1...