楼层: 首页/ 软件技术/ Node.js 全栈实战/ 异步编程与事件循环:Node 的魂
04

异步编程与事件循环:Node 的魂

Async & Event Loop

这一章是 Node.js 最核心、也最容易把人绕晕的地方。先记住一句话:Node 是单线程的,但它能同时招呼一万个请求——秘诀就是"不等"。读文件、查数据库、发网络请求这些"要等"的活,它丢给后台去干,自己接着处理下一个请求。本章就讲这套机制怎么一步步演化成你天天写的 async/await。

第一站:回调函数(Callback),以及它怎么把你坑哭

最早的 Node 用"回调函数"处理异步:你给一个操作传一个函数,等操作做完了,它回头调你这个函数。听着挺好,问题是嵌套深了就变成了这样——史称回调地狱(Callback Hell)。

回调地狱长这样(缩进往右飞,像个箭头)

// 读文件 → 读完再查用户 → 查完再发邮件,一层套一层 fs.readFile("config.txt", "utf8", function(err, config) { if (err) return console.error(err); db.queryUser(config.userId, function(err, user) { if (err) return console.error(err); mailer.send(user.email, function(err) { console.log("邮件发完了,终于。"); }); }); });
为什么没人再这么写了

三层嵌套看着还行,实际项目七八层起步。读代码像在解谜语,错误处理到处 if(err),改一个需求要在九层塔上开刀。于是 Promise 登场了——它把"竖着叠"改成"横着连"。

第二站:Promise,把回调拉成一条链

Promise 是个"承诺容器":它代表一件现在没干完、将来会干完(或失败)的事。用 .then() 接成功、.catch() 接失败,一行一行横着连,再也不缩进了。

同一个逻辑,用 Promise 链式调用

readFilePromise("config.txt") .then(config => db.queryUser(config.userId)) // 读完 → 查用户,返回新的 Promise .then(user => mailer.send(user.email)) // 查到 → 发邮件 .then(() => console.log("邮件发完了。")) .catch(err => console.error("中间哪步出错了:", err));

顺手解释三个方法:.then() 是成功后干嘛,.catch() 是出错了干嘛,.finally() 是不管成功失败都干嘛(比如关个加载动画)。

第三站:async/await,写异步像写同步

ES2017 推出 async/await,这是目前你 99% 时间在用的写法。它的魔法是:让"等一个异步结果"看起来和"等一个普通变量"一模一样,再也不用 .then 链条。

同一个逻辑,async/await 版——一眼看懂

// 注意:await 只能放在 async 函数里 async function main() { try { const config = await readFilePromise("config.txt", "utf8"); const user = await db.queryUser(config.userId); await mailer.send(user.email); console.log("邮件发完了。"); } catch (err) { // try 里任何一步 await 出错,都会跳到这里 console.error("出错了:", err); } } main();

划记住两条铁律

铁律一:await 只能在 async 函数(或模块顶层)里用,外面用了直接报错。

铁律二:每一个 await 都应该被 try/catch 包住,或者后面有 .catch——否则一个没捕获的 Promise 报错,Node 15 以后会直接让整个进程崩溃退出。这是新手最常见的线上事故。

事件循环到底在转什么

你不用背下全部细节,但要知道 Node 转一圈(一次事件循环)分六个阶段,像警察绕小区巡逻,每到一个岗亭处理一批活。

阶段这一圈在干嘛
timers处理 setTimeout、setInterval 到点的回调。
pending callbacks处理上一轮延迟到的系统级回调(如 TCP 错误)。
poll(轮询)重点岗亭:等新的 I/O 事件(文件读完、网络请求回来),处理它们。没活了就在这等一下。
check处理 setImmediate 的回调。
close callbacks处理关闭事件,比如 socket.on('close')。

宏任务 vs 微任务:执行顺序的"插队规则"

这是面试高频、也是写代码容易踩坑的点。一句话规则:每跑完一个宏任务,就把队列里所有微任务全部清空,再去跑下一个宏任务。

  • 宏任务(macrotask):setTimeout、setInterval、I/O、setImmediate。排队等着轮流跑。
  • 微任务(microtask):Promise.then/.catch/.finally、process.nextTick。优先级极高,当前宏任务一结束立刻全部清空。

看这段,猜一下打印顺序

console.log("1 同步代码"); setTimeout(() => console.log("2 setTimeout(宏任务)"), 0); Promise.resolve().then(() => console.log("3 Promise.then(微任务)")); console.log("4 同步代码");
1 同步代码 4 同步代码 3 Promise.then(微任务) 2 setTimeout(宏任务)

规律:先跑全部同步代码(1、4);同步跑完,清空微任务(3);最后才轮到宏任务(2)。哪怕 setTimeout 写着 0 毫秒,也得等微任务清空才轮到它。

四个并发帮手:Promise.all / race / allSettled / any

你有三件事可以同时做(比如同时查三个接口),别傻乎乎 await 完一个再等下一个——那样是串行,慢三倍。用这四个方法批量并发:

方法什么时候用、什么行为
Promise.all三件事一起发,全部成功才成功;任何一个失败就立刻失败。适合"三个数据都拿到才能继续"。
Promise.race谁先回来用谁。适合"超时兜底"——设个 3 秒的定时,谁先结束听谁的。
Promise.allSettled等所有事都有结果(不管成功失败),拿到一份成绩单。适合"批量发请求,个别失败不影响其他"。
Promise.any第一个成功的就用它,全失败才算失败。适合"多个备用接口,哪个通了用哪个"。
记
本章小结

① 异步写法演进:回调(嵌套地狱)→ Promise(链式)→ async/await(同步观感),现在一律用 async/await。

② 微任务(Promise)比宏任务(setTimeout)先跑,事件循环每圈先清微任务。

③ 能并发就别串行:用 Promise.all 同时发多个请求;每个 await 都记得 try/catch。