Back

在现代 JavaScript 中使用顶层 await

在现代 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 模块,同时避免暂停模块执行带来的陷阱。

常见问题

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

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

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

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.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay