为什么 async/await 杀不死 Generator?
最近在做代码 Review 时,被一段处理 Policy(政策)批处理的逻辑勾起了很多思考。
1. 缘起:一次“失败”的逻辑抽取
业务场景其实很常见:分页拉取数据、做 24h 业务过滤、统计成功/跳过/报错的数量,最后每 10 个一组聚合发送。
起初,大家习惯性地想用 async/await 来写。但当你试图把“分页 + 过滤”这部分通用的数据获取逻辑抽出来复用时,你会发现代码变得极其扭曲。最常见的尝试是写一个带有 Callback(回调) 的工具函数:
JavaScript
// 这种模式下,控制权在 walkPolicies 手里 await walkPolicies\(async \(data\) => \{ stats\.success\+\+; buffer\.push\(data\); if \(buffer\.length === 10\) await sendBatch\(buffer\.splice\(0, 10\)\); \}, \(\) => stats\.skip\+\+\);
很多人说 Generator 的好处是“关注点分离”,但其实回调函数也能做到。真正的痛点在于“非侵入性”。 在回调模式下,如果你想改动执行节奏(比如发完一组歇一会儿),或者想中途停止,你必须去修改工具函数的内部实现。
而 Generator 的价值在于:它允许你在不触碰执行函数内部逻辑的前提下,由外部决定如何、何时推进流程。
2. 控制权的反转:不改源码的解耦
换成 async generator 后,生产者和消费者的界限变得极其清晰。
生产者(只管业务流,不关心频率):
1 | async function* policyProvider() { |
消费者(掌控一切):
1 | for await (const result of policyProvider()) { |
这种 控制反转(IoC) 最大的价值是:生产者只负责产出状态,而外部可以根据状态控制生成者。这种“非侵入式”的调度,是 async/await 很难优雅实现的。
3. 底层硬核:为什么我们会转入原理?
当我们发现 async/await 无法优雅地处理这种逻辑抽取时,直觉会把我们带向底层:Generator 和普通的函数到底差在哪?
这涉及到 JS 引擎对内存的魔术。普通函数高度依赖物理调用栈(Stack),执行完即销毁。但 Generator 彻底打破了栈的物理限制:
暂停(Yield):引擎会把当前的执行上下文(变量、IP 指针、
this等)直接从物理栈里弹出来,**写回到堆(Heap)**里的对象中封存。恢复(Next):引擎从堆里把这个快照捡回来,重新压回栈顶接着跑。
而且,Normal Generator 的暂停和恢复是同步的。它不需要排队,它就是在同一个执行序列里完成了“现场切换”。
所以,闭包让“作用域”活了下来,而 Generator 让整个“执行过程”活了下来。
4. 写在最后:为什么它成了“少数派”的武器?
既然 Generator 这么强大,为什么现在很少见到了?
第一,它的“双向通信”反而是个负担。
Generator 允许通过 next\(val\) 往函数内部传值,这在理论上很美,但在业务代码中极其少见。这种复杂的交互让代码的可读性直线下降,如果只是为了拿数据,async/await 配合简单的循环显然更自然。
第二,它处于一种“不上不下”的尴尬位置。
在 90% 的简单场景下,我们只需要“等一个结果”,async/await 的心智负担最低。在极致复杂的响应式场景下,大家又去用 RxJS 了。
但这并不代表它过时了。当你发现 async/await 让你不得不通过修改函数内部来实现外部控制时,说明你需要的不再是一个简单的异步结果,而是一个**“状态保存在堆里的执行环境”**。
