核心是精准复现v8等引擎基于状态机的promise驱动跳转契约,必须满足暂停/恢复、错误传播、上下文保持、微任务调度五点行为等价,且状态机需绑定执行上下文、支持帧还原、queuemicrotask调度、try/catch/finally状态映射及状态机栈管理。

手写 async/await 编译器模拟器的核心,不是模拟语法糖,而是精准复现 JavaScript 引擎(如 V8)中基于状态机的 Promise 驱动跳转契约。关键不在“写得像”,而在“行为等价”——即暂停、恢复、错误传播、上下文保持、微任务调度这五点必须与规范对齐。
状态机结构必须绑定执行上下文与挂起点
每个 async 函数编译后应生成一个闭包包裹的状态机对象,含:当前状态索引(state)、局部变量快照(locals)、Promise resolve/reject 句柄、上一个 await 的 Promise 实例引用。不能只用 switch(state) 跳转,而要确保每次 yield(即 await 暂停)时,把 this、arguments、临时栈值一并封入一个可恢复的执行帧(execution frame),并在 resume 时完整还原。例如:
- await Promise.resolve(42) 暂停后,state 设为 1,locals 保存当前作用域所有变量(包括闭包捕获值)
- 微任务队列清空后调用 resume(),从 state=1 继续执行,locals 已就位,无变量丢失
- 若 await 后是 reject,状态机必须直接跳至 catch 块对应 state,并传入 rejection reason
await 表达式必须触发标准微任务调度链
不能直接 .then() 执行后续逻辑。正确做法是:将 await 后的 Promise 实例调用 .then(res => queueMicrotask(() => resumeWithResult(res)), err => queueMicrotask(() => resumeWithReject(err)))。queueMicrotask 是关键——它保证了与原生 await 一致的微任务时机(早于 nextTick,晚于同步代码,且在本轮事件循环末尾统一执行)。漏掉 queueMicrotask 或误用 setTimeout / Promise.resolve().then,都会破坏时序契约,导致竞态或顺序错乱。
try/catch/finally 必须映射为状态跳转边界而非嵌套回调
async 函数中的 try 块不是简单包装,而是划分状态区间。例如:
- try { await p1; await p2; } catch(e) { ... } → 编译后 state 0→1→2 对应两个 await,catch 对应 state -1
- 当 p1 reject,状态机不进入 state 1,而是立即跳至 state -1,并把 e 作为参数注入 resumeWithReject
- finally 块需拆分为“正常完成路径”和“异常完成路径”两套 state 分支,但共享 finally 内部逻辑,且 finally 返回值会覆盖原返回值(符合规范)
await 多重嵌套需支持状态机栈(非递归)
async 函数内部 await 另一个 async 函数时,不能靠函数调用栈维持上下文。必须显式维护一个 状态机栈(machineStack):外层状态机暂停时,压入自身帧;内层 async 启动新状态机并开始运行;内层 resolve 后,弹出栈顶帧并恢复外层状态。这样既避免调用栈爆炸,又保证 await 链中每个层级的 this、lexical env、await 点都独立可溯。V8 的 TurboFan 就采用类似机制,而非递归展开。
不复杂但容易忽略的是:状态索引必须全局唯一 per-execution(不是 per-function),且每个 await 表达式生成的 Promise 处理器必须持有对外层状态机 resume 方法的强引用,防止 GC 提前回收。做到这几点,你写的模拟器就不再是玩具,而是真正能跑通 TC39 test262 中 async-await 全部语义测试用例的底座。











