TypeScript 实验性分支提议条件式 async?/await? 语法
Conditional "Async?/Await?" For TypeScript
一份 TypeScript 实验性分支的 RFC 提出条件式 `async?`/`await?` 语法,用于缓冲写入这类通常同步完成、偶尔需异步 flush 的场景。
征求意见:条件式 async 与 await
TL;DR / 摘要
这是针对一个实验性 TypeScript 分支的征求意见。缓冲写入通常立即完成,偶尔需要异步刷新。重复的 const temp = ...; if (temp) await temp 检查既痛苦又丑陋;await? 直接表达这种顺序。async? 通过调用层保持同步完成,仅在需要时返回 Promise。
最新测量——分层缓冲写入器:100,000 条记录,300,000 次写入,4.8 MB;在三个全新 Node 进程中 27 个样本的池化中位数。每个条目都使用相同的最新批次。时间越低越好。
| 实现 | 内存,无刷新 | 微任务刷新 | 文件写入 |
|---|---|---|---|
普通 async + await |
27.52 ms | 26.19 ms | 33.60 ms |
普通 async + 手动检查 |
9.54 ms | 9.33 ms | 15.23 ms |
普通 async + await? |
23.48 ms | 21.38 ms | 29.62 ms |
async? + await? |
6.04 ms | 5.39 ms | 11.25 ms |
| 手动同步延续 | 5.94 ms | 4.01 ms | 10.38 ms |
在这一批中,条件函数在内存中的耗时比普通 async/await 大约少 4.6 倍,并且与手动延续中位数相差约 2% 以内。手动延续在刷新后通过 pending.then(resume) 显式恢复;它们假定 void | Promise<void>,而编译器支持任意 thenable。对于扁平内存写入器,async? + await? 耗时 2.74 ms,而普通 async/await 为 20.16 ms。
受支持的短序列和 for 循环仅在挂起时创建延续回调;较大的序列使用共享回调,复杂函数体保留生成器回退。这是一个可工作的原型,不是 ECMAScript 标准或上游 TypeScript 功能。
在 Node v24.19.0 上测量,目标 ES2022,日期为 2026-10-09。这些合成结果在共享环境中会有所变化;文件写入省略 fsync。它们不能预测原生引擎性能,也不能确立统计等价性。有关两种布局的完整最新结果与权衡、早期比较、原始样本和局限性,请参阅完整最新结果与权衡。
问题
缓冲代码通常执行许多在内存中立即完成的小操作。只有偶尔缓冲区会填满并需要异步刷新。因此,一个有用的 API 会返回 void | Promise<void>:当写入适合时没有 Promise,当调用者必须等待刷新后才能继续时返回 Promise。类似模式也出现在缓存值和可组合流处理中。
如今,在每个调用点保留同步路径意味着反复引入临时变量和条件 await。例如,在普通 async 函数内部,写入一条记录的各个部分会变成:
const headerResult = buffer.write(header); if (headerResult) await headerResult; const payloadResult = buffer.write(payload); if (payloadResult) await payloadResult; const trailerResult = buffer.write(trailer); if (trailerResult) await trailerResult;
此检查适用于特定的 void | Promise<void> 契约;通用的值或 thenable API 需要正确的 thenable 检查。临时变量避免了对每个操作求值两次,但在整个管道中重复 const temp = ...; if (temp) await temp 既痛苦又丑陋。它把实际工作的顺序埋在簿记之下,并使每个新操作都成为忘记完成检查的又一个机会。
编写 await buffer.write(...) 很简洁,但即使结果是 undefined 也会引入挂起。手动编写条件 await 避免了这些挂起,然而普通的 async 外层函数仍然总是返回 Promise。通过多层保留同步完成需要进一步分支、显式延续或生成器运行器。
提议的写法使预期的顺序变得可见:
await? buffer.write(header); await? buffer.write(payload); await? buffer.write(trailer);
在一个 async? 函数内部,这些操作可以同步完成,一直返回到其调用者,或者在需要实际挂起时返回一个 Promise。其目的是只表达一次刻意混合的完成契约,而不必重复同步和异步算法,也不必在每一层散布临时变量检查。这会改变可观察的时序;它并不是普通 async/await 的透明替代品。
缓冲写入测量:收益与原型成本
我们测量了激发这一模式的场景,而不是假设更漂亮的语法必然产生更快的代码。跳过无条件的 await 有帮助,但微小的分层 async? 函数会暴露出可观的生成器开销。下面的原始结果促成了一项降级优化;这些表格之后的后续测量和解释描述了更新后的输出。
最新结果:延迟回调创建将分层内存 async? 从 9.00 ms 降低到 6.04 ms。在同一新批次中,普通 async/await 耗时 27.52 ms,手动续体耗时 5.94 ms。生成代码和手动内存中位数现在相差约 2% 以内;这并不是统计等价性声明。下面完整的最新表格和更早批次展示了收益、剩余成本和变化。
“手动同步续体”是手写的 JavaScript 控制流:当结果是 undefined 时,运行普通调用和循环;当写入返回 Promise 时,返回 pending.then(resume) 以在刷新后继续。它不创建生成器,也不会因同步写入而挂起。分层版本具有显式的 payload/trailer 回调。此基准实现假定其精确的 void | Promise<void> 契约,而编译器必须处理任意 thenable、getter 副作用和错误时序。
每次试验写入 100,000 条记录 / 300,000 次小写入 / 4.8 MB。周期性场景刷新一个 64 KiB 缓冲区(73 次完整刷新加一次最终部分刷新)。微任务接收器隔离调度开销;文件接收器实际写入并验证完整输出,不使用 fsync。内存中场景将整个输入放入其缓冲区,不进行刷新。
扁平写入器:一个函数循环处理所有写入。时间越低越好;条目是 27 个样本在三个全新 Node 进程中的汇总中位数。
| 实现 | 内存中,不刷新 | 微任务刷新 | 文件写入 |
|---|---|---|---|
| 普通 async + await | 20.35 ms | 19.08 ms | 27.33 ms |
| 普通 async + 手动检查 | 2.35 ms | 3.51 ms | 10.56 ms |
| 普通 async + await? | 4.06 ms | 5.07 ms | 11.94 ms |
| async? + await? | 14.65 ms | 15.46 ms | 22.44 ms |
| 手动同步续体 | 2.64 ms | 2.46 ms | 9.75 ms |
分层写入器:外层循环为每条记录调用单独的 header/payload/trailer 函数。每个变体在需要时等待完成;字节序列和总工作量与扁平情况相同。
| 实现 | 内存中,不刷新 | 微任务刷新 | 文件写入 |
|---|---|---|---|
| 普通 async + await | 35.81 ms | 35.90 ms | 48.00 ms |
| 普通 async + 手动检查 | 10.64 ms | 10.77 ms | 19.31 ms |
| 普通 async + await? | 27.72 ms | 30.75 ms | 40.96 ms |
| async? + await? | 155.06 ms | 150.89 ms | 179.01 ms |
| 手动同步续体 | 7.14 ms | 6.40 ms | 15.77 ms |
在平坦内存场景中,普通 async 加 await? 耗时 4.06 ms,而无条件 await 为 20.35 ms,耗时约减少 5.0 倍。现有手动检查更快,为 2.35 ms。async? 加 await? 耗时 14.65 ms,提供了同步返回语义,但成本高于手动续体代码(2.64 ms)。
分层内存场景是当前最明显的局限:async? 加 await? 耗时 155.06 ms,约为普通 async/await(35.81 ms)耗时的 4.3 倍。在两种分层刷新场景中它也较慢。这与微小函数存在大量 generator/runner/probe 开销一致;这些测量并未分离各组件的成本。即使使用 await?,普通 async 记录函数仍返回 Promise,因此外层的条件 await 无法使该路径同步。
这些原始结果支持避免不必要挂起和重复检查的动机,同时指出高效的条件函数降级是一个未解决的实现问题。手动续体变体表明,同步契约在现有 JavaScript 下是可行的;所提议的语法使该控制流更易于编写,但原始降级未能达到其性能。在无刷新试验中,只有 async? 和手动续体变体同步返回 undefined;当需要刷新时,所有策略都返回 Promise。因此这些变体共享有序输出,但调度/API 契约并不相同。
测量于 2026-10-09,Node v24.19.0,V8 13.6.233.17-node.51,Linux x64,Intel Xeon Platinum 8573C,暴露九个逻辑 CPU,使用 overlay 文件系统。输出目标:ES2022 CommonJS,所有变体由同一 fork 编译。每个进程对每种策略/场景使用三轮预热和九轮测量,并轮换顺序。编译、缓冲区设置、文件打开/关闭和输出验证在计时器之外;最终刷新、写入器分配、GC 和完成等待在计时器之内。
此共享环境存在显著的样本波动。例如,分层 async? 微任务试验的范围为 131.01 至 300.63 ms;我们保留了所有样本。文件结果包含操作系统缓存,并不衡量持久物理磁盘吞吐量。这些是合成的缓冲写入工作负载,不是浏览器基准测试、完整应用工作负载,也不是对原生引擎性能的预测。我们请求独立复现和针对特定工作负载的测量,尤其是针对替代降级方案。
参见 基准测试源代码、方法和复现命令、所有原始运行、运行 2、运行 3 以及 汇总中位数和范围。
为什么分层成本更高,以及我们改进了什么
分层写入器写入相同的字节,但会调用一个三写记录函数 100,000 次,而不是在一个函数内完成所有写入。在原来的条件输出中,每次记录调用都会构造一个生成器函数、一个生成器对象和一个运行器闭包。每个普通的 await? 仍然会创建一个探测数组和一个带标记的 yield 数据包,yield 一个生成器结果,并回调到 generator.next。三个内部 await 加上一个外部 await 意味着即使每次写入都返回 undefined,也会有 400,000 次这样的 yield/resume 循环。每个完成的记录还要经过返回值探测。
这是编译器生成的工作,而不是额外的缓冲输出或更多的 flush。扁平变体将生成器设置摊销到整个循环中;分层变体则按每条记录支付该设置成本。这解释了性能退化的结构性来源。我们尚未通过分配/CPU 分析将具体的毫秒数归因于分配、生成器调用、JIT 决策或垃圾回收。
更新后的 lowering 做了三处更改:
- 在 yield 之前探测。普通的条件 await 在生成器内部继续执行,没有 yield/resume 循环;探测返回
null而不分配数组。Thenable 保留接收者保留包装器和 Promise 同化。 - 对于独立的
await?,丢弃结果而不保留单独的值临时变量。消耗值的 await 仍然保留其普通结果。 - 完成时,对于
undefined立即返回;不需要完成探测或包装器。其他返回值仍然经过 thenable 同化。
因此运行器不需要带标记的数据包或同步恢复循环。普通的 await 仍然无条件 yield。错误时机、单操作数和 getter 求值、拒绝处理以及有序缓冲区复用都得以保留。
Void 处理在应用于被丢弃的值和实际的 undefined 完成时有效。仅靠静态 void 注解是不够的。 TypeScript 可以在期望 () => void 的地方接受一个返回值回调。因此,返回该回调结果的函数即使在表达式具有静态类型 void 时,也可能返回运行时的 thenable。跳过其同化会改变完成和拒绝行为。同样,忽略一个被 await 的值也绝不意味着可以跳过等待本身。这些优化依赖于语法和运行时值,而不会让类型注解改变 JavaScript 行为。测试覆盖了被丢弃的 fulfillment/rejection 以及返回普通值、已解析 Promise 或已拒绝 Promise 的上下文 void 回调。
我们在三个新进程中重新运行了未更改的工作负载,每个案例汇总 27 个样本。原始原始结果单独保留。下表比较了更改前后 async? + await? 的中位数;越低越好。加速比比较的是单独的测量批次,而不是配对试验。
| 布局 / 接收器 | 原始 | 更新后 | 原始 / 更新后 |
|---|---|---|---|
| flat / memory | 14.65 ms | 3.81 ms | 3.84× |
| flat / microtask | 15.46 ms | 4.68 ms | 3.30× |
| flat / file | 22.44 ms | 12.63 ms | 1.78× |
| layered / memory | 155.06 ms | 124.95 ms | 1.24× |
| layered / microtask | 150.89 ms | 111.21 ms | 1.36× |
| layered / file | 179.01 ms | 122.48 ms | 1.46× |
作为对比,这些是更新批次中的所有策略:
扁平写入器
| 实现 | 内存中 | 微任务 flush | 文件写入 |
|---|---|---|---|
| 普通 async + await | 22.71 ms | 22.74 ms | 25.93 ms |
| 普通 async + 手动检查 | 2.81 ms | 3.84 ms | 9.78 ms |
| 普通 async + await? | 3.06 ms | 4.63 ms | 11.07 ms |
| async? + await? | 3.81 ms | 4.68 ms | 12.63 ms |
| 手动同步续延 | 3.99 ms | 3.03 ms | 8.69 ms |
分层写入器
| 实现 | 内存中 | 微任务刷新 | 文件写入 |
|---|---|---|---|
| 普通 async + await | 43.83 ms | 42.79 ms | 45.87 ms |
| 普通 async + 手动检查 | 15.13 ms | 10.95 ms | 18.87 ms |
| 普通 async + await? | 39.45 ms | 27.08 ms | 38.84 ms |
| async? + await? | 124.95 ms | 111.21 ms | 122.48 ms |
| 手动同步续体 | 6.04 ms | 6.14 ms | 16.09 ms |
扁平场景大幅改善;分层场景有所改善,但仍逊于普通 async/await 和手动续体。生成器函数/对象和运行器设置仍按每条记录发生。这些结果促成了接下来描述的直接续体降级。
通用续体/状态机降级仍必须保留局部变量、词法绑定、try/finally、循环以及同步错误行为;复杂函数体保留生成器回退方案。共享环境仍然嘈杂:更新后的分层微任务样本跨度 89.76–322.64 ms。其他策略的中位数也在批次之间变动,因此这些数字支持的是实现改进,而非精确的通用加速。所有原始样本和范围见 tools/benchmarks/conditional-async/ 下的 optimized-run-{1,2,3}.json 和 optimized-summary.json。
首次直接续体生成:测量结果
我们现在为基准测试的三次写入记录函数生成普通续体,并为其外层/扁平循环生成普通恢复循环。同步路径不再构造生成器函数、生成器对象或运行器。它仍使用保留接收者的 thenable 探测;只有实际挂起时才调用 Promise 续体辅助函数。这与手动实现是相同的宽泛执行模式,只是采用了提案更宽泛的 thenable 语义。
未改变的工作负载在三个全新进程中重新运行,每个用例汇集 27 个样本。以下是首次直接生成批次中的所有策略:
扁平写入器
| 实现 | 内存中 | 微任务刷新 | 文件写入 |
|---|---|---|---|
| 普通 async + await | 23.83 ms | 20.42 ms | 25.82 ms |
| 普通 async + 手动检查 | 2.55 ms | 3.70 ms | 9.17 ms |
| 普通 async + await? | 3.08 ms | 4.38 ms | 10.89 ms |
| async? + await? | 3.20 ms | 4.82 ms | 10.18 ms |
| 手动同步续体 | 3.45 ms | 2.66 ms | 8.20 ms |
分层写入器
| 实现 | 内存中 | 微任务刷新 | 文件写入 |
|---|---|---|---|
| 普通 async + await | 30.01 ms | 27.12 ms | 34.28 ms |
| 普通 async + 手动检查 | 10.48 ms | 9.31 ms | 14.23 ms |
| 普通 async + await? | 25.97 ms | 21.65 ms | 30.45 ms |
| async? + await? | 9.00 ms | 6.86 ms | 12.61 ms |
| 手动同步续体 | 6.44 ms | 4.71 ms | 9.17 ms |
分层 async? 在直接生成之前/之后:
| 接收端 | 优化后的生成器 | 直接续体 | 生成器 / 直接 |
|---|---|---|---|
| 内存 | 124.95 ms | 9.00 ms | 13.89× |
| 微任务 | 111.21 ms | 6.86 ms | 16.21× |
| 文件 | 122.48 ms | 12.61 ms | 9.71× |
与手动续体剩余的差异包括通用 thenable 探测、挂起时包装的 Promise 规范化、被丢弃的最终完成处理,以及生成的闭包/控制流形态的差异。我们尚未单独分析这些成本,也不保证能匹配手写代码的确切速度。扁平写入器已经在摊销生成器设置成本,因此移除它在那里带来的改进要小得多。
这些前后比率比较的是不同批次。其他策略在批次之间发生了变动,因此同批次比较是相对性能更可靠的指引。我们保留了每个样本,包括离群值:直接分层文件试验的范围为 10.24 至 95.76 ms。环境和方法未变;这些是合成的 Node 工作负载,并非原生引擎预测。所有原始计时和范围见 continuation-run-{1,2,3}.json 和 continuation-summary.json 以及之前的结果。
延迟回调创建:最新测量结果
对于最多四个被丢弃 await 的短序列,同步工作现在内联执行。每个 thenable 分支为剩余工作创建一个箭头函数;普通分支继续内联执行。这会在各分支间重复后缀代码,因此该优化仅限于四个 await。更长的直接序列保留共享回调以限制代码大小。受支持的循环也以内联方式启动:第一次挂起创建一个恢复回调,该回调在后续刷新中复用。因此,作为动机的三次写入记录及其外层循环在完全同步调用中不创建任何续延闭包。这并不声称零引擎分配:激活/上下文存储仍可能消耗内存。
相同的工作负载和设置在三个全新进程中重新运行,每个用例 27 个池化样本,没有丢弃任何观测值。最新批次中的所有策略:
扁平写入器
| 实现 | 内存中 | 微任务刷新 | 文件写入 |
|---|---|---|---|
| 普通 async + await | 20.16 ms | 19.41 ms | 25.09 ms |
| 普通 async + 手动检查 | 2.44 ms | 3.82 ms | 9.54 ms |
| 普通 async + await? | 2.47 ms | 4.27 ms | 10.97 ms |
| async? + await? | 2.74 ms | 4.58 ms | 10.67 ms |
| 手动同步续延 | 3.70 ms | 2.53 ms | 8.40 ms |
分层写入器
| 实现 | 内存中 | 微任务刷新 | 文件写入 |
|---|---|---|---|
| 普通 async + await | 27.52 ms | 26.19 ms | 33.60 ms |
| 普通 async + 手动检查 | 9.54 ms | 9.33 ms | 15.23 ms |
| 普通 async + await? | 23.48 ms | 21.38 ms | 29.62 ms |
| async? + await? | 6.04 ms | 5.39 ms | 11.25 ms |
| 手动同步续延 | 5.94 ms | 4.01 ms | 10.38 ms |
async? 延迟回调创建之前/之后:
| 布局 / 接收器 | 首次直接输出 | 延迟回调 | 耗时变化 |
|---|---|---|---|
| 扁平 / 内存 | 3.20 ms | 2.74 ms | -14.5% |
| 扁平 / 微任务 | 4.82 ms | 4.58 ms | -5.1% |
| 扁平 / 文件 | 10.18 ms | 10.67 ms | +4.8% |
| 分层 / 内存 | 9.00 ms | 6.04 ms | -32.8% |
| 分层 / 微任务 | 6.86 ms | 5.39 ms | -21.4% |
| 分层 / 文件 | 12.61 ms | 11.25 ms | -10.8% |
减少幅度最大的是分层内存用例,此前每条记录都会创建共享续延闭包。同批次的手动结果现在在内存方面接近,而在周期性刷新时仍然更快。扁平文件的中位数略有增加;这一改动并非在所有测量用例中都胜出。之前/之后的百分比比较的是不同批次,不应被解读为精确的因果估计。其他策略在不同批次之间也有变化,更新后的分层文件样本跨度为 9.44–21.50 ms。仍需要独立复现。所有原始数据和范围保留在 tools/benchmarks/conditional-async/ 下的 lazy-run-{1,2,3}.json 和 lazy-summary.json 中。
语义保持不变:操作数和 getter 求值、接收者保留、嵌套 thenable 同化、被丢弃的 fulfillment、rejection、词法 this 以及 fulfillment 后的循环递增都保持其行为。测试覆盖了每条记录写入时的挂起、全 thenable 序列、循环恢复、普通路径上不分配 Promise,以及五 await 序列保留的共享续延路径。
我们征求的反馈
本文档是一份征求意见稿,并附有实验性编译器实现作为支撑,以便可以检查和测试语义及生成的代码。该设计开放修改。我们希望就以下方面获得反馈:
- 缓冲写入、缓存及类似工作负载是否足以证明该语法优于普通 async 函数、手动分支或库辅助函数。
async?和await?是否清晰地传达了条件完成,以及不同的拼写或契约是否更易于推理。- 指定的时序、重入、错误和 thenable 行为是否可接受,以及哪些具体示例揭示了不安全或令人意外的行为。
- 类型、声明、生成的代码和工具约束是否使其在实践中可用,以及我们遗漏了哪些实现案例。
- 在真实工作负载上的测量,包括与手写分支、普通 async/await 以及替代库方法的比较。
下面的历史反对意见部分记录了我们采取的缓解措施以及仍然存在的权衡。我们请审阅者评估这些权衡,而不是假设某个实现能解决语言设计上的反对意见。
提议的行为
这个实验性编译器分支添加了相邻的 async? 和 await? 后缀。它基于 microsoft/TypeScript main 的 6ad8c56f9b5a9bb910046c56059296311adc24ba。
async? function write(events: Event[]): void | Promise<void> { for (const event of events) { await? buffer.write(event) } } const result = write(events) // result is undefined when the whole operation completes synchronously, // or a Promise<void> once a thenable requires suspension.
await? expression 对操作数求值一次。它检查对象和函数上的可调用 then;普通值会立即继续,不经过微任务边界。thenable 会挂起执行,其兑现或拒绝会恢复外围函数。对于该 await 操作,then getter 只读取一次,thenable 方法接收其原始接收者。
async? 支持函数声明、表达式、箭头函数以及类和对象方法。函数体同步开始,并通过普通条件 await 同步继续。在第一次实际挂起时,调用返回一个原生 promise。后续的普通条件 await 也会继续,而不会增加挂起。常规 await 总是挂起,包括当其操作数是普通值时。
挂起前的异常会同步抛出。挂起后的异常会拒绝返回的 promise。try、catch 和 finally 保留其正常控制流。返回的 thenable 会被同化为原生 promise,即使没有到达任何 await。函数的参数默认值作为启动函数体的一部分同步执行。
检查器保守地推断出 Awaited<T> | Promise<Awaited<T>>(或 void | Promise<void>)。它不会尝试证明某条特定路径永不挂起。显式返回注解必须同时接受同步和异步完成。声明生成会移除该修饰符,并将该联合类型暴露给使用者;生成声明的使用者不需要这个分支。
await? 也可在普通 async 函数和 async 生成器内部使用,以及在具有与普通 await 相同 target/module 限制的模块顶层使用。普通 async 函数继续返回 promise。async? 生成器被拒绝,因为此扩展未定义条件迭代器 API;请对它们使用普通 async function*。
发射器为符合条件的被丢弃 await 序列和单 await for 循环选择直接续体;其他函数体使用生成器和一个小型运行器,利用 TypeScript 现有的词法绑定和控制流保留。普通条件 await 不分配探测/yield 数据包或 Promise,也不引入微任务。回退路径仍会分配生成器;直接路径则使用普通续体。短序列和受支持的循环仅在需要实际挂起后才创建回调;较大的序列仍会预先分配共享回调。性能取决于函数体形态和工作负载;上述测量区分了这些路径。
即使使用 --importHelpers,新的辅助函数也会内联生成,因为已发布的 tslib 包不提供它们。--noEmitHelpers 会抑制它们,并要求应用程序提供等效的辅助函数。源 AST 打印会保留后缀;生成的 JavaScript 仅包含标准 JavaScript。
ES2022 代码生成
这些示例使用此分支并通过 --target es2022 编译。该声明提供了单独实现的缓冲区写入器的类型;它不生成 JavaScript。下面的格式与编译器输出一致。
declare function writeChunk(chunk: string): void | Promise<void>; async? function writeAll(chunks: string[]): void | Promise<void> { for (const chunk of chunks) { await? writeChunk(chunk); } } async function ordinaryAsync(chunk: string): Promise<void> { await? writeChunk(chunk); }
生成的函数(省略了开头的 "use strict"):
function writeAll(chunks) { return __conditionalAwaiter(this, void 0, void 0, function* () { var _a; for (const chunk of chunks) { ((_a = __conditionalAwait(writeChunk(chunk))) ? yield _a : void 0); } }); } async function ordinaryAsync(chunk) { var _a; ((_a = __conditionalAwait(writeChunk(chunk))) ? await _a : void 0); }
编译器在需要时每个输出文件生成一次这些辅助函数:
var __conditionalAwait = (this && this.__conditionalAwait) || function (value) { var then = value !== null && (typeof value === "object" || typeof value === "function") ? value.then : void 0; return typeof then === "function" ? { then: function (resolve, reject) { Reflect.apply(then, value, [resolve, reject]); } } : null; }; var __conditionalAwaiter = (this && this.__conditionalAwaiter) || function (thisArg, _arguments, P, generator) { function step(result) { if (!result.done) { return Promise.resolve(result.value).then( function (value) { return step(generator.next(value)); }, function (error) { return step(generator["throw"](error)); } ); } // A bare return/fallthrough needs neither probing nor allocation. if (result.value === void 0) return; var completion = __conditionalAwait(result.value); return completion ? Promise.resolve(completion) : result.value; } return step((generator = generator.apply(thisArg, _arguments || [])).next()); };
__conditionalAwait 返回一个 thenable 包装器或 null,对于普通值不进行探测分配。条件 await 在让出之前会测试这一点;普通值在生成器内联运行,不涉及运行器。每个实际的 yield 都通过 Promise.resolve 挂起,包括普通的 await。独立的 await 丢弃其结果,只需要一个 promise 临时变量;消费值的 await 还会为普通分支保留原始值。返回值会检查是否为 thenable,并带有立即的 undefined 快速路径。生成的 ordinaryAsync 在此目标下保持原生 async,并且始终返回 Promise,即使其条件 await 没有挂起。
此输出是标准 ES2022 JavaScript。较旧的受支持目标在必要时使用现有的 async 转换;此示例不承诺支持 ES5。P 辅助函数参数目前未使用。
直接续体输出
对于包含 1–32 个独立条件 await 语句的块,或包含一个 for 循环(具有单个标识符 let 初始化器,并且其主体是单个独立条件 await)的块,会从语法上选择直接路径。参数必须是简单标识符,没有默认值或剩余参数。相关表达式中的嵌套 await/函数/类、直接 eval、arguments、super 和 new.target 会保留生成器回退,消费值的 await、显式 return、分支、try/finally 以及其他循环形状也是如此。一到四个 await 的序列会内联同步工作,仅在挂起分支上创建回调。它们的后缀在分支之间重复,因此此优化最多限制为四个 await,以限制代码增长。五到 32 个 await 的序列保留共享续体;32 条语句的限制限制了同步调用深度。受支持的循环最初内联运行,然后在第一次挂起时创建一个可复用的回调。没有类型注解会改变适用的运行时语义。
例如,此源代码:
declare function writeChunk(chunk: string): void | Promise<void>; async? function writeRecord(chunks: string[]): void | Promise<void> { await? writeChunk(chunks[0]); await? writeChunk(chunks[1]); await? writeChunk(chunks[2]); } async? function writeMany(chunks: string[]): void | Promise<void> { for (let i = 0; i < chunks.length; i++) await? writeChunk(chunks[i]); }
生成以下 ES2022 输出(省略开头的 "use strict"):
var __conditionalAwait = (this && this.__conditionalAwait) || function (value) { var then = value !== null && (typeof value === "object" || typeof value === "function") ? value.then : void 0; return typeof then === "function" ? { then: function (resolve, reject) { Reflect.apply(then, value, [resolve, reject]); } } : null; }; var __conditionalContinue = (this && this.__conditionalContinue) || function (pending, resume) { return Promise.resolve(pending).then(resume); }; function writeRecord(chunks) { const _pending_1 = __conditionalAwait(writeChunk(chunks[0])); if (_pending_1) return __conditionalContinue(_pending_1, () => { const _pending_4 = __conditionalAwait(writeChunk(chunks[1])); if (_pending_4) return __conditionalContinue(_pending_4, () => { const _pending_6 = __conditionalAwait(writeChunk(chunks[2])); if (_pending_6) return __conditionalContinue(_pending_6, () => { }); }); const _pending_5 = __conditionalAwait(writeChunk(chunks[2])); if (_pending_5) return __conditionalContinue(_pending_5, () => { }); }); const _pending_2 = __conditionalAwait(writeChunk(chunks[1])); if (_pending_2) return __conditionalContinue(_pending_2, () => { const _pending_7 = __conditionalAwait(writeChunk(chunks[2])); if (_pending_7) return __conditionalContinue(_pending_7, () => { }); }); const _pending_3 = __conditionalAwait(writeChunk(chunks[2])); if (_pending_3) return __conditionalContinue(_pending_3, () => { }); } function writeMany(chunks) { { let i = 0; while (i < chunks.length) { const _pending_8 = __conditionalAwait(writeChunk(chunks[i])); if (_pending_8) { const _resume_1 = () => { i++; while (i < chunks.length) { const _pending_9 = __conditionalAwait(writeChunk(chunks[i])); if (_pending_9) return __conditionalContinue(_pending_9, _resume_1); i++; } }; return __conditionalContinue(_pending_8, _resume_1); } i++; } } }
空的最终回调丢弃最后一个 thenable 的完成值,同时保留拒绝。每个续体仅在前一个写入完成后运行。当所有写入都是普通值时,短序列不会创建续体闭包;每个重复的后缀仅在一个选定的分支中执行。循环同样在挂起之前不创建恢复回调,然后为后续刷新复用该回调。它在完成之后递增,同步迭代使用 while 而不是递归调用。箭头回调保留词法 this;循环绑定保持外层块作用域。挂起之前的抛出会同步逃逸;恢复回调中的抛出会拒绝其 Promise。
在 --importHelpers 下,__conditionalContinue 辅助函数保持内联,并且像现有辅助函数一样防冲突。使用 --noEmitHelpers 时,当生成直接续体时,消费者必须提供它。其全局辅助函数作用域还避免捕获名为 Promise 的源参数。
测试覆盖每个挂起位置、被丢弃的值、拒绝、抛出
then/getter、接收者保留、延迟 then 调用、方法 this、
循环条件/递增时机、循环变量遮蔽、生成名称和
辅助函数冲突、名为 Promise 的源参数、100,000 次同步
迭代且不分配 Promise,以及捕获循环绑定时的回退。
历史异议与我们的回应
研究核查于 2026-10-09。以下来源包括 2019 年最初的 await? 讨论、委员会关于混合返回值的相关讨论,
以及较新的条件迭代讨论。这些是不同的提案。
2019 年的作者放弃了该讨论;这些来源并未确立
TC39 正式投票否决这一确切的 async? / await? 设计。可用的
编译器分支同样不能确立委员会的接受。
来源
- H1:条件 await,有人要吗?(2019 年 10 月):Gus Caplan、Tab Atkins Jr.、Dan Peddle、Isiah Meadows 和 Andrea Giammarchi 讨论了调度、用例和语法成本。
- H2:TC39 2020 年 4 月 2 日会议记录,Atomics.waitAsync 讨论:代表们讨论了可预测的 Promise 契约、带标签的容器和类型化。这是相关的 API 设计证据,而非
await?决定。 - H3:for await? .. of(2025–2026):Jordan Harband 提出了混合返回值的担忧;Isaac Schlueter 区分了回调时机与可检查的完成值。作者后来报告了一个并发迭代器请求缺陷。
- H4:TypeScript 设计目标:上游倾向于与 ECMAScript 对齐、保留 JavaScript 行为,并避免新的表达式语法和运行时功能。
历史关切总结如下。缓解措施描述此 分支的实现和文档;状态列有意 将已实现的保护与已接受或未解决的权衡区分开来。
| 严重异议 | 我们做了什么 | 状态 / 剩余限制 |
|---|---|---|
| 可观察的调度变化(H1):跳过 await 会改变交错,因此它不能是透明的优化。 | 添加了显式的相邻后缀;普通的 await 仍然挂起,普通的 async 仍然返回 Promise。运行时测试检查普通路径和 promise 路径以及强制普通 await。 |
已缓解,但未消除。await? 有意改变时机,包括在普通 async 函数内部。这是语义选择,不是优化提示。 |
| 重入和依赖缓存的顺序(H1、H3):条件 await 之后的工作可能在调用方继续之前或之后发生。 | 在声明中公开混合完成契约;下文记录顺序;通过原生 Promise 作业规范化实际 thenable,而不是内联调用其回调。 | 已接受的权衡。普通路径仍然内联运行。审查两条路径上的锁、回调、初始化和共享状态。在一致的进入时机很重要时,使用下面的延迟适配器。 |
| 混合返回契约和困难类型化(H2、H3):使用者可能假定为 Promise 或错过延迟完成。 | 保守地推断 Awaited<T> | Promise<Awaited<T>>,要求显式注解以允许两个分支,并生成普通的联合返回声明。测试覆盖泛型、嵌套 Promise 类型、void 和无效注解。 |
已缓解。调用方必须消费完成;对联合类型直接使用 .then / .catch 是无效的。类型不会强制等待或描述效果。 |
| 用例不明确 / 为现有模式增加额外语法(H1):手动分支或生成器运行器已经可以做到这一点。 | 目标缓冲写入:大多数事件可放入内存,偶发的 flush 返回一个 Promise。发射器集中了生成器/thenable 机制,使每个调用点都能一次性表达顺序。 | 未解决的语言设计判断。这减少的是样板代码,而非表达能力;对许多 API 而言,普通 async 或手动分支仍然合适。 |
| 含义令人困惑且性能证据不足(H1、H3):简写可能掩盖语义成本。 | 发布实际生成的代码、明确的时序/错误契约以及分配测试。普通 await 不再产生或分配探测包;测试处理 100,000 次普通 await。被丢弃的值和实际的 undefined 完成都有快速路径。 | 经过测量的权衡。上述缓冲写入结果表明,在扁平循环中条件等待优于无条件等待,优化后的降级减少了开销,但在所测量的分层工作负载中,直接续体现在优于普通 async/await;手动续体仍然胜出。通用控制流仍保留生成器设置,代码大小和 thenable 归一化成本依然存在。原生引擎性能尚未测量;采用前请对真实工作负载进行基准测试。 |
| 非标准运行时语法与 TypeScript 政策冲突(H4)。 | 保持其为实验性分支。生成标准 JavaScript 和可擦除的联合声明,不进行类型导向的运行时分支。在聚焦测试套件中,普通源码的 async 基线保持不变。 | 未解决的上游/工具链反对意见。这不是 ECMAScript 标准,也不是上游 TypeScript 特性。现成的解析器、格式化器、linter 和源码执行运行时都需要支持;仅靠声明无法解决源码兼容性。 |
| 条件迭代器需要并发语义(H3)。 | 拒绝 async? function*;保留普通 async 生成器和普通 for await。它们现有的 Promise/迭代契约不会被替换。 |
已排除范围。未实现 for await? 或条件迭代器协议。独立调用仍需要应用层并发控制。 |
结合历史关切一并检查的实现风险
以下内容是我们的工程审计,并非声称每一项都曾在历史讨论串中被提出。
| 风险 | 已实施的缓解措施与证据 | 剩余限制 |
|---|---|---|
| Thenable 检测是可观察的,且可能带有敌意。 | 对操作数求值一次;对对象/函数每次探测读取一次 then;要求其可调用;使用 Reflect.apply 保留其接收者。测试覆盖 getter、可调用 thenable、外部 realm 的 Promise、被覆盖的 .call、嵌套解析、重复结算、拒绝以及抛出异常的 then 方法。 |
Getter/proxy 副作用仍可被观察到。返回值的同化是单独的探测,因此在 await 一个普通对象后返回它可能会再次读取其 then。这不会检查 Promise 是否已经 fulfilled:所有 thenable 都会挂起。 |
| 异常在 throw 和 rejection 之间切换。 | 挂起前抛出;挂起后使用 Promise rejection。生成器控制流保留 try/catch/finally;测试覆盖 getter 失败、同步主体错误、被拒绝的输入以及清理。 |
裸 .catch 无法捕获同步调用失败。请使用 try { await operation() } 或下面的适配器。这有意区别于普通 async 仅拒绝的主体错误契约。 |
| 在 lowering 过程中丢失词法绑定或求值顺序。 | 复用编译器的异步转换基础设施。测试覆盖箭头函数、方法、this、arguments、super、计算属性访问、默认值/剩余参数、嵌套函数,以及跨五个目标的有序循环执行。 |
这是有针对性的回归覆盖,并非对每个程序正确性的证明。不承诺原生异步堆栈拼接或调试器呈现;source-map/调试器行为需要专门审计。 |
| 饥饿与无界同步工作。 | 使用迭代式 runner,使一长串普通 await 不会递归增长堆栈。作者可以显式插入普通的 await。 |
没有自动的公平性预算。await Promise.resolve() 会让出给微任务,但不保证定时器/I/O 运行;当需要时,请使用宿主任务调度原语。 |
| 辅助函数与内建函数的兼容性。 | 生成防冲突的名称,并且即使使用 --importHelpers 也内联发出辅助函数;测试覆盖辅助函数名称冲突以及不使用 tslib 的 CommonJS。 |
--noEmitHelpers 需要等价的辅助函数。该实现使用环境中的 Promise.resolve、.then 和 Reflect.apply;它不提供针对 monkey-patching 的内建加固,也不保证精确的原生 await 微任务计数。未添加取消机制。 |
语法歧义与误导性的 ? 语义。 |
要求相邻,并且仅在受支持的函数形式中识别条件 async。解析器/运行时测试将 async?1:2 保留为三元运算符,并覆盖箭头函数、表达式和方法。 |
await? 不是空值条件访问:null 和 undefined 是普通的同步值,被拒绝的 thenable 仍会抛出/拒绝。仍不支持第三方语法工具。 |
时序与安全 API 边界
async? function mark(value: number | PromiseLike<number>, log: string[]) { log.push("before"); await? value; log.push("after"); } const plainLog: string[] = []; const plain = mark(1, plainLog); plainLog.push("caller"); // plain is undefined; plainLog is ["before", "after", "caller"]. const promisedLog: string[] = []; const promised = mark(Promise.resolve(1), promisedLog); promisedLog.push("caller"); // Before completion: ["before", "caller"]. await promised; // After completion: ["before", "caller", "after"].
当公共 API 需要仅 Promise 的结果、统一延迟的函数体进入, 以及仅拒绝的错误时,在该边界处进行适配:
function writeAllDeferred(chunks: string[]): Promise<void> { return Promise.resolve().then(() => writeAll(chunks)); }
.then 回调会延迟调用 writeAll,并将其同步抛出捕获为拒绝。Promise.resolve(writeAll(chunks)) 会规范化其
返回值,但既不会延迟其函数体,也不会捕获同步抛出。
普通的 async 包装器也会规范化返回值/错误,但调用其函数体
仍会同步开始。使用者可以使用标准的 try/await/catch:
const chunks = ["first", "second"]; try { await writeAll(chunks); } catch (error) { // Handles both a synchronous call failure and a rejected completion. }
仅当两条完成路径都是 API 契约的一部分,并且测量结果证明其合理时,
才使用条件操作。以上内容均未断言
将每个普通 await 改为 await? 都能保持行为不变。
构建与测试
前置条件:Go 1.27 和 Node.js 24。
go build -o ./tsgo ./tsc/cmd/tsc ./tsgo --target es2022 example.ts TSGO_BINARY="$PWD/tsgo" node --test tools/tests/conditional-async/runtime.test.mjs go -C tsc test ./internal/parser ./internal/printer ./internal/checker go -C tsc test ./internal/testrunner -run 'TestLocal/(conditionalAsyncAwait|async.*|await.*|forAwait.*)'
运行时测试套件会编译并运行五个输出目标,并检查原生 顶层条件 await。编译器基线测试套件覆盖语法、 声明输出、推断、注解和错误。现有 async 基线保持不变。
来源:Hacker News · github.com