ESM 顶层 await 之后的 const 声明会触发 TDZ 崩溃
写了一个数据库迁移脚本,本地跑了十几次都没问题,部署到容器里第一次走完整路径就炸了:ReferenceError: Cannot access 'appliedMap' before initialization。堆栈指向的那行代码看起来毫无问题——appliedMap 就在同一个文件里声明着,类型检查也是绿的。
问题根在 ESM 顶层 await 与 const 的 TDZ(Temporal Dead Zone)之间的一个反直觉的交互。
现象
迁移脚本入口大概长这样:
// migrate-schema.mjs
import mysql from 'mysql2/promise'
const conn = await mysql.createConnection({ uri: process.env.DB_URL }) // 顶层 await,第 10 行
try {
await cmdRun() // 触发 listApplied()
} finally {
await conn.end()
}
async function cmdRun() {
const [files, applied] = await Promise.all([
listLocalMigrations(),
listApplied(), // ← 从这里崩溃
])
}
const appliedMap = new Map() // 第 213 行,远在文件末尾
async function listApplied() {
const [rows] = await conn.query('SELECT filename FROM schema_migrations')
appliedMap.clear() // ← ReferenceError: Cannot access 'appliedMap' before initialization
rows.forEach(r => appliedMap.set(r.filename, r))
return new Set(rows.map(r => r.filename))
}本地测试走的是"无待迁移"快速路径,listApplied 根本没被调用,所以一直绿灯。容器里第一次完整跑,Promise.all 同时触发了 listApplied,当场崩溃。
根因
ESM 模块本质上是一个大的异步函数体。其评估顺序是:
- 先提升:所有
function声明(cmdRun、listApplied)被提升到顶部,名字在作用域里注册——但const/let声明的名字同样注册,只是绑定未初始化,处于 TDZ。 - 逐行执行:遇到顶层
await,模块评估在此挂起。 - await 恢复后继续:恢复执行,调用
cmdRun()→listApplied()。 - 此时
const appliedMap = new Map()那行根本还没到:它在第 213 行,模块评估还没走到那里。appliedMap仍在 TDZ →ReferenceError。
没有顶层 await 时,整个模块体在任何异步回调触发之前已经同步跑完——所有 const/let 都初始化完毕,不会有 TDZ 问题。
加入顶层 await 后,模块评估本身变成可挂起的异步序列。挂起期间派发出去的异步函数,可以在模块评估尚未到达某个 const 声明时就去访问它。 函数体里的引用在编译期合法(函数提升),但运行期命中 TDZ。
TypeScript、ESLint、以及任何静态分析工具对此全程沉默——因为 TDZ 是纯运行时检查。
正解
把被共享的 const 移到第一个顶层 await 之前的同步前置区:
// ✅ 修复后
import mysql from 'mysql2/promise'
// 必须在第一个顶层 await 之前声明,否则 cmdRun → listApplied 访问时仍在 TDZ
const appliedMap = new Map()
const conn = await mysql.createConnection({ uri: process.env.DB_URL })
try {
await cmdRun()
} finally {
await conn.end()
}
async function listApplied() {
const [rows] = await conn.query('SELECT filename FROM schema_migrations')
appliedMap.clear() // ✅ 现在安全
rows.forEach(r => appliedMap.set(r.filename, r))
return new Set(rows.map(r => r.filename))
}在 ESM 文件里,把需要跨函数共享的模块级状态(Map、Set、配置对象等)统一放到文件顶部、所有顶层 await 之前的同步区。这样无论模块在哪个 await 挂起,这些变量都已完成初始化。
加一行注释防止未来重构时被无意间移回去:
// 必须在第一个顶层 await 之前声明 · 否则异步函数访问时仍在 TDZ
const sharedState = new Map()排查速查
遇到 ReferenceError: Cannot access 'X' before initialization,且堆栈指向一个本文件内的 async function,先跑两条 grep:
# 找所有顶层 await 的行号
grep -nE '^(const \w+ = await|await )' file.mjs
# 找 X 的声明行号
grep -n 'const X\b' file.mjs若 X 的声明行号 大于 任意一个顶层 await 的行号,就是这个坑。
一句话外卖
ESM 顶层
await让模块评估变成异步流,const/let的 TDZ 窗口因此被拉长——所有跨函数共享的模块级状态必须声明在第一个顶层await之前。