第3章 3.5 util.promisify 与流控制
🎯 开场:为什么要学这个?
上一章我们搞懂了事件循环——Node.js 是怎么"一心多用"处理海量请求的。但学完你可能会想:「我知道它底层在调度,但实际代码里怎么控制这些异步任务的执行顺序?多个请求同时来,我怎么让它们排队?怎么确保它们全部完成?」
这就是本章要解决的问题。
举个例子:你写了个爬虫,要从 10 个网站抓数据。 naive 的写法是逐个请求——第一个完事再请求第二个,10 个网站串行跑完可能要 30 秒。但其实这 10 个请求互相不依赖,完全可以同时发出去,5 秒跑完。
学完这章,你就能:
- 把回调风格的异步代码" promisify 化",用
await来写异步 - 用
Promise.all让多个异步任务同时跑(并发,不是并行) - 用
Promise.race实现竞速——谁先完成用谁的结果 - 用
Promise.allSettled收集所有任务的结果,不管成功还是失败
🧱 基础:核心概念
3.5.1 回调地狱与 promisify
生活类比:你去医院挂号,窗口告诉你「下午 3 点来取报告」。你下午去,窗口说「还没好,再等 10 分钟」。你又去,又说「还差一个指标」。这种反复跑、反复等的模式,就是回调。
Node.js 早期大量使用回调函数来处理异步。比如读文件:
const fs = require('fs');
fs.readFile('./data.txt', 'utf8', function(err, data) {
if (err) {
console.error('读取失败:', err);
return;
}
console.log('文件内容:', data);
fs.readFile('./another.txt', 'utf8', function(err, data2) {
if (err) {
console.error('读取失败:', err);
return;
}
console.log('第二个文件:', data2);
});
});
嵌套嵌套再嵌套,这就是传说中的「回调地狱」。业务逻辑被缩进吞掉了。
promisify 是什么?
util.promisify 是 Node.js 内置工具,它能把回调风格的函数,自动转换成返回 Promise 的版本。转换后,你就可以用 await 来写了。
const fs = require('fs');
const util = require('util');
// 把回调版本的 readFile 变成 Promise 版本
const readFile = util.promisify(fs.readFile);
// 现在可以这样写了!
async function main() {
const data = await readFile('./data.txt', 'utf8');
console.log('文件内容:', data);
const data2 = await readFile('./another.txt', 'utf8');
console.log('第二个文件:', data2);
}
main();
解释:第三行用 util.promisify(fs.readFile) 创建了一个新函数 readFile,它的功能和老的一样,但不再需要回调,而是返回 Promise。

什么时候用:
- 接手老项目,看到
fs.readFile(path, callback)这种旧写法,想改成await风格 - 自己写库,某个回调风格的 API 想提供 Promise 版本
3.5.2 Promise.all:一起跑,谁慢听谁的
生活类比:你去自助餐厅,点了牛排、寿司、甜点。厨房说「三样一起做,哪样最慢做好,你就什么时候吃」。Promise.all 就是这个逻辑——等所有 Promise 都完成,收集所有结果。
const util = require('util');
const fs = require('fs');
const readFile = util.promisify(fs.readFile);
async function fetchAllData() {
// 三个文件同时开始读!
const results = await Promise.all([
readFile('./a.txt', 'utf8'),
readFile('./b.txt', 'utf8'),
readFile('./c.txt', 'utf8')
]);
console.log('全部读完:', results);
console.log('第一个文件:', results[0]);
console.log('第二个文件:', results[1]);
console.log('第三个文件:', results[2]);
}
fetchAllData();
解释:Promise.all([...]) 接收一个数组,里面放 N 个 Promise。它会同时启动所有任务(不是按顺序一个一个),等最慢的那个完成,然后返回一个数组,按顺序放着每个 Promise 的结果。
重要特性:
- 如果任意一个 Promise reject 了,整个 Promise.all 都会 reject(像自助餐厅只要一道菜出问题,你就吃不成)
- 适合「所有任务必须全部成功」的场景
3.5.3 Promise.race:竞速模式
生活类比:你和朋友同时抢微信红包,谁先点到就归谁。Promise.race 就是这个竞速逻辑——哪个 Promise 先完成,就用它的结果。
const util = require('util');
const fs = require('fs');
const readFile = util.promisify(fs.readFile);
async function raceDemo() {
const fastFile = readFile('./small.txt', 'utf8'); // 小文件,可能更快
const slowFile = readFile('./big.txt', 'utf8'); // 大文件,更慢
const winner = await Promise.race([fastFile, slowFile]);
console.log('竞速获胜者:', winner);
}
raceDemo();
解释:Promise.race 监听所有 Promise,一旦任意一个先完成(不管是 resolve 还是 reject),就立即返回那个结果,其他的不管了。
典型应用场景:
- 超时控制:你发请求给服务器,但不想等太久。设置一个「超时 Promise」,比如 3 秒后自动 reject。Promise.race([真正的大请求, 超时Promise])——如果请求 3 秒内没返回,就当它超时了。
function timeout(ms) {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), ms)
);
}
async function fetchWithTimeout() {
try {
const result = await Promise.race([
fetch('https://api.example.com/slow-endpoint'), // 假设很慢
timeout(3000) // 3秒超时
]);
console.log('成功:', result);
} catch (err) {
console.error('失败:', err.message); // 超时时会走到这里
}
}

3.5.4 Promise.allSettled:不管成败,都要结果
生活类比:你给 5 个朋友发微信,约周末打球。A 没回复(pending),B 说来,C 说来不了,D 说来,E 没回复。你想知道每个人到底怎么说,而不是「只要有一个人说来不了就整个失败」。
Promise.allSettled 就是这样——等所有 Promise 落幕(settled),不管成功还是失败,都返回结果。
const util = require('util');
const fs = require('fs');
const readFile = util.promisify(fs.readFile);
async function fetchWithFallback() {
const results = await Promise.allSettled([
readFile('./existed.txt', 'utf8'),
readFile('./not-exist.txt', 'utf8'), // 这个会失败
readFile('./another.txt', 'utf8')
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`文件${index + 1} 成功:`, result.value.substring(0, 20));
} else {
console.log(`文件${index + 1} 失败:`, result.reason.code);
}
});
}
fetchWithFallback();
输出示例:
文件1 成功: "这是文件内容..."
文件2 失败: ENOENT
文件3 成功: "第三个文件..."
解释:返回的数组里,每个元素是 { status: 'fulfilled', value: ... } 或 { status: 'rejected', reason: ... },清楚地告诉你每个任务的状态。
什么时候用:
- 批量请求日志:你发了 10 个请求,想知道每个的结果,而不是「一个失败全部失败」
- 优雅降级:部分服务挂了不影响其他服务
🔥 实战:3 个递进项目
项目 1:文件存在性检查器(5 分钟)
目标:用 Promise.all 检查多个文件是否存在。
const util = require('util');
const fs = require('fs');
const stat = util.promisify(fs.stat);
// 检查单个文件是否存在
async function checkFile(filePath) {
try {
await stat(filePath);
return { path: filePath, exists: true };
} catch (err) {
return { path: filePath, exists: false };
}
}
// 同时检查多个文件
async function checkMultiple(files) {
const results = await Promise.all(files.map(f => checkFile(f)));
results.forEach(r => {
console.log(r.exists ? '✓' : '✗', r.path);
});
return results;
}
// 运行
checkMultiple([
'./package.json',
'./README.md',
'./non-existent.txt',
'./node_modules/package.json'
]);
预期输出:
✓ ./package.json
✓ ./README.md
✗ ./non-existent.txt
✓ ./node_modules/package.json
解释:每个 checkFile 调用互不依赖,Promise.all 让它们同时跑。如果有 100 个文件要检查,也只需要等最慢的那个。
项目 2:批量获取 API 数据(15 分钟)
目标:从 JSONPlaceholder API 抓取多个用户信息,汇总输出。
const util = require('util');
const https = require('https');
// promisify 化 http.get
function get(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error('JSON 解析失败'));
}
});
}).on('error', reject);
});
}
async function fetchUsers(userIds) {
console.log(`正在获取 ${userIds.length} 个用户信息...`);
const startTime = Date.now();
// 同时发请求!
const promises = userIds.map(id =>
get(`https://jsonplaceholder.typicode.com/users/${id}`)
);
const users = await Promise.all(promises);
const elapsed = Date.now() - startTime;
console.log(`\n获取完成,耗时 ${elapsed}ms\n`);
users.forEach(user => {
console.log(`📧 ${user.email}`);
console.log(` 城市: ${user.address.city}`);
console.log(` 公司: ${user.company.name}`);
console.log('---');
});
return users;
}
fetchUsers([1, 3, 5, 7, 9]);
预期输出(部分):
正在获取 5 个用户信息...
获取完成,耗时 312ms
📧 Eliseo@guillermo.tv
市: Indianapolis
司: Reynolds, Weiss and Waelchi
---
📧 Nayeli@aut-ec.com
市: Estelle
司: Bruen and Sons
---
...
解释:userIds.map() 创建了 5 个 Promise,Promise.all 让它们同时发出。如果串行执行(等一个完再请求下一个),可能要 5 × 300ms = 1500ms;并发只要等最慢那个,300ms 左右。
项目 3:带超时和熔断的数据聚合器(15 分钟)
目标:综合运用 Promise.allSettled + 超时控制,实现一个「部分失败也能继续」的数据聚合器。
const util = require('util');
const https = require('https');
// 超时包装器
function withTimeout(promise, ms, name) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`[${name}] 超时 ${ms}ms`)), ms)
)
]);
}
// http get promisify
function get(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(e); }
});
}).on('error', reject);
});
}
async function fetchDashboard() {
const endpoints = [
{ name: '用户数', url: 'https://jsonplaceholder.typicode.com/users/1' },
{ name: '文章数', url: 'https://jsonplaceholder.typicode.com/posts/1' },
{ name: '评论数', url: 'https://jsonplaceholder.typicode.com/comments/1' },
{ name: '假数据(会超时)', url: 'https://httpbin.org/delay/10' }, // 故意设超时
];
console.log('正在并行获取仪表盘数据...\n');
const startTime = Date.now();
// 给每个请求加 2 秒超时,用 allSettled 确保部分失败不崩
const promises = endpoints.map(ep =>
withTimeout(get(ep.url), 2000, ep.name)
.then(data => ({ name: ep.name, success: true, data }))
.catch(err => ({ name: ep.name, success: false, error: err.message }))
);
const results = await Promise.all(promises);
const elapsed = Date.now() - startTime;
console.log(`\n请求完成,耗时 ${elapsed}ms\n`);
console.log('='.repeat(40));
results.forEach(r => {
if (r.success) {
console.log(`✓ ${r.name}: 获取成功`);
} else {
console.log(`✗ ${r.name}: ${r.error}`);
}
});
// 统计
const successCount = results.filter(r => r.success).length;
console.log('='.repeat(40));
console.log(`成功: ${successCount}/${results.length}`);
return results;
}
fetchDashboard();
预期输出:
正在并行获取仪表盘数据...
请求完成,耗时 2012ms
========================================
✓ 用户数: 获取成功
✓ 文章数: 获取成功
✓ 评论数: 获取成功
✗ 假数据(会超时): [假数据(会超时)] 超时 2000ms
========================================
成功: 3/4
解释:我们用 Promise.race 做了超时控制,用 Promise.allSettled 思想(虽然是手动实现类似效果)确保部分失败不会让整个程序崩溃。
💪 进阶:常见坑 + 调试技巧
坑 1:Promise.all 遇到 reject 就全崩
// ❌ 错误示例
async function badFetch() {
const results = await Promise.all([
fetchUser(1), // 成功
fetchUser(999), // 这个用户不存在,reject
fetchUser(3), // 还没来得及跑,就被上面的错误冲掉了
]);
console.log(results); // 不会走到这里
}
// ✅ 正确示例:用 Promise.allSettled
async function goodFetch() {
const results = await Promise.allSettled([
fetchUser(1),
fetchUser(999),
fetchUser(3),
]);
// 逐个检查结果
results.forEach((r, i) => {
if (r.status === 'fulfilled') {
console.log(`用户${i+1}:`, r.value);
} else {
console.log(`用户${i+1} 失败:`, r.reason.message);
}
});
}
坑 2:race 里没有真正的「取消」
// ❌ 错误示例:以为 race 能取消失败的那个
const p1 = fetchSlowData(); // 假设这个要 10 秒
const p2 = timeout(3000);
const result = await Promise.race([p1, p2]); // 3秒后返回超时错误
// 但 p1 还在后台跑!只是没人管它了
// ✅ 正确做法:如果需要真正取消,用 AbortController
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const result = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
} catch (err) {
if (err.name === 'AbortError') {
console.log('请求被取消');
}
}
坑 3:循环里错误地创建 Promise
// ❌ 错误示例:循环里直接 await,实际上变成串行了
async function badPattern() {
for (const url of urls) {
const data = await fetch(url); // 等一个完才跑下一个!
results.push(data);
}
}
// ✅ 正确示例:先创建所有 Promise,再一起 await
async function goodPattern() {
const promises = urls.map(url => fetch(url)); // 立即开始所有请求
const results = await Promise.all(promises); // 同时等待
}
坑 4:promisify 不支持多参数回调
// ❌ 错误示例:readFile 的回调签名是 (err, data),promisify 只能取 data
const readFile = util.promisify(fs.readFile);
const data = await readFile(path, 'utf8');
// ok!
// 但如果你的回调是 (err, data1, data2) 这种多参数:
function customCallback(err, a, b, c) { ... }
const wrapped = util.promisify(customCallback);
// 只能拿到 a,b 和 c 丢失了
// ✅ 正确做法:手写 Promise 包装器
function customPromisify(fn) {
return (...args) => new Promise((resolve, reject) => {
fn(...args, (err, a, b, c) => {
if (err) reject(err);
else resolve({ a, b, c }); // 全部保留
});
});
}
调试技巧:给 Promise 加日志
// 包装器:打印每个 Promise 的开始/结束
function debugPromise(p, label) {
console.log(`[${label}] 开始`);
return p
.then(result => {
console.log(`[${label}] 完成`);
return result;
})
.catch(err => {
console.log(`[${label}] 失败: ${err.message}`);
throw err;
});
}
// 使用
await Promise.all([
debugPromise(readFile('a.txt'), '读取A'),
debugPromise(readFile('b.txt'), '读取B'),
]);
✏️ 练习题
练习 1(2 分钟):promisify 一个函数
- 输入:fs.readFile 回调风格写法
- 预期输出:改写成 await readFile(...) 形式
- 提示:const readFile = util.promisify(fs.readFile)
练习 2(2 分钟):给 race 加超时
- 输入:给 Promise.race([slowRequest, fastRequest]) 加 1 秒超时
- 预期输出:1 秒内没返回就抛出超时错误
- 提示:用 setTimeout 创建一个"必然超时"的 Promise
练习 3(3 分钟):用 allSettled 批量处理
- 输入:一个数组 [1, 2, 999],获取 ID=999 的用户会失败
- 预期输出:打印成功和失败的结果,不崩溃
- 提示:Promise.allSettled 返回 { status: 'fulfilled/rejected', value/reason }
练习 4(5 分钟):改造项目 1 支持超时
- 输入:给文件检查器加上 500ms 超时
- 预期输出:超时的文件显示「超时」而非「不存在」
- 提示:用 Promise.race 包装 stat
练习 5(3 分钟):分析这个报错
- 输入:报错 "Promise was rejected with value: undefined"
- 预期输出:指出问题原因并修复
- 提示:检查 Promise.all 里是否有没加 catch 的 Promise
作业:做一个「多源数据聚合器」
需求:从 3 个不同的免费 API 同时获取数据,汇总后打印。要求:
1. 同时发起请求(用 Promise.all)
2. 支持 2 秒超时(用 Promise.race)
3. 部分失败不影响其他数据展示(用 Promise.allSettled 思想)
4. 最后打印成功率
功能点:
- 从 JSONPlaceholder 获取 1 个用户、1 个帖子、1 个评论
- 故意访问一个不存在的 URL 模拟失败
- 超时控制要生效(可用 httpbin 的 /delay/5 并设置 2 秒超时)
加分项:
- 给每个请求显示耗时
- 用颜色区分成功/失败/超时
验收标准:能跑起来,成功率显示 2/4 或 3/4(取决于超时不超时)
📚 总结
本文学了 3 件事:
1. util.promisify 把回调风格函数变成 Promise,让代码更易读
2. Promise.all 让多个异步任务同时跑,Promise.race 实现竞速和超时
3. Promise.allSettled 确保部分失败不崩溃,收集所有任务状态
延伸资源:
- Node.js 官方文档 - util.promisify
- 《深入浅出 Node.js》——蒲宇达,写得很细
- MDN - Promise 完整指南
你在项目里遇到过「回调地狱」吗?后来怎么解决的?评论区聊聊,老粉优先回复!
下一章我们要综合运用这章学到的流控制技术,做一个并发爬虫——同时抓取多个页面,速度比串行快 10 倍。准备好你的爬虫技能了吗?

评论(0)