异步编程模式:回调函数、Promise、async/await 与错误处理最佳实践

说实话,Node.js 最让我着迷的地方,就是它的异步机制。我刚接触 Node 那会儿,也被回调地狱折磨过。后来慢慢摸透了 Promise 和 async/await,才真正体会到什么叫「写得爽、跑得快」。今天我就把这几年的实战心得,掰开了揉碎了讲给你听。

回调函数:最朴素的异步方案

回调函数是 Node.js 异步编程的基石。说白了,就是把一个函数作为参数传给另一个函数,等异步操作完成后再调用它。

const fs = require('fs');

// 读取文件,完成后执行回调
fs.readFile('/etc/passwd', (err, data) => {
  if (err) {
    console.error('读文件出错了:', err);
    return;
  }
  console.log(data.toString());
});

我在项目中遇到过最头疼的问题,就是回调嵌套太深。三层五层还好,一旦超过七层,代码基本就没法维护了。你想想看,每个回调里又套着回调,缩进越来越深,逻辑越来越乱——这就是著名的「回调地狱」。

注意:回调函数虽然简单直接,但嵌套层级超过 3 层时,建议立即重构。我曾经在一个老项目里看到过 12 层嵌套的回调,改一个 bug 花了整整两天。

Promise:让异步变得优雅

Promise 的出现,算是救了广大 Node.js 开发者一命。它把异步操作包装成一个对象,用 .then().catch() 来处理结果和错误。

const fs = require('fs').promises;

fs.readFile('/etc/passwd')
  .then(data => {
    console.log(data.toString());
  })
  .catch(err => {
    console.error('读文件出错了:', err);
  });

我个人习惯用 Promise 来封装那些基于回调的旧 API。比如 Node.js 自带的 util.promisify,一行代码就能把回调风格的函数转成 Promise 风格:

const util = require('util');
const fs = require('fs');

const readFile = util.promisify(fs.readFile);

readFile('/etc/passwd')
  .then(data => console.log(data.toString()))
  .catch(err => console.error(err));
核心要点:Promise 有三种状态——pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦改变,就不可逆转。这是它比回调更可靠的根本原因。

async/await:同步写法,异步执行

async/await 是 Promise 的语法糖。它让我们可以用写同步代码的方式,来写异步逻辑。嗯,这里要注意:它本质上还是异步的,只是写法变好看了。

const fs = require('fs').promises;

async function readConfig() {
  try {
    const data = await fs.readFile('/etc/config.json');
    const config = JSON.parse(data);
    console.log('配置加载成功:', config);
    return config;
  } catch (err) {
    console.error('配置加载失败:', err);
    throw err;
  }
}

// 调用
readConfig().then(config => {
  // 使用配置
});

我建议你在新项目里优先使用 async/await。为什么?因为它的错误处理更直观,代码可读性也更好。你想想看,用 try/catch 包裹异步代码,跟处理同步错误一模一样,多省心。

小技巧:多个互不依赖的异步操作,可以用 Promise.all() 并行执行,能大幅提升性能。比如同时读取三个文件:
const [data1, data2, data3] = await Promise.all([
  fs.readFile('file1.txt'),
  fs.readFile('file2.txt'),
  fs.readFile('file3.txt')
]);

错误处理最佳实践

异步编程里,错误处理是最容易翻车的地方。我曾经因为漏掉一个 .catch(),导致线上服务挂了整整 15 分钟。从那以后,我总结了一套自己的错误处理规范。

1. 永远不要吞掉错误

// ❌ 错误:吞掉了异常
async function badExample() {
  try {
    await riskyOperation();
  } catch (err) {
    // 什么都不做
  }
}

// ✅ 正确:至少记录日志
async function goodExample() {
  try {
    await riskyOperation();
  } catch (err) {
    console.error('操作失败:', err);
    // 或者重新抛出
    throw err;
  }
}

2. 区分操作错误和程序员错误

错误类型 示例 处理方式
操作错误 文件不存在、网络超时 重试、降级、返回友好提示
程序员错误 类型错误、空指针 立即崩溃,修复代码

我个人习惯用自定义错误类来区分:

class OperationalError extends Error {
  constructor(message) {
    super(message);
    this.name = 'OperationalError';
    this.isOperational = true;
  }
}

class ProgrammingError extends Error {
  constructor(message) {
    super(message);
    this.name = 'ProgrammingError';
    this.isOperational = false;
  }
}

3. 全局错误处理

对于 Node.js 应用,一定要设置全局的错误处理钩子:

// 未捕获的 Promise 异常
process.on('unhandledRejection', (reason, promise) => {
  console.error('未处理的 Promise 拒绝:', reason);
  // 记录日志、发送告警
});

// 未捕获的同步异常
process.on('uncaughtException', (err) => {
  console.error('未捕获的异常:', err);
  // 记录日志后优雅退出
  process.exit(1);
});
避坑指南:我曾经在生产环境里忘记加 unhandledRejection 监听,结果一个 Promise 错误导致整个进程挂掉。从那以后,每个项目我都会加上这个钩子。记住:所有 Promise 都必须有错误处理

实战对比:三种模式写同一个功能

咱们用同一个场景——读取两个文件并合并内容——来对比三种写法:

// 1. 回调模式(不推荐)
fs.readFile('a.txt', (err, dataA) => {
  if (err) return console.error(err);
  fs.readFile('b.txt', (err, dataB) => {
    if (err) return console.error(err);
    console.log(dataA.toString() + dataB.toString());
  });
});

// 2. Promise 模式(推荐)
fs.readFile('a.txt')
  .then(dataA => fs.readFile('b.txt').then(dataB => dataA + dataB))
  .then(result => console.log(result.toString()))
  .catch(err => console.error(err));

// 3. async/await 模式(最推荐)
async function mergeFiles() {
  try {
    const [dataA, dataB] = await Promise.all([
      fs.readFile('a.txt'),
      fs.readFile('b.txt')
    ]);
    console.log(dataA.toString() + dataB.toString());
  } catch (err) {
    console.error('合并文件失败:', err);
  }
}

你看,同样的功能,async/await 的代码量最少,可读性也最好。而且用 Promise.all 并行读取,性能也比串行读取好得多。

我的建议:
  • 新项目:全部用 async/await + Promise
  • 老项目改造:逐步用 Promise 替换回调
  • 第三方库:如果只提供回调接口,用 util.promisify 包装
  • 错误处理:每个 async 函数都要有 try/catch

最后说一句:异步编程没有银弹。回调、Promise、async/await 各有各的适用场景。关键是要理解它们的本质——都是用来管理异步操作的流程控制工具。选最适合你当前场景的那个,别为了炫技而用。