先记住这个答案
ESM 有一条规则:一个模块开始执行时,它静态导入的模块必须已经执行完,这样它拿到的导出才是初始化好的。子模块顶层写了 await,它就成了异步模块,要等那个 Promise 结束才算执行完。父模块只能等着,父模块的父模块也一样,一路往上传。规避办法是不在模块顶层等:导出一个 Promise 或 init() 函数让调用方自己决定何时等,或者用动态 import() 在需要时再加载。
- 顶层
await让模块变成异步模块,父模块要等它执行完 - 只阻塞依赖链上的祖先,不相关的兄弟模块照常执行
- 模块只执行一次,所有导入方共用同一个等待结果
- 规避:导出 Promise 或
init(),或改用动态import()
看一下执行顺序
入口模块 main.js 先后导入了 config.js 和 log.js。config.js 顶层要等一个异步请求,log.js 是普通模块。
// config.js
console.log('config start')
export const config = await fetch('/config.json').then((r) => r.json())
console.log('config done')
// log.js
console.log('log runs')
// main.js(入口)
import { config } from './config.js'
import './log.js'
console.log('main runs', config)
// 打印顺序:
// config start
// log runs ← 兄弟模块不用等 config
// config done
// main runs {...} ← 父模块等 config 执行完才开始config.js 执行到 await 就暂停了。引擎不会干等,它会去执行其他不依赖 config.js 的模块,所以 log.js 先跑。main.js 静态导入了 config.js,要读 config 这个导出,只能等 config done 之后才开始。
这里的「等」发生在执行阶段。模块文件的下载、解析、导入导出的连接在这之前已经全部做完了,顶层 await 推迟的只是代码的运行。
为什么要这样设计
如果父模块不等,它读到的 config 就是还没赋值的状态,访问会抛 ReferenceError,或者读到一半初始化的数据。顶层 await 的设计目标就是让模块作者能写「先把数据准备好,再导出」的代码,导入方拿到手的就是准备好的值,不用自己再判断。
代价是异步会顺着依赖链传染。一个底层工具模块加了顶层 await,所有直接或间接导入它的模块都要等。它还只执行一次:十个模块都导入 config.js,请求只发一次,十个模块一起等同一个结果。
两种规避方式
判断的标准是:这份数据是不是所有导入方启动时都必须有。必须有,那就接受阻塞,这正是顶层 await 的用途;只有少数功能才用到,就别让整个应用为它等。
// config.js:不在顶层等,只把 Promise 导出去
export const configReady = fetch('/config.json').then((r) => r.json())
// main.js:模块本身立刻执行,真正用到时再取结果
import { configReady } from './config.js'
renderShell() // 页面框架先渲染出来,不用等配置
configReady.then((config) => {
renderContent(config) // 请求在导入时就发出了,这里只是取结果
})config.js 同步执行完,main.js 马上可以跑,先把不依赖配置的界面渲染出来。请求照样在模块加载时就发出,没有浪费时间。和它等价的写法是导出一个 init() 函数,由入口在合适的时机调用。
- 用动态
import()把依赖移出静态依赖图 - 动态
import()不属于静态依赖,父模块执行时不会等它。比如图表模块顶层要await加载一个大字体文件,就在用户点开图表时再const { draw } = await import('./chart.js'),只有这段逻辑在等,页面其他部分不受影响。
容易答错的地方
- 以为顶层
await会阻塞整个页面 - 它只阻塞依赖这个模块的祖先模块。没有依赖关系的兄弟模块可以在它等待期间执行,主线程也没有被卡住,事件照样响应,只是依赖它的模块代码还没开始跑。
- 以为每个导入方都会重新执行一次
- 模块只实例化和执行一次,结果被缓存。多个模块导入同一个异步模块,是一起等同一个 Promise,不会重复请求。
- 顶层
await一个永远不结束的 Promise - 比如等一个没设超时的请求,而服务端一直没响应,所有祖先模块就永远不会执行,页面没有任何报错,只是一片空白。顶层等待网络时,要配合
AbortSignal.timeout()这类超时手段。
面试官还会怎么问?
顶层 await 的 Promise 被拒绝了会怎样?
这个模块执行失败,所有依赖它的祖先模块都不会执行,错误会让入口模块的加载失败。浏览器里表现为控制台报错、后续脚本没跑。所以顶层 await 最好自己 try/catch,失败时导出一个兜底值。
普通 <script> 或 CommonJS 里能用顶层 await 吗?
不能。顶层 await 只在 ES 模块里可用,普通脚本里写会报语法错误。Node.js 里用 require() 加载一个含顶层 await 的 ES 模块也会失败,报 ERR_REQUIRE_ASYNC_MODULE,提示改用 import()。
打包工具怎么处理顶层 await?
Webpack 5 支持,它会把依赖了异步模块的整条链编译成异步加载的形式,较早的 5.x 版本需要在配置里打开 experiments.topLevelAwait。Vite 构建时要求 build.target 本身支持顶层 await,目标里有老浏览器就会直接报错。所以遇到构建报错,先检查目标环境配置。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。