Node.js 异步编程
异步是 Node 的灵魂——所有 I/O 操作(读文件、查库、发请求)都是异步的,主线程绝不停下来等。这一章是 Node 进阶的最大门槛,也是真实开发的核心。我们从事件循环讲到 Promise,再到现代的 async/await,把异步编程一网打尽。
1. 事件循环:Node 的"心跳"
Node 是单线程的(主线程只有一个),靠事件循环处理异步。理解事件循环,才能解释"为什么这段代码先执行、那段后执行"。先看一个经典例子:
// Node 异步代码的执行顺序(经典面试题)
console.log("1. 同步开始");
setTimeout(() => {
console.log("2. setTimeout(宏任务)");
}, 0);
setImmediate(() => {
console.log("3. setImmediate(宏任务)");
});
Promise.resolve().then(() => {
console.log("4. Promise(微任务)");
});
process.nextTick(() => {
console.log("5. nextTick(比微任务还快)");
});
console.log("6. 同步结束");
// 实际输出顺序:
// 1 → 6 → 5 → 4 → 2/3
// 同步代码先跑完 → nextTick → 微任务 → 宏任务事件循环有几个阶段(phase),每轮循环依次执行:timers(setTimeout)→ pending callbacks → idle → poll(I/O)→ check(setImmediate)→ close callbacks。同步代码跑完后,事件循环按阶段取任务执行,每轮之间会清空两种队列:
- nextTick 队列(
process.nextTick):优先级最高,每阶段切换前都清空。 - 微任务队列(Promise 的 then/catch/finally):nextTick 之后清空。
所以记忆口诀:同步 → nextTick → 微任务 → 宏任务(setTimeout/setImmediate/I/O)。
2. 回调函数:Node 的传统姿势
Node 的传统异步 API 用回调函数——异步操作完成后调用你传入的函数。Node 有个重要约定:错误优先回调(第一个参数永远是 err):
// Node 的"错误优先"回调约定
// 第一个参数永远是 err,没有错误就是 null
const fs = require("fs");
fs.readFile("config.json", "utf8", (err, data) => {
if (err) {
console.error("读取失败:", err.message);
return; // 出错就 return,别继续往下
}
console.log("读取成功:", data);
});
// 回调地狱(callback hell):嵌套太深,极难读
getUser(userId, (err, user) => {
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetail(orders[0].id, (err, detail) => {
if (err) return handleError(err);
// 越缩越深,维护噩梦...
});
});
});回调的痛点是"回调地狱"——多层嵌套,代码像横着的箭头,极难读。这是 Promise 被发明的直接原因。
3. Promise:链式扁平化
Promise 是 ES6 引入的"将来会有"的值的容器,通过 then 链式调用把嵌套变成扁平:
// Promise:解决回调地狱
const fs = require("fs").promises;
// 把异步操作封装成 Promise
function readConfig() {
return fs.readFile("config.json", "utf8");
}
// 链式调用:扁平化、错误集中处理
readConfig()
.then(text => JSON.parse(text)) // 解析 JSON
.then(config => loadDatabase(config))
.then(db => db.query("SELECT 1"))
.then(rows => console.log("结果:", rows))
.catch(err => console.error("任一步失败:", err))
.finally(() => console.log("无论成败都执行"));
// Promise 三状态:
// pending(进行中)→ fulfilled(成功)/ rejected(失败)
// 状态一旦确定就不可逆Promise 解决了回调地狱,但代码仍有些"流水账"。最终方案是 async/await。
4. async/await:现代标准
ES2017 引入的 async/await 是异步编程的终极方案——基于 Promise,但写法像同步代码一样直白,错误处理用 try/catch:
// async/await:用同步的写法做异步(终极方案)
const fs = require("fs").promises;
async function loadAll() {
try {
const text = await fs.readFile("config.json", "utf8");
const config = JSON.parse(text);
const data = await fetchData(config.url);
return data;
} catch (err) {
// 任何 await 的失败都会进这里
console.error("失败:", err.message);
return null;
} finally {
console.log("清理资源");
}
}
loadAll().then(data => console.log(data));
// 关键点:
// - async 函数自动返回 Promise
// - await 暂停函数,等 Promise resolve
// - 只能在 async 函数里用(顶层 await 是 ES2022)
// - 错误用 try/catch 处理,直观实战中 90% 的异步代码用 async/await。Node 内置的 fs/promises、fetch(Node 18+)、所有数据库驱动都返回 Promise,完美配合。
5. 串行 vs 并行:性能关键
这是 Node 性能调优的核心。多个独立的异步操作,串行 await 会浪费时间,应该用 Promise.all 并行:
// 并行 vs 串行:对性能影响巨大
const fs = require("fs").promises;
// ❌ 串行:总耗时 = 三个文件时间之和(浪费时间)
async function serial() {
const a = await fs.readFile("a.txt");
const b = await fs.readFile("b.txt");
const c = await fs.readFile("c.txt");
return [a, b, c];
}
// ✅ 并行:总耗时 = 最慢那个的时间
async function parallel() {
const [a, b, c] = await Promise.all([
fs.readFile("a.txt"),
fs.readFile("b.txt"),
fs.readFile("c.txt")
]);
return [a, b, c];
}
// Promise.all:全部成功才成功(任一失败就 reject)
// Promise.allSettled:等全部完成(不管成败),返回状态数组
// Promise.race:第一个完成(成败都行),常用于超时控制
// Promise.any:第一个成功(忽略失败)假设每个文件读 100ms,串行要 300ms,并行只要 100ms——三倍差距。真实项目里查询多个独立接口、读多个独立文件,务必用 Promise.all。
6. 超时控制
网络请求一定要有超时——不然接口卡住,整个请求链路全卡。Promise.race 是实现超时的经典模式:
// 用 Promise.race 实现超时控制
function fetchWithTimeout(url, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("请求超时")), ms)
);
return Promise.race([
fetch(url),
timeout
]);
}
// 用法:5 秒内没返回就报错
fetchWithTimeout("https://slow-api.com/data", 5000)
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err.message)); // "请求超时"7. 循环里的异步:常见陷阱
在循环里用 await 是新手最容易踩的坑。forEach 根本不会等 await!正确做法是用 for...of 或 Promise.all:
// 在循环里正确处理异步
// ❌ 错误:forEach 不会等 await
async function wrong() {
[1, 2, 3].forEach(async (n) => {
await delay(n);
});
console.log("done"); // 这行会先打印!
}
// ✅ 正确 1:for...of(串行)
async function right1() {
for (const n of [1, 2, 3]) {
await delay(n);
}
console.log("done");
}
// ✅ 正确 2:Promise.all(并行,更快)
async function right2() {
await Promise.all([1, 2, 3].map(n => delay(n)));
console.log("done");
}
function delay(n) {
return new Promise(r => setTimeout(r, n * 100));
}记忆:别用 forEach 跑 async 回调,用 for...of(串行)或 map + Promise.all(并行)。
8. process.nextTick vs setImmediate
这两个是 Node 特有的、容易混淆的定时器:
- process.nextTick:把回调放到 nextTick 队列,当前操作完成后立即执行(比 Promise 还快)。优先级太高,滥用会阻塞 I/O,慎用。
- setImmediate:把回调放到事件循环的 check 阶段,下轮循环才执行。名字虽叫"immediate",其实比 nextTick 慢得多。
99% 的场景用 Promise.resolve().then() 就够了,不用纠结这两个。它们主要用于库作者精细化控制执行时机。
9. 全局未捕获错误
如果一个 Promise 没 catch,它的 rejection 会"漂浮"——Node 默认只打印警告,Node 15+ 默认会让进程退出。监听全局事件做兜底:
// 未捕获的 Promise rejection
process.on("unhandledRejection", (err) => {
console.error("未处理的 Promise 失败:", err);
// 生产环境:记日志、上报、优雅退出
});
// 未捕获的同步异常
process.on("uncaughtException", (err) => {
console.error("未捕获的异常:", err);
// 进程状态可能已不稳定,建议退出让 PM2/容器重启
process.exit(1);
});生产环境务必监听这两个事件——否则一个未处理的 Promise 失败,可能让整个服务状态紊乱。
小结
异步是 Node 的灵魂。记住几条:事件循环执行顺序是同步 → nextTick → 微任务 → 宏任务;新代码一律用 async/await;独立操作用 Promise.all 并行;循环里别用 forEach 跑 async;生产环境监听 unhandledRejection。掌握这些,你就算真正"会"Node 异步了。
← 上一篇 Node.js 流
下一篇 Node.js 包管理 →