Node.js 异步编程:回调函数、Promise、async/await、事件循环机制
聊到 Node.js,绕不开的就是异步编程。说实话,很多初学者刚接触 Node.js 时,最头疼的就是「回调地狱」。我自己带过不少新人,发现大家一开始都容易把异步当成同步来写,结果代码跑起来完全不是那么回事。
这一章,我就带你彻底搞懂 Node.js 的异步编程。从最原始的回调函数,到 Promise,再到现在的 async/await,最后再聊聊底层的「事件循环机制」。把这些吃透了,你写 Node.js 代码会顺手很多。
回调函数:最朴素的异步方案
回调函数是 Node.js 异步编程的基石。说白了,就是把一个函数作为参数传给另一个函数,等异步操作完成后再调用它。
举个例子,读取文件:
const fs = require('fs');
fs.readFile('./data.json', 'utf8', (err, data) => {
if (err) {
console.error('读文件出错了:', err);
return;
}
console.log('文件内容:', data);
});
console.log('我先执行了');
你猜猜输出顺序是什么?
先打印「我先执行了」,然后才是文件内容。这就是异步——代码不会傻等着文件读完,而是先往下走,等文件读完了再回来执行回调。
但回调函数有个大问题——嵌套多了就变成「回调地狱」:
fs.readFile('a.txt', (err, dataA) => {
fs.readFile('b.txt', (err, dataB) => {
fs.readFile('c.txt', (err, dataC) => {
// 这里已经三层了,再往下写就疯了
});
});
});
这种代码,维护起来简直是噩梦。我曾经接手过一个项目,回调嵌套了七八层,改一个逻辑要翻半天。所以后来社区就搞出了 Promise。
Promise:让异步更优雅
Promise 本质上是对回调的封装。它有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦改变,就不会再变了。
用 Promise 改写上面的文件读取:
const fs = require('fs').promises;
fs.readFile('a.txt', 'utf8')
.then(data => {
console.log('a.txt 内容:', data);
return fs.readFile('b.txt', 'utf8');
})
.then(data => {
console.log('b.txt 内容:', data);
return fs.readFile('c.txt', 'utf8');
})
.then(data => {
console.log('c.txt 内容:', data);
})
.catch(err => {
console.error('出错了:', err);
});
你看,通过 .then() 链式调用,代码变得扁平了。而且错误处理也统一到了 .catch() 里,不用每个回调都写 if (err)。
我个人的习惯是,只要涉及异步操作,优先用 Promise 封装。哪怕是最简单的 setTimeout,我也会写成 Promise 形式:
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
delay(1000).then(() => console.log('1秒后执行'));
这样写,后面配合 async/await 会非常舒服。
async/await:异步的终极形态
async/await 是 Promise 的语法糖。它让异步代码写起来像同步一样直观。我个人觉得,这是 Node.js 异步编程里最舒服的写法。
看个例子:
async function readFiles() {
try {
const dataA = await fs.readFile('a.txt', 'utf8');
console.log('a.txt:', dataA);
const dataB = await fs.readFile('b.txt', 'utf8');
console.log('b.txt:', dataB);
const dataC = await fs.readFile('c.txt', 'utf8');
console.log('c.txt:', dataC);
} catch (err) {
console.error('出错了:', err);
}
}
readFiles();
是不是清爽多了?没有 .then() 链,没有回调嵌套,就是一行一行往下写。但注意,await 只能在 async 函数里使用。
说到 Promise.all(),它特别适合处理「多个互不依赖的异步任务」:
async function loadAll() {
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
]);
return { user, posts, comments };
}
这样三个请求是并发执行的,比串行快得多。我一般在做数据聚合接口时,特别喜欢用这个模式。
事件循环机制:Node.js 的底层逻辑
好了,前面讲了怎么用异步,现在聊聊「为什么能异步」。这就要说到事件循环(Event Loop)了。
Node.js 是单线程的,但它能处理高并发,靠的就是事件循环。说白了,就是一个「先干手头的活,干完了再回来处理其他事」的机制。
事件循环有几个关键阶段:
| 阶段 | 说明 | 典型操作 |
|---|---|---|
| timers | 执行 setTimeout、setInterval 的回调 | 定时器到期 |
| pending callbacks | 执行一些系统操作的回调 | TCP 错误回调 |
| idle, prepare | 内部使用 | Node.js 内部机制 |
| poll | 获取新的 I/O 事件,执行 I/O 回调 | 文件读取、网络请求 |
| check | 执行 setImmediate 的回调 | setImmediate |
| close callbacks | 执行关闭事件的回调 | socket.on('close') |
每次事件循环,Node.js 会按顺序走完这些阶段。每个阶段都有一个「任务队列」,先进先出地执行。
来看个经典面试题:
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
输出顺序是什么?
答案是:1, 4, 3, 2。
为什么会这样?因为 Promise 的回调属于「微任务」(microtask),而 setTimeout 的回调属于「宏任务」(macrotask)。微任务的优先级比宏任务高。在每个宏任务执行完后,会清空所有微任务,再执行下一个宏任务。
我在项目中遇到过一个问题:用 process.nextTick 做递归,结果把事件循环卡死了。因为 process.nextTick 的优先级比 Promise 还高,它会一直插队,导致其他回调永远没机会执行。所以,process.nextTick 要慎用,一般用 setImmediate 更安全。
总结一下
这一章我们聊了 Node.js 异步编程的四个核心:
- 回调函数:最基础的异步方案,但嵌套多了就是灾难
- Promise:解决了回调地狱,提供了链式调用和统一错误处理
- async/await:Promise 的语法糖,让异步代码像同步一样写
- 事件循环:Node.js 异步的底层机制,理解它才能写出高性能代码
我个人建议,新项目一律用 async/await + Promise.all() 的组合。老项目如果还在用回调,可以逐步迁移。记住,异步编程的核心不是「怎么写」,而是「什么时候执行」。理解了事件循环,你就能掌控代码的执行顺序。
下一章,我们会聊聊 Node.js 的模块系统。到时候见。