Node.js Promise 异步编程
上篇 Node.js 回调与回调地狱 展示了嵌套回调的痛点,这篇用 Promise 把它解决掉。读者已有 JS 的 Promise 基础,这里聚焦 Node.js 场景,重点是链式传值、并发控制,以及 Node 提供的 util.promisify 和 fs.promises 两套工具。
三个状态快速回顾
Promise 有 pending、fulfilled、rejected 三个状态。pending 是进行中,resolve 后变成 fulfilled,reject 后变成 rejected。状态只能转变一次,一旦落定不会再变,后面再调用 resolve 或 reject 都会被忽略
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve("成功"), 1000);
// 下面这行无效,状态已经落定
setTimeout(() => reject(new Error("晚了")), 2000);
});
p.then((value) => console.log(value));
这个特性很重要,它保证回调最多执行一次,正好治了回调里”成功失败路径都跑”的毛病。
then catch finally 链式调用
then 处理成功,catch 处理失败,finally 无论成败都执行。关键在 then 的返回值,每个 then 都会返回一个新的 Promise,链才能继续接下去
Promise.resolve(1)
.then((n) => n + 1) // 返回 2,包成新的 Promise
.then((n) => n * 10) // 返回 20
.then((n) => console.log(n)) // 输出 20
.catch((err) => console.error("出错了", err.message))
.finally(() => console.log("链结束"));
只要某个 then 抛异常或返回 rejected 的 Promise,后面的链会直接跳到最近的 catch,中间的正常 then 全部跳过。finally 不接收值,只用来做清理。
Promise 链式传递
回到上篇的读文件场景,用 Promise 链重写。每步的返回值通过 return 传给下一步,流程从左到右平铺,不再往右缩进
const fs = require("node:fs");
const { promisify } = require("node:util");
const readFile = promisify(fs.readFile);
let result = "";
readFile("a.txt", "utf8")
.then((dataA) => {
result += dataA;
return readFile("b.txt", "utf8");
})
.then((dataB) => {
result += dataB;
return readFile("c.txt", "utf8");
})
.then((dataC) => {
result += dataC;
console.log(result);
})
.catch((err) => console.error("读取失败", err.message));
对比上一篇的金字塔,错误只在一个 catch 里处理,漏网的错误不会再静默消失。
并发控制
依次读取是串行,三个文件其实可以同时读,Promise 提供三个静态方法控制并发
| 方法 | 行为 | 适用场景 |
|---|---|---|
Promise.all |
全部成功才成功,一个失败整体失败 | 并发请求后必须全部拿到才继续 |
Promise.race |
返回最先落定的那个,不管成败 | 超时控制,哪个快用哪个 |
Promise.allSettled |
等全部落定,返回每个的结果和状态 | 部分失败也要处理,互不牵连 |
const fs = require("node:fs/promises");
Promise.all([
fs.readFile("a.txt", "utf8"),
fs.readFile("b.txt", "utf8"),
fs.readFile("c.txt", "utf8"),
])
.then(([dataA, dataB, dataC]) => {
console.log(dataA + dataB + dataC);
})
.catch((err) => console.error("任一文件失败", err.message));
Promise.allSettled([
fs.readFile("a.txt", "utf8"),
fs.readFile("b.txt", "utf8"),
]).then((results) => {
results.forEach((r, i) => {
if (r.status === "fulfilled") {
console.log(`文件 ${i + 1} 内容`, r.value);
} else {
console.log(`文件 ${i + 1} 失败`, r.reason.message);
}
});
});
all 的返回顺序和传入顺序一致,不一定是完成顺序,这点在实践部分会验证。
util.promisify
Node 特有的工具,位于 util 模块,把错误优先回调函数转成 Promise 函数。fs 老接口都是回调风格,不想手动包 new Promise 就用它
const fs = require("node:fs");
const { promisify } = require("node:util");
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
async function main() {
const data = await readFile("a.txt", "utf8");
await writeFile("out.txt", data + "\n追加内容");
console.log("写入完成");
}
main();
promisify 的原理就是包一层 Promise,调用原函数,把 err 交给 reject,数据交给 resolve。只要函数遵守错误优先约定就能转,自定义的异步函数同样适用。
fs.promises
fs 模块自带的 Promise 版本 API,从 node:fs/promises 引入,不用手动转换,推荐直接用
const fsp = require("node:fs/promises");
fsp.readFile("a.txt", "utf8")
.then((data) => console.log(data))
.catch((err) => console.error("读取失败", err.message));
方法名和回调版一致,readFile、writeFile、mkdir、readdir 都能用。Node 20 里这套 API 已完全稳定,新代码优先选它。
实践
准备 a.txt、b.txt、c.txt 三个文件,内容分别为 1 到 3 行文字。
第一步,用 Promise 链实现依次读取并合并输出,注意输出顺序固定是 a、b、c。
第二步,改成 Promise.all 并行读取,运行三次观察输出顺序。预期是 a、b、c 固定,因为 all 按传入顺序返回结果。
第三步,把其中两个文件内容调大,比如 b.txt 写入几百行,再对比串行和并行谁先跑完。想直观感受就在代码里给读取加 console.time 和 console.timeEnd
const fsp = require("node:fs/promises");
console.time("串行");
fsp.readFile("a.txt", "utf8")
.then(() => fsp.readFile("b.txt", "utf8"))
.then(() => fsp.readFile("c.txt", "utf8"))
.then(() => console.timeEnd("串行"));
console.time("并行");
Promise.all([
fsp.readFile("a.txt", "utf8"),
fsp.readFile("b.txt", "utf8"),
fsp.readFile("c.txt", "utf8"),
]).then(() => console.timeEnd("并行"));
常见坑
第一个坑,then 里忘了 return。下一个 then 拿到的就是 undefined,链悄悄断掉,数据丢失,还不报错。
第二个坑,把回调风格的函数直接 .then。fs.readFile 不返回 Promise,调用 .then 直接报 TypeError,先过 promisify 或改用 fs.promises。
第三个坑,Promise.all 一个失败整体失败,只想拿到所有结果不管成败要用 allSettled,场景选错是新手最常见的问题。
第四个坑,new Promise 里抛同步异常。构造器会自动捕获并变成 rejected,不会崩进程,但代码里可能看不出来,建议错误都走 reject 保持统一。
下一步
Promise 把嵌套摊平了,但链式写法还是不够直观,复杂逻辑里 return 满天飞。下一章 Node.js async await 用同步的样子写异步,把这条链彻底收起来。