Node.js 异步编程:回调、Promise、async/await
Node.js 的核心特点是非阻塞 I/O,而实现这一特点的关键就是异步编程。如果你刚接触 Node.js,可能会被回调、Promise、async/await 这几种写法搞混。今天这篇文章将用最直接的方式,带你彻底理解它们的关系和用法。
为什么需要异步编程
在传统同步代码中,程序必须等待一个操作完成才能执行下一步。比如读取文件时,整个线程会被阻塞,后面的代码只能干等。Node.js 采用单线程 + 事件循环的架构,如果所有操作都同步执行,一个耗时的文件读取就能让整个服务器卡死。
异步编程的核心思想是:发起一个耗时操作后,不等待结果,继续执行后面的代码。当操作完成时,通过回调、Promise 或 async/await 的方式处理结果。
第一阶段:回调函数
什么是回调函数
回调函数本质就是一个普通函数,只不过它被当作参数传递给另一个函数,并在某个时刻被调用。来看一个读取文件的例子:
const fs = require('fs');
fs.readFile('./data.txt', 'utf8', function(err, data) {
if (err) {
console.error('读取文件出错:', err);
return;
}
console.log('文件内容:', data);
});
console.log('这是文件读取之后的代码,会先执行');
执行这段代码,你会发现「这是文件读取之后的代码,会先执行」先被打印出来,文件内容后打印。这就是异步的魅力 —— 发起 I/O 后不阻塞,继续往下走。
回调地狱的问题
当多个异步操作需要顺序执行时,回调函数就会陷入层层嵌套:
fs.readFile('./a.txt', 'utf8', (err, data1) => {
if (err) return handleError(err);
fs.readFile('./b.txt', 'utf8', (err, data2) => {
if (err) return handleError(err);
fs.readFile('./c.txt', 'utf8', (err, data3) => {
if (err) return handleError(err);
console.log('三个文件都读取完成:', data1, data2, data3);
});
});
});
这种写法有三个严重问题:
- 缩进地狱:层级越来越深,代码往右跑
- 错误处理重复:每个层级都要写
if (err) return - 可读性差:很难看出执行逻辑
这就是著名的「回调地狱」(Callback Hell),也是 Promise 诞生的直接原因。
第二阶段:Promise
Promise 的基本概念
Promise 是一个表示「将来某个时刻会完成」操作的对象。它有三种状态:
| 状态 | 含义 |
|---|---|
pending |
进行中,还不知道结果 |
fulfilled |
成功完成 |
rejected |
失败出错了 |
状态一旦改变就不会再变,这个特性叫「状态不可逆」。
创建 Promise
用 new Promise() 创建一个 Promise 对象,传入一个执行器函数:
const fs = require('fs');
// 将回调写法包装成 Promise
function readFileAsync(filePath) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
reject(err); // 失败时调用
} else {
resolve(data); // 成功时调用
}
});
});
}
then / catch / finally
Promise 提供链式调用来处理结果:
readFileAsync('./a.txt')
.then(data => {
console.log('a.txt 内容:', data);
return readFileAsync('./b.txt'); // 返回新的 Promise
})
.then(data => {
console.log('b.txt 内容:', data);
return readFileAsync('./c.txt');
})
.then(data => {
console.log('c.txt 内容:', data);
})
.catch(err => {
// 统一捕获任意环节的错误
console.error('读取文件出错:', err);
})
.finally(() => {
// 无论成功失败都会执行
console.log('读取操作结束');
});
对比回调地狱,Promise 链式调用的优势非常明显:
- 扁平化结构:没有深层嵌套
- 错误冒泡:只需一个
catch就能捕获整条链上的错误 - 逻辑清晰:按照执行顺序从上往下读
Promise 静态方法
Promise 还提供几个实用的静态方法:
// 等待所有 Promise 完成
Promise.all([readFileAsync('./a.txt'), readFileAsync('./b.txt')])
.then(([data1, data2]) => {
console.log('两个文件都读完了:', data1, data2);
})
.catch(err => console.error('只要有一个失败就到这里:', err));
// 等待最快的那个完成
Promise.race([
readFileAsync('./a.txt'),
new Promise((_, reject) => setTimeout(() => reject('超时'), 5000))
])
.then(result => console.log('最先完成的结果:', result))
.catch(err => console.error('超时或其他错误:', err));
第三阶段:async/await
为什么还需要 async/await
Promise 已经解决了回调地狱,但链式调用在复杂场景下仍然不够直观。比如你需要在一个 Promise 结果出来后再做判断、循环处理多个结果,代码会变得繁琐。async/await 就是为了解决这个问题,它让异步代码看起来像同步代码。
基本用法
在函数前加 async 关键字,函数内部就可以用 await 等待 Promise:
const fs = require('fs');
function readFileAsync(filePath) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
async function processFiles() {
try {
const data1 = await readFileAsync('./a.txt');
console.log('a.txt 内容:', data1);
const data2 = await readFileAsync('./b.txt');
console.log('b.txt 内容:', data2);
const data3 = await readFileAsync('./c.txt');
console.log('c.txt 内容:', data3);
console.log('三个文件都读取完成');
} catch (err) {
console.error('读取文件出错:', err);
}
}
processFiles();
这段代码的阅读体验和同步代码完全一样,从上往下顺着读就行。
async/await 的细节规则
使用 async/await 时需要记住几条规则:
第一,await 只能用在 async 函数内部。在普通函数或全局作用域使用会报错:
// 错误写法
const data = await readFileAsync('./a.txt');
// 正确写法:包在 async 函数里
async function main() {
const data = await readFileAsync('./a.txt');
}
第二,async 函数总是返回 Promise。即使你直接 return 一个值,调用方收到的是一个包装后的 Promise:
async function getNumber() {
return 42;
}
getNumber().then(console.log); // 输出 42
第三,错误处理可以用 try/catch,也可以让错误冒泡到调用方:
async function getData() {
// 方式一:try/catch 内部处理
try {
const data = await fetchData();
} catch (err) {
console.error('处理错误:', err);
}
}
// 方式二:交给调用方的 catch
async function getData() {
const data = await fetchData();
return data;
}
getData().catch(err => console.error('调用方处理错误:', err));
并行 await 的技巧
如果你有多个独立的异步操作要执行,不要在循环里一个一个 await,这样会变成串行执行:
// 串行执行:慢,每个都要等前一个完成
async function serialRead() {
const a = await readFileAsync('./a.txt'); // 1秒
const b = await readFileAsync('./b.txt'); // 再等1秒
const c = await readFileAsync('./c.txt'); // 再等1秒
return [a, b, c];
}
应该用 Promise.all 一次性等待所有操作:
// 并行执行:快,一起发起
async function parallelRead() {
const promises = [
readFileAsync('./a.txt'),
readFileAsync('./b.txt'),
readFileAsync('./c.txt')
];
const [a, b, c] = await Promise.all(promises);
return [a, b, c];
}
Promise.all 会同时发起三个文件读取操作,总耗时大约等于最慢那个操作的时间,而不是三个相加。
三种方式的对比
| 特性 | 回调函数 | Promise | async/await |
|---|---|---|---|
| 嵌套层级 | 深(回调地狱) | 扁平链式 | 扁平线性 |
| 错误处理 | 层级内重复 | 统一 catch |
try/catch |
| 可读性 | 差 | 中等 | 好 |
| 调试难度 | 高(断点跳跃) | 中等 | 低(像同步代码) |
| 适用场景 | 简单回调 | 需要链式调用 | 推荐日常使用 |
最佳实践:新代码统一用 async/await,它让异步代码的可读性和可维护性达到最高。如果你要封装底层异步操作,可以返回一个 Promise。对于第三方库不支持 Promise 的情况,用 util.promisify 包装:
const { promisify } = require('util');
const readFile = promisify(fs.readFile); // 包装后的函数返回 Promise
async function main() {
const data = await readFile('./a.txt', 'utf8');
console.log(data);
}
总结
Node.js 异步编程经历了从回调到 Promise 再到 async/await 的演进。回调函数是最基础的方式,但深层嵌套会带来「回调地狱」问题。Promise 通过链式调用解决了嵌套问题,让错误处理更统一。async/await 则是语法糖,用同步的方式写异步代码,大大提升了可读性。
实际开发中,优先使用 async/await,它应该是你处理异步操作的首选方式。只有在需要并行发起多个操作时,才需要手动使用 Promise.all 配合 await。理解这三者的关系和演进,你就能在实际开发中灵活选择最适合的写法。

暂无评论,快来抢沙发吧!