12k
All articles

在现代 JavaScript 中使用顶层 await

ES 模块中的顶层 await 消除了异步立即执行函数的变通写法,并影响模块执行顺序、动态导入及循环依赖的处理方式。

OpenReplay Team
OpenReplay Team
在现代 JavaScript 中使用顶层 await

简介

如果您曾经为了在模块级别使用 await 而将异步代码包装在立即调用函数表达式(IIFE)中,那么您并不孤单。在 ES2022 之前,JavaScript 开发者必须费尽周折才能在模块初始化期间处理异步操作。JavaScript 顶层 await 通过允许在 ES 模块中直接使用 await 而无需 async 函数包装器来改变这一现状。

本文将解释顶层 await 如何改变模块执行、其在配置加载和动态导入中的实际应用,以及您需要了解的关键权衡——包括执行阻塞和循环依赖陷阱。您将学会何时使用这个强大的功能,以及同样重要的是,何时避免使用它。

关键要点

  • 顶层 await 允许在 ES 模块中直接使用 await,无需包装在异步函数中
  • 模块执行变为异步,会阻塞依赖模块直到完成
  • 最适用于一次性初始化、配置加载和条件导入
  • 避免在库和工具中使用,以防止阻塞下游消费者
  • 需要 ES 模块和现代运行时支持(Node.js 14.8+,ES2022)

什么是顶层 await,为什么要引入它?

它解决的问题

顶层 await 之前,使用异步数据初始化模块需要变通方法:

// 使用 IIFE 的旧方法
let config;
(async () => {
  config = await fetch('/api/config').then(r => r.json());
})();

// 访问时 config 可能是 undefined!

这种模式会造成时序问题,并使代码更难理解。模块无法保证在导出值之前其异步依赖已准备就绪。

ES2022 解决方案

顶层 await 允许在模块作用域中直接使用 await 表达式:

// 现代方法
const config = await fetch('/api/config').then(r => r.json());
export { config }; // 导入时总是已定义

此功能专门在 ES 模块中工作——具有 .mjs 扩展名的文件,或在 package.json 中设置了 "type": "module" 的项目中的 .js 文件。在浏览器中,脚本必须使用 <script type="module">

顶层 await 如何改变模块执行

模块加载变为异步

当 JavaScript 遇到异步函数外的 await 时,它从根本上改变了该模块的加载方式:

  1. 解析阶段:引擎验证语法并识别导入/导出
  2. 实例化阶段:创建模块绑定但不进行求值
  3. 求值阶段:执行代码,在每个 await 处暂停
// database.js
console.log('1. 开始连接');
export const db = await connectDB();
console.log('2. 连接就绪');

// app.js
console.log('3. 应用启动');
import { db } from './database.js';
console.log('4. 使用数据库');

// 输出顺序:
// 1. 开始连接
// 3. 应用启动
// 2. 连接就绪
// 4. 使用数据库

级联效应

模块依赖会产生连锁反应。当模块使用顶层 await 时,每个导入它的模块——无论是直接还是间接——都会等待完成:

// config.js
export const settings = await loadSettings();

// auth.js
import { settings } from './config.js';
export const apiKey = settings.apiKey;

// main.js
import { apiKey } from './auth.js'; // 等待整个链条

常见用例和模式

动态模块加载

JavaScript 顶层 await 在基于运行时条件的条件导入方面表现出色:

// 根据环境加载数据库驱动
const dbModule = await import(
  process.env.DB_TYPE === 'postgres' 
    ? './drivers/postgres.js' 
    : './drivers/mysql.js'
);

export const db = new dbModule.Database();

配置和资源初始化

非常适合在模块执行前加载配置或初始化资源:

// i18n.js
const locale = await detectUserLocale();
const translations = await import(`./locales/${locale}.js`);

export function t(key) {
  return translations.default[key] || key;
}

WebAssembly 模块加载

简化 WASM 初始化,无需包装函数:

// crypto.js
const wasmModule = await WebAssembly.instantiateStreaming(
  fetch('/crypto.wasm')
);

export const { encrypt, decrypt } = wasmModule.instance.exports;

关键限制和权衡

仅限 ES 模块

顶层 await 有严格的上下文要求:

// ❌ CommonJS - 语法错误
const data = await fetchData();

// ❌ 经典脚本 - 语法错误
<script>
  const data = await fetchData();
</script>

// ✅ ES 模块
<script type="module">
  const data = await fetchData();
</script>

执行阻塞

每个 await 都会创建一个同步点,可能影响应用程序启动:

// slow-module.js
export const data = await fetch('/slow-endpoint'); // 5 秒延迟

// app.js
import { data } from './slow-module.js';
// 整个应用在此行运行前等待 5 秒

循环依赖死锁

顶层 await 使循环依赖更加危险:

// user.js
import { getPermissions } from './permissions.js';
export const user = await fetchUser();

// permissions.js
import { user } from './user.js';
export const permissions = await getPermissions(user.id);

// 结果:死锁 - 模块无限期地相互等待

生产使用的最佳实践

何时使用顶层 await

  • 一次性初始化:数据库连接、API 客户端
  • 配置加载:特定环境的设置
  • 功能检测:有条件地加载 polyfills

何时避免使用

  • 库模块:永远不要阻塞下游消费者
  • 频繁导入的工具:保持同步以提高性能
  • 有循环依赖风险的模块:改用异步函数

错误处理策略

始终处理失败以防止模块加载崩溃:

// 带回退的安全模式
export const config = await loadConfig().catch(err => {
  console.error('配置加载失败:', err);
  return { defaultSettings: true };
});

// 替代方案:让消费者处理错误
export async function getConfig() {
  return await loadConfig();
}

构建工具和运行时支持

现代工具以不同方式处理 JavaScript 顶层 await

  • Webpack 5+:通过 experiments.topLevelAwait 支持
  • Vite:在开发和生产中原生支持
  • Node.js 14.8+:在 ES 模块中完全支持
  • TypeScript 3.8+:需要 module: "es2022" 或更高版本

对于传统环境,考虑将异步逻辑包装在导出函数中,而不是使用顶层 await。

结论

顶层 await 改变了我们在 JavaScript 中编写异步模块初始化的方式,消除了 IIFE 变通方法,使代码更具可读性。然而,它的强大功能伴随着责任——阻塞模块执行和潜在的循环依赖问题需要仔细考虑。

将顶层 await 用于特定应用程序的初始化和配置加载,但要将其排除在共享库和工具之外。通过理解其能力和约束,您可以利用此功能编写更清洁、更易维护的 JavaScript 模块,同时避免暂停模块执行带来的陷阱。

常见问题

我可以在 Node.js CommonJS 模块中使用顶层 await 吗?

不可以,顶层 await 只在 ES 模块中工作。在 Node.js 中,使用 .mjs 文件或在 package.json 中设置 type module。CommonJS 模块必须继续使用异步函数或 IIFE 进行异步操作。

顶层 await 会影响 tree shaking 和包大小吗?

顶层 await 本身不会阻止 tree shaking,但可能会影响包分割。打包工具可能会以不同方式对使用顶层 await 的模块进行分组以维持执行顺序,可能会创建更大的块。

如何测试使用顶层 await 的模块?

大多数现代测试运行器都支持带有顶层 await 的 ES 模块。对于 Jest,启用实验性 ESM 支持。考虑模拟异步依赖或将初始化包装在函数中以便于测试。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.