先记住这个答案
顶层 await 让模块的求值过程可以暂停:该模块后面的语句等 Promise 完成后才运行,导入它的模块也要等它完成,才能执行自己的正文。这种等待会沿着依赖关系传下去,但没有依赖它的模块可以继续加载和执行。await 不会同步卡住浏览器主线程。把它说成“阻塞所有兄弟模块的解析”是不准确的。
- 顶层
await只在模块顶层可用 - 依赖该模块的正文要等异步结果
- 无关模块不会因此停止加载
- 等待失败会阻止依赖模块正常执行
从配置模块看等待范围
假设页面入口导入配置模块,配置模块要先读一份异步配置。入口不能在配置尚未就绪时使用导出的值,因此入口正文会等配置模块完成。这里等待的是依赖它的执行链;页面中另一个不依赖配置的模块仍可推进。
// config.js:顶层 await 暂停本模块后续语句
const response = await fetch('/api/config')
export const config = await response.json()
// main.js:静态导入 config.js
// import { config } from './config.js'
// console.log(config.theme) // 要等 config.js 完成才运行示例用两个文件说明顺序。main.js 的导入声明在执行前建立依赖,不能把 config.theme 当作“还没加载好时先读到 undefined”。配置模块成功完成求值后,入口才开始执行自己的正文。若 fetch 或解析 JSON 失败且没有在配置模块里处理,依赖它的模块也无法正常完成求值。
与普通异步函数有什么不同
在普通 async 函数里写 await,暂停的是那次函数调用。调用者拿到 Promise,可以自行决定何时等待。顶层 await 写在模块正文中,模块自身就要等待;静态导入者不能绕过模块求值阶段,先执行自己的顶层代码。
如果启动时根本不需要这份配置,可以把读取动作封装成异步函数,在用户真正进入功能时调用。另一种选择是动态 import(),让模块到使用时才加载。它们解决的是“什么时候启动异步工作”;顶层 await 解决的是“模块开始求值后,要不要等结果才暴露完成状态”。
启动性能和失败处理
顶层 await 很适合模块确实需要先完成初始化才能被依赖者使用的情况,例如读取配置或建立连接。代价是网络很慢时,整条依赖链的正文都要等。若把它放在许多页面共同导入的基础模块里,等待范围会扩大;把可延后的工作放到具体功能入口,通常更容易控制首屏时间。
失败处理也应靠近异步操作。模块内部可以用 try 和 catch 决定默认值或明确抛出错误。不要假设导入者的顶层代码能先运行并补救,因为它要等依赖模块完成求值才会启动。若设计了循环依赖,还需额外检查初始化顺序,顶层等待会让原本隐蔽的顺序问题更难排查。
容易答错的地方
- 说顶层
await会阻塞整个页面 await暂停相关模块的执行,不会把主线程锁住,也不会让所有不相关模块停下。分析影响范围时画出依赖链:谁导入了等待中的模块,谁的正文就需要等。- 以为导入者能先读到半初始化的导出
- 静态导入者会等待依赖完成模块求值。不能把这件事类比为对象属性还没赋值时读到
undefined。异步求值失败时,导入链上的模块也无法按正常路径执行。
面试官还会怎么问?
普通 <script> 顶层能直接写 await 吗?
不能。顶层 await 用于模块;普通脚本的顶层直接写会产生语法错误。浏览器里要用模块脚本,或把异步代码放进 async 函数。
两个互不依赖的模块,其中一个等待网络,另一个也要等吗?
不需要仅因为“同在一个页面”就等待。模块执行的先后还受各自加载方式影响,但顶层 await 的依赖等待只沿模块图传播,不能概括成所有兄弟模块都被它阻塞。
怎样避免一个慢接口拖住整个入口?
先确认入口是否必须在接口返回后才能工作。若不是,改为在用户进入对应功能时调用异步函数,或动态导入该功能模块。若确实必须等待,就为接口设置明确的失败处理和加载反馈。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。