本节摘要:在 Promise 与 async 出现之前,异步靠回调层层传递,形成「倒金字塔」代码与支离破碎的错误处理;生成器函数一度被 co 这类库改造成「同步写法跑异步」的过渡方案。本节回顾这条演化路径,把回调风格、错误优先约定、Promise 化改造、生成器协程四个阶段的机制与教训讲清——既为读懂存量代码,也为理解 await 为何是这条路的终点。
模拟一段三步依赖的旧式异步(用 setTimeout 代替网络请求,逻辑同构):
function fetchUser(id, callback) { setTimeout(() => callback(null, { id, name: '小明' }), 100); } function fetchOrders(user, callback) { setTimeout(() => callback(null, [{ id: 1, owner: user.id }]), 100); } function fetchItems(order, callback) { setTimeout(() => callback(null, ['书', '笔']), 100); } fetchUser(1, (err, user) => { if (err) return handle(err); fetchOrders(user, (err, orders) => { if (err) return handle(err); fetchItems(orders[0], (err, items) => { if (err) return handle(err); console.log('最终结果', items); // ['书', '笔'] }); }); });
这段代码有旧时代的三个典型病灶。形状病:每层嵌套右移一格,读代码要横着数括号,即所谓回调地狱(倒金字塔)。错误病:每层手写 if (err),漏写一层错误就静默丢失;错误无法统一收集,因为没有「链」可以穿透。信任病:回调的调用时机、次数完全由被调用方决定——回调可能被调零次、两次(第 4.2 节说过 Promise 用一次性闸门根治了这一点),也可能同步调用(你以为异步它偏同步,触发竞态)。
「错误优先」约定(Error-first callback,Node 社区的硬规范:回调第一个参数留给错误,无错传 null)缓解了错误病的一部分,但形状病与信任病是回调模型的结构性缺陷,约定治不了。
公平地说,回调本身没有被淘汰——它只是退到了适合自己的位置。判断标准是「触发几次」:
一次性结果(请求完成、定时器到点):今天应该返回 Promise 或接受返回 Promise 的接口。一次性事件用回调表达,等于放弃了状态机、穿透、组合器全部工具。
多次性事件(点击、数据流、消息):回调(事件监听)仍是正确形态。Promise 表达不了「多次落定」,硬用就是拿状态机当流用,必然丢数据。
// 多次性:事件监听,回调就是正解 button.addEventListener('click', handler); // 一次性:Node 的 util.promisify 把错误优先回调包成 Promise const fs = require('fs'); const readFile = require('util').promisify(fs.readFile); readFile('配置路径', 'utf8') .then(text => console.log('长度', text.length)) .catch(e => console.log('失败', e.code));
手写 promisify(改造存量回调 API 时天天用)的内核:
function promisify(fn) { return function (...args) { return new Promise((resolve, reject) => { fn(...args, (err, value) => { if (err) reject(err); // 错误优先约定:第一位非空即失败 else resolve(value); }); }); }; }
改造逻辑就是第 2 章闭包(保存 fn 与参数)加第 4.2 节状态机(一次性闸门保证不双调)的组合应用。
生成器函数(function*)体内可以写 yield,执行到 yield 处暂停并把值递出去,外部用 next 恢复它。这是一个「可暂停函数」原语:
function* steps() { const a = yield '第一步的输入请求'; console.log('拿到', a); // 拿到 A const b = yield '第二步的输入请求'; console.log('拿到', b); // 拿到 B return '结束'; } const g = steps(); console.log(g.next()); // { value: '第一步的输入请求', done: false } console.log(g.next('A')); // 拿到 A → { value: '第二步的输入请求', done: false } console.log(g.next('B')); // 拿到 B → { value: '结束', done: true }
注意 next 的参数会变成对应 yield 表达式的返回值——这正是异步的钥匙:把「等网络结果」写成 yield,由外部驱动者在结果到达时用 next(result) 灌回去。于是 2013-2016 年间的 co 库把本节开头的倒金字塔改写成了这样:
const co = require('co'); // 概念示意:co 已逐渐退出历史 co(function* () { const user = yield fetchUserP(1); const orders = yield fetchOrdersP(user); const items = yield fetchItemsP(orders[0]); console.log('最终结果', items); // ['书', '笔'] });
外观已经和今天的 async-await 几乎一样。co 的驱动逻辑浓缩版(面试经典题「手写 co」):
function co(genFn) { return new Promise((resolve, reject) => { const g = genFn(); function step(val) { const { value, done } = g.next(val); if (done) return resolve(value); Promise.resolve(value).then(step, reject); // 等上一步结果,灌回下一步 } step(); }); }
一个十行的递归驱动器,把「同步的皮、异步的芯」焊在一起。读懂它,async-await 就再无秘密——引擎做的事与 co 同构,只是把驱动器内置、把 yield 换成了 await、把星号换成了 async。
生成器今天仍活跃在三个场合:惰性序列(按需产出,处理无限流);自定义迭代器(配合 for...of 与展开运算符);状态机建模(每次 next 前进一个状态)。异步这摊业务则整体移交给了 async。
从回调到 await 的接力次序与每棒解决的问题:
| 年代 | 方案 | 解决 | 遗留 |
|---|---|---|---|
| 1995-2009 | 裸回调 | 能用 | 倒金字塔、错误散落、信任问题 |
| 2009-2015 | 错误优先约定 + 事件库 | 错误有了格式 | 形状与信任仍是结构缺陷 |
| 2015 | Promise 入标准 | 状态机、穿透、组合器 | 链式仍切断作用域 |
| 2013-2016 | 生成器 + co | 同步写法 | 依赖库驱动,错误栈不友好 |
| 2017 | async-await 入标准 | 同步写法 + 原生驱动 + try-catch | 顶层与循环使用需谨慎 |
这条线的教训有普遍价值:抽象要沿「最少新概念」的方向进化——Promise 只加了一个状态机概念就消掉了信任病;await 甚至零新概念(就是 then 的语法映射)。反过来,中间出现过的「事件库统一回调」「库级协程」都因为要求使用者学习整套新约定而没能走到最后。
读存量代码的翻译表:见到嵌套回调,先画依赖图(谁等谁);见到 co 或 thunk,在心里替换成 async 函数;见到自定义 Promise 化包装,检查是否处理了「回调被同步调用」的情形(旧 API 同步回调会让某些 Promise 实现提前落定,标准 Promise 不会)。
⚠️ 常见坑:别在生成器与 async 之间来回翻译时丢掉错误分支——co 时代错误靠驱动器 reject 整体,翻译成 async 后要补上 try-catch,否则原本「炸到 co 外面」的错误会变成 unhandledrejection 静默漂移(第 7 章的兜底能接住,但排查路径变了)。
生成器在今天的正业是「按需产出序列」,分页拉取是典型:不把全部数据拉进内存,要一页算一页。
function* pageStream(fetchPage) { let cursor = null; do { const { items, next } = yield fetchPage(cursor); // 产出「取一页的请求」,等驱动者喂结果 for (const it of items) yield it; // 逐条产出,流式消费 cursor = next; } while (cursor); } // 驱动侧:模拟异步取页,把结果灌回生成器 async function drain(stream, handle) { let input = undefined; while (true) { const { value, done } = stream.next(input); if (done) break; const resolved = await Promise.resolve(value); // 页请求或数据条 handle(resolved); input = resolved; // 结果作为下一轮 next 的参数灌回 } } const fakePage = cursor => new Promise(res => setTimeout(() => res({ items: ['A', 'B'], next: cursor === 2 ? null : (cursor ?? 0) + 1 }), 10)); const got = []; drain(pageStream(fakePage), v => got.push(v)) .then(() => console.log('全部条目', got)); // 实测输出:全部条目 [页对象, 'A', 'B', 页对象, 'A', 'B', 页对象, 'A', 'B']
这个练习的价值在于「yield 的双职能」被同时用到:对外产出值(消费者拿到页或条目),对内接收输入(next 的参数变成本轮 fetchPage 的结果)——正是当年 co 赖以实现同步写法异步跑的那扇门。工程里更顺手的形态是异步迭代器(for await 配 Symbol.asyncIterator),它把这层驱动逻辑标准化了;但手写一遍驱动器,异步迭代的「魔法」也就褪成了普通机制。
生成器现在还有哪些非用不可的场景?
三个:无限或超长序列的惰性产出(数据流、素数序列、日志行);自定义可迭代对象(让 for...of 与展开运算符认识你的结构);协程式状态机(每次 next 前进一个状态,比 switch 嵌套清爽)。异步已归还 async,别再为异步用它——除非你在写解释器或调度器这类基础设施。
yield 是什么?*
委托语法:yield* someIterable 把产出动作转交给另一个可迭代对象,逐项搬出。树遍历、嵌套结构展平用它一行搞定(递归生成器里 yield* 自身调用是标准姿势)。它与展开运算符的区别在于惰性:前者逐个产出不建中间数组,后者立刻物化整个数组。
读老代码遇到 thunk 与 co,翻译时最容易丢什么?
两样:错误语义与执行时机。co 把生成器里的异常统一抛到最外层的 Promise,翻译成 async 后要补 try-catch(否则变 unhandledrejection);co 的 yield 数组(并发)与 yield 单值(串行)区分明确,翻译时别把并发的写成串行 await。翻译完用同一份输入对照两版输出与总耗时,是最低成本的验收。
生成器的 return 值去哪了?
生成器函数里的 return 不会成为任何一次 next 的常规产出:它出现在最后一次 next 的返回对象里,此时 done 已为 true,for...of 循环默认忽略它(循环只消费 done 为 false 的值),只有 yield 星号委托会把被委托者的 return 值作为整个 yield 星号表达式的值交还。想给「序列一个终值」的场景(比如流结束时带一个汇总),要么消费手工 next 的最后一个返回对象,要么把终值作为普通 yield 产出后再 return——语义更直白。
异步迭代器:生成器思想的现代形态
上一节的驱动器要手写,标准化的版本是异步迭代器——for await 循环配 Symbol.asyncIterator 协议,专门消费「按异步节奏逐个到达的序列」:
async function* ticker(times) { // 异步生成器:yield 交给 Promise 化的驱动 for (let i = 1; i <= times; i++) { await new Promise(r => setTimeout(r, 10)); yield i; // 每到一项就产出 } } (async () => { for await (const tick of ticker(3)) { console.log('收到', tick); // 收到 1 → 收到 2 → 收到 3,间隔十毫秒 } console.log('流结束'); })();
异步生成器把「生成器的暂停恢复」与「await 的挂起」焊在一个语法里,驱动逻辑完全交给引擎。它的最佳场景是「有界流式消费」:分页接口逐页拉取逐页处理(不必把全部页攒进内存)、日志尾巴持续读取、传感器数据逐条响应。与「一次性拉全再遍历」相比,流式版把内存峰值压到常数,代价是失去随机访问——和同步世界里「生成器对数组」的取舍一模一样。
对照着看三代异步消费形态:回调时代每项注册一次回调、事件时代监听一个流、async 迭代器时代用最像同步循环的写法消费异步序列。语法在变,本质始终是第 4.1 节那条调度规则的反复应用——只是离你的直觉越来越近。
生成器和 async 之间怎么选,有没有并存的工作区?
分工清晰:等待用 async(每次等待都是真实的外部节奏:网络、定时、用户);产出用生成器(数据按需计算、序列可能无限);两者交界处用异步生成器(数据既按需又异步到达,本节 ticker 的场景)。并存的工作区确实存在——「用生成器做同步管道预处理、末端交给 async 消费」的组合在流式数据处理里常见,但要在团队里注明分层,别让同一层混用两种暂停语义。
想读懂 co 这类老库的源码,路线是什么?
三步:先读懂驱动循环(本节手写版的十行就是全部骨架——next、检查 done、then 递归);再看错误分支(reject 如何穿透到外层 Promise);最后看它对 yield 值的分派(数组并发、单值串行的类型判断)。这三块占了 co 实现的绝大部分,剩下的都是边界处理。读完你会发现「await 的引擎实现」与它同构——这正是历史课的价值:看懂来路,去路就不再神秘。
回调时代的经验里,哪些今天仍然成立?
三条老经验值得继承:错误处理要「尽早、统一」——回调时代每层手写的 if (err),精神内核就是今天链尾统一 catch 的前身;不要信任调用方的重入——回调可能同步可能异步的「信任问题」,今天的解法是状态机与竞态守卫;释放资源要配对——定时器、监听器、句柄的登记注销纪律,在任何异步范式下都一样。范式会换代,这三条工程直觉不会。
事件循环一章到此收束:队列规则(4.1)、状态机(4.2)、语法糖(4.3)、演化史(本节)四块拼图合上,你已经能推导任何异步代码的执行轨迹,也能把任何旧式回调翻译成现代写法而不丢语义——promisify 的三行内核、co 的十行驱动器、异步迭代器的标准化形态,都是这条路上的路标。下一章把镜头从语言层推向浏览器——DOM、渲染与定时器在同一个调度框架下如何各就各位,前端的「现场感」由此展开。