第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。

配图1 - 配图1

什么时候用

  • 接手老项目,看到 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);  // 超时时会走到这里
}
}

配图2 - 配图2


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 倍。准备好你的爬虫技能了吗?

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。