4. 回调函数模式

Node.js 的异步能力最初几乎完全建立在回调函数之上。理解回调模式,不只是为了读懂老代码,更是为了理解异步控制流为什么需要被重构。回调函数本质上是“把一段后续逻辑作为参数传给另一个函数”,由被调用方在合适的时机执行。在 Node.js 中,这个“合适时机”通常对应 I/O 完成、定时器到期或事件触发。

先看一个最小示例。下面的代码读取一个文件,并在读取完成后打印内容:

const fs = require('fs');

fs.readFile('./config.json', 'utf8', (err, data) => {
  if (err) {
    console.error('读取失败:', err.message);
    return;
  }
  console.log('文件内容:', data);
});

这里出现了 Node.js 回调模式中最重要的约定:错误优先回调(Error-First Callback)。回调函数的第一个参数固定为错误对象,后续参数才是成功结果。调用方必须先判断 err 是否存在,再处理 data。这个约定让错误处理成为回调协议的一部分,而不是靠返回值或全局状态判断。

错误优先回调的通用签名是:callback(err, result1, result2, ...)。约定要求:成功时 err 为 null 或 undefined;失败时 err 为 Error 实例,且后续结果参数通常不再有意义。

为什么必须坚持这个约定?因为异步操作的结果无法通过 return 返回,也无法通过 try/catch 直接捕获。如果不用第一个参数传递错误,调用方就只能在回调内部自行猜测状态,或者依赖额外的全局变量,代码会迅速失控。错误优先回调把“成功路径”和“失败路径”统一到同一个函数签名里,使调用方可以稳定地写出判断逻辑。

当多个异步操作需要按顺序执行时,回调嵌套会自然出现。例如:读取配置 → 根据配置读取数据库连接信息 → 连接数据库 → 查询用户 → 写入日志。每一步都依赖上一步的结果,于是代码变成层层缩进的“金字塔”:

fs.readFile('./config.json', 'utf8', (err, configData) => {
  if (err) return handleError(err);
  const config = JSON.parse(configData);

  fs.readFile(config.dbPath, 'utf8', (err, dbData) => {
    if (err) return handleError(err);
    const dbConfig = JSON.parse(dbData);

    db.connect(dbConfig, (err, conn) => {
      if (err) return handleError(err);

      conn.query('SELECT * FROM users WHERE id = ?', [1], (err, rows) => {
        if (err) return handleError(err);

        fs.appendFile('./access.log', `query ok\n`, (err) => {
          if (err) return handleError(err);
          console.log('完成:', rows);
        });
      });
    });
  });
});

这就是通常所说的回调地狱(Callback Hell)。它的核心问题不是“缩进太深”这一视觉现象,而是控制流被切碎后带来的实际维护困难:错误处理重复且容易遗漏;每一步的上下文变量散落在不同层级;无法用 return 提前结束整个流程;循环、条件分支与异步回调混合时,执行顺序难以推理;测试时很难单独隔离某一步。

下面这张图展示了回调地狱中控制流与错误路径的典型结构。可以看到,成功路径不断向右下延伸,而每个节点都挂着一个独立的错误出口,错误处理逻辑被重复分散在每一层。

回调地狱:嵌套控制流与分散的错误出口 readFile(config) 读取配置 readFile(dbPath) 读取数据库配置 db.connect() 建立连接 conn.query() 查询用户 appendFile(log) 写入日志 handleError(err) handleError(err) handleError(err) handleError(err) handleError(err) 蓝色实线:成功路径逐层嵌套 | 红色虚线:每层独立且重复的错误出口

要重构这种代码,第一步不是立刻引入 Promise 或 async/await,而是先把回调逻辑“拍平”。一个实用做法是:把每一层回调提取成具名函数,并统一错误处理入口。例如把上面的流程拆成 loadConfig、loadDbConfig、connectDb、queryUser、writeLog 五个函数,每个函数只负责一件事,并遵循错误优先回调签名。这样嵌套深度从五层降到一层,每个函数都可以单独测试。

第二步是统一错误处理。不要在每一层重复写 if (err) return handleError(err),而是让每个具名函数在出错时把错误交给同一个回调,由最外层决定如何响应。例如:

function loadConfig(cb) {
  fs.readFile('./config.json', 'utf8', (err, data) => {
    if (err) return cb(err);
    try {
      cb(null, JSON.parse(data));
    } catch (e) {
      cb(e);
    }
  });
}

function loadDbConfig(config, cb) {
  fs.readFile(config.dbPath, 'utf8', (err, data) => {
    if (err) return cb(err);
    try {
      cb(null, JSON.parse(data));
    } catch (e) {
      cb(e);
    }
  });
}

注意这里对 JSON.parse 使用了 try/catch。因为 JSON.parse 是同步操作,它抛出的异常不会被回调捕获,必须显式转换成错误优先回调中的 err。这是回调模式中一个常见陷阱:同步异常必须手动转换为异步错误,否则会直接冒泡到事件循环之外。

在回调函数内部抛出的同步异常,不会自动传给外层回调。凡是可能抛异常的同步代码(如 JSON.parse、类型转换、自定义校验),都应放在 try/catch 中,并把捕获到的错误通过 cb(err) 传出。

第三步是控制并发与顺序。回调模式下,顺序执行靠嵌套,并发执行靠计数器或第三方库。例如同时读取三个文件,可以用一个简单的计数器收集结果:

function readAll(paths, cb) {
  const results = new Array(paths.length);
  let pending = paths.length;
  let failed = false;

  paths.forEach((p, i) => {
    fs.readFile(p, 'utf8', (err, data) => {
      if (failed) return;
      if (err) {
        failed = true;
        return cb(err);
      }
      results[i] = data;
      pending -= 1;
      if (pending === 0) cb(null, results);
    });
  });
}

这段代码体现了回调模式下的两个关键判断:一是用 failed 标志防止多次回调;二是用 pending 计数判断全部完成。如果不加 failed,第一个错误之后仍可能有其他回调触发 cb,导致外层逻辑被执行多次。这类“回调只能被调用一次”的约束,是回调风格代码必须自行保证的。

重构到这一步,代码已经比原始嵌套清晰很多,但仍然存在局限:错误处理仍需手动传递,流程组合能力弱,无法像 Promise 那样使用 Promise.all 或 catch 统一捕获。因此,回调模式是理解异步的起点,而不是终点。掌握错误优先约定、具名函数拆分、同步异常转换和一次性回调保护之后,再过渡到 Promise 与 async/await,才能真正理解 Node.js 异步演进的原因。

最后给出一个可复用的检查清单,用于审查回调风格代码是否可维护:

检查项 合格标准 常见问题
回调签名 第一个参数为 err,成功时为 null 把结果放在第一个参数
错误处理 每个异步回调都判断 err 并提前 return 遗漏 err 判断,继续使用无效数据
同步异常 JSON.parse 等同步操作包在 try/catch 中 异常直接抛出,绕过回调
回调次数 保证成功或失败只调用一次 cb 错误后仍继续调用 cb
嵌套深度 通过具名函数拆分,嵌套不超过两层 五层以上金字塔缩进

回调函数模式是 Node.js 异步编程的历史基石。它用“错误优先”这一简单约定,解决了异步结果与错误传递的问题;但当流程变长、分支变多时,嵌套结构会显著增加维护成本。把嵌套回调重构为具名函数、统一错误出口、保护一次性回调,是走向可维护异步代码的第一步,也是后续理解 Promise 和 async/await 的必要铺垫。