2.1 从回调到async/await:异步编程进化史


2.1 从回调到 async/await:异步编程进化史

本节摘要:Node 的异步编程经历了回调函数、Promise、async/await 三代形态。本节按演化顺序逐个拆解:回调为何会堆成地狱、Promise 的状态机如何把「完成通知」变成可组合的值、async/await 又如何把异步流程写回同步的模样。理解每一代解决了什么、留下了什么,比记住 API 更重要。

学习目标

  1. 能指出回调风格的三个结构性缺陷(嵌套、控制反转、错误处理割裂)
  2. 能手写一个 30 行的简化版 Promise
  3. 能把任意 async 函数还原成等价的 Promise 链
  4. 能在工程中合理混用三代风格并知道边界在哪

一、起点:回调时代

第 1 章的第一个 HTTP 服务器已经用了回调。对于单个异步操作,回调无可指责:

const fs = require('fs'); fs.readFile('配置.json', 'utf8', (err, data) => { if (err) { console.error('读取失败', err); return; } console.log('内容长度', data.length); });

问题出现在有依赖关系的多个异步步骤时。读配置 → 依据配置查用户 → 依据用户查订单,用回调写出来是这样:

fs.readFile('配置.json', 'utf8', (err, config) => { if (err) return handle(err); db.query('SELECT * FROM users WHERE id = ?', [JSON.parse(config).uid], (err, user) => { if (err) return handle(err); db.query('SELECT * FROM orders WHERE uid = ?', [user.id], (err, orders) => { if (err) return handle(err); if (!orders.length) { // 想再补查一次历史表?继续往右缩进…… } render(user, orders); }); }); });

三层嵌套还只是观感问题,真正要命的是三件事:

  • 控制反转:你把后续逻辑交给了 db.query,它调不调、调几次、用什么 this 调,你说了不算。著名的「回调被调用两次导致数据重复写入」事故就源于此。
  • 错误处理割裂:每层都要手写 if (err),漏写一层,错误就静默消失。
  • 无法组合:两个异步操作「并行取最快」「全完成再汇总」,回调风格都得靠额外库或手工计数器。

异步演化时间线

异步演化时间线

二、Promise:把结果变成值

Promise 的本质是把「将来才会有的值」包装成对象,让异步结果可以像变量一样被 return、被传递、被组合。它的规则集中在一个状态机里:pending → fulfilled 或 pending → rejected,一旦落定不可逆

一个足够用的简化实现不到 30 行:

class MiniPromise { constructor(executor) { this.state = 'pending'; this.value = undefined; this.callbacks = []; const resolve = (v) => { if (this.state !== 'pending') return; // 不可逆 this.state = 'fulfilled'; this.value = v; this.callbacks.forEach(cb => cb(v)); }; const reject = (e) => { if (this.state !== 'pending') return; this.state = 'rejected'; this.value = e; this.callbacks.forEach(cb => cb(e)); }; executor(resolve, reject); } then(onFulfilled) { if (this.state === 'fulfilled') { queueMicrotask(() => onFulfilled(this.value)); } else { this.callbacks.push(v => queueMicrotask(() => onFulfilled(v))); } return this; // 真正的 then 返回新 Promise,这里从简 } }

真实实现要处理 then 返回新 Promise、rejection 传播、thenable 兼容,但骨架就是这样:注册回调 → 状态落定时批量触发。注意 then 里的回调是排进微任务队列的——这个细节下一节会成为顺序推演的主角。

同样的三步查询,Promise 化之后:

const fsp = require('fs').promises; fsp.readFile('配置.json', 'utf8') .then(config => db.query('SELECT * FROM users WHERE id = ?', [JSON.parse(config).uid])) .then(user => db.query('SELECT * FROM orders WHERE uid = ?', [user.id])) .then(orders => render(orders)) .catch(err => handle(err)); // 三层的错误在这里一次收口

嵌套消失了,错误统一了,而且并行也顺手解决了:

// 两个查询并行,总耗时约等于较慢的那个 const [profile, orders] = await Promise.all([ getUserProfile(uid), getUserOrders(uid), ]);

三、async/await:把异步写回同步的模样

async 函数是生成器 + 自动执行器的语法糖。在引擎眼里,await 后面的代码相当于注册进了一个 then 回调——每次 await 都是一次函数暂停与微任务排队的交接。所以这段代码:

async function loadPage() { const config = await fsp.readFile('配置.json', 'utf8'); const user = await db.query(sql, [uid]); return { config, user }; }

与手写的 Promise 链完全等价。它换来的可读性是真的,但新的坑也是真的:

⚠️ 常见坑:在循环里顺序 await 相互独立的异步操作,是最常见的性能事故。十个各需 100ms 的请求被串成 1 秒。独立操作用 Promise.all 包起来,有依赖关系才逐个 await。

四、三代风格的工程边界

我的实践中遵循三条:对外接口层尽量 async/await,配合 try/catch 让错误路径显式;库的内部边界可以保留回调形态以避免不必要的包装(Node 核心模块至今双轨提供);永不裸写嵌套超过两层的回调——那说明该抽函数了。

维度 回调 Promise async/await
可读性 嵌套增长 链式但密集 接近同步
错误处理 每层手工 catch 收口 try/catch
并行表达 需库辅助 all/race 原生 需主动配合 all
调试栈 深且乱 较好 最好

语法之外:异步与测试的第一次相遇

异步代码的可测性差异也值得提前留意。回调风格难以断言——你无法在测试里直接拿到「将来才执行的回调结果」,只能借助 done 参数或替身库;Promise 化之后,测试只需要返回这个 Promise,断言链自然成立;async 函数更进一步,测试本身也写成 async,生产与测试的形态完全一致。很多团队重构成 async 的隐性收益,就是测试代码从「等待与计数」的黑魔法变成了线性的自描文档。

本节要点回顾

  • 回调的三宗罪:嵌套失控、控制反转、错误处理割裂——Promise 的设计目标正是逐条偿还。
  • 状态机是核心:pending 一旦落定不可逆,then 回调走微任务队列。
  • await 即暂停:async 函数在每次 await 处暂停并把后续代码注册为续体。
  • 并行要主动要:await 天然偏串行,独立任务必须 Promise.all。
  • 三代并存:不是取代关系,是不同抽象层的工具,工程上按边界混用。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U