返回博客

ESM 顶层 await 之后的 const 声明会触发 TDZ 崩溃

写了一个数据库迁移脚本,本地跑了十几次都没问题,部署到容器里第一次走完整路径就炸了:ReferenceError: Cannot access 'appliedMap' before initialization。堆栈指向的那行代码看起来毫无问题——appliedMap 就在同一个文件里声明着,类型检查也是绿的。

问题根在 ESM 顶层 awaitconst 的 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 模块本质上是一个大的异步函数体。其评估顺序是:

  1. 先提升:所有 function 声明(cmdRunlistApplied)被提升到顶部,名字在作用域里注册——但 const/let 声明的名字同样注册,只是绑定未初始化,处于 TDZ。
  2. 逐行执行:遇到顶层 await,模块评估在此挂起
  3. await 恢复后继续:恢复执行,调用 cmdRun()listApplied()
  4. 此时 const appliedMap = new Map() 那行根本还没到:它在第 213 行,模块评估还没走到那里。appliedMap 仍在 TDZ → ReferenceError
坑:顶层 await 让模块评估变成「可被中途打断的异步流」

没有顶层 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))
}
正解:所有模块级 const/let 移到第一个顶层 await 之前

在 ESM 文件里,把需要跨函数共享的模块级状态(MapSet、配置对象等)统一放到文件顶部、所有顶层 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 之前。