# 错误与调试：从回调到 async 的传播边界

## 本章解决什么问题

在 Vue 里，错误可能最终显示在浏览器控制台；在长期运行的 Node 服务里，一个被忽略的失败可能意味着任务丢失，一个未处理异常可能终止整个进程。目标不是把每段代码套进 try/catch，而是确认每个异步边界怎样报告失败，由谁决定恢复，以及失败之后资源如何释放。前置是上一章的回调调度与 Promise 执行模型。

团队助手导入文档时，可能遇到文件不存在、编码或格式不合法、业务字段缺失、保存失败等不同问题。它们需要不同处理：不存在的可选配置也许可以使用默认值，损坏的正式数据则不能悄悄当成空列表。错误处理是业务契约的一部分，不是只为消除红色日志而添加的补丁。

## 先区分错误值、失败通道与处理策略

Error 对象是携带失败信息的值，throw 是同步传播失败的一种方式，Promise rejection 是未来结果失败的状态，错误优先回调则通过参数报告失败。三者不能因为最后都拿到一个 Error 就混为一谈。调用方必须使用与被调用 API 相匹配的接收方式，否则 catch 写得再多也可能位于错误的时间边界之外。

策略又是另外一层。拿到失败后，你可以终止当前任务、转换成对外错误、尝试有限重试或使用明确允许的默认值。API 不会替你决定哪一种符合业务。不要看到 `ENOENT` 就在所有地方返回空数组：第一次启动时尚未创建的数据文件可以如此处理，已经确认存在的上传原件突然消失则可能是需要调查的问题。

系统错误通常带有 `code` 等字段，适合按稳定类别判断。错误消息面向人阅读，可能受平台、路径和版本影响，不宜依赖完整字符串做分支。反过来，不能只输出一个代码而完全丢失上下文；日志还需要知道执行哪个步骤、哪个内部资源以及哪次请求，但避免记录密码和完整私密文档。

## 同步 try/catch 的作用域是执行过程

try/catch 能捕获的是当前同步执行链上抛出的异常。安排一个定时器后，外层 try 已经执行结束；定时器以后运行时抛出的异常，不会穿越时间回到那个 catch。Promise 则可以把未来的失败表示为一个对象状态，再通过 await 或 catch 在新的执行链中处理。这是 async 写法看起来接近同步、却仍需要理解异步边界的原因。

同一个 API 还可能同时存在两类错误。参数类型不合法可能同步抛错，真正的文件读取失败则通过回调参数报告。阅读文档时要分别检查参数约束与操作失败通道。不要因为它叫异步 API 就假设调用表达式绝对不会同步抛出，也不要把所有失败都归到回调。

错误优先回调通常形如 `(error, value)`：成功时第一个参数为空值，失败时第一个参数是错误，成功值此时通常不可用。实现自己的回调 API 时必须保证完成通知只调用一次。一个分支报错后没有 return，又继续调用成功回调，会让调用方在同一操作上收到矛盾结果，这是比单次异常更难追踪的问题。

## 实验一：把文件回调转换成可等待结果

环境 Node 22.22.0，无第三方依赖。保存为 `callback-errors.mjs`。它创建自己的临时目录，分别演示成功和文件不存在，并在 finally 中清理；不会访问已有业务文件。

```js callback-errors.mjs
import { readFile } from 'node:fs';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import assert from 'node:assert/strict';

function readText(path) {
  return new Promise((resolve, reject) => {
    readFile(path, 'utf8', (error, text) => {
      if (error) { reject(error); return; }
      resolve(text);
    });
  });
}
const directory = await mkdtemp(join(tmpdir(), 'node-errors-'));
try {
  const file = join(directory, 'document.txt');
  await writeFile(file, '团队文档', 'utf8');
  assert.equal(await readText(file), '团队文档');
  await assert.rejects(readText(join(directory, 'missing.txt')), { code: 'ENOENT' });
  console.log('读取成功与 ENOENT 失败均已验证');
} finally {
  await rm(directory, { recursive: true, force: true });
}
```

执行 `node callback-errors.mjs`，预期打印验证通过。包装函数的 Promise 执行器立即运行，真正的读取结果稍后进入回调；错误分支 reject 并 return，成功分支 resolve。外层 await 等待的是这个明确的完成协议，而不是等待 readFile 的直接返回值。直接写 `await readFile(...)` 不会自动把回调版 API 转成 Promise 版。

Node 已经提供 `node:fs/promises` 的读取函数，真实新代码通常直接使用它。本例手写包装是为了看清协议，不是鼓励给所有内置 API 重复造轮子。`util.promisify` 也可以转换遵守标准错误优先、末参数回调约定的函数；如果方法依赖 this，绑定方式还需要保留；如果回调有多个成功值或特殊规则，也不能机械套用。

这里的 `assert.rejects` 接收 Promise 或返回 Promise 的函数，等待它拒绝并检查错误条件。如果操作反而成功，断言失败；这正好避免测试只覆盖正常路径。同步函数的抛错应使用 `assert.throws`，二者的区别对应两条不同的失败通道。

## await、return 与被遗忘的 Promise

async 函数总会返回 Promise。函数内部的 throw 会使该 Promise 拒绝，调用方可以 await 它并用 try/catch 处理。若调用方既不 await，也不 return，更没有 catch，失败就可能成为未处理拒绝。把调用写在 try 内部但不等待，仍然无法让 try 捕获以后发生的拒绝。

`return somePromise` 会把返回状态交给调用方，适合单纯传递结果。若当前函数要在自己的 catch 中转换异步失败，就需要在那个 try 作用域里 await。不要把 `return await` 一概视为多余，也不要在所有地方无条件添加；先问当前层是否需要观察失败或在 finally 中等待相关生命周期。

`Promise.all` 可以并发等待独立工作，并在某个输入拒绝时使整体拒绝，但不会自动取消其他已经开始的工作。`Promise.allSettled` 则等待全部结束，返回每项成功或失败的记录。团队助手批量导入独立文档时，可能需要汇总部分成功；创建一个必须全部成功的组合任务时，则需要更明确的一致性策略。选择组合器应该来自结果语义，而不是只看哪个写起来短。

## 实验二：错误转换保留真正原因

保存为 `error-cause.mjs`，无依赖。它把底层 JSON 语法失败转换成“文档解析失败”，同时保留 cause，使调用方既能得到稳定业务分类，也能追查原始错误。

```js error-cause.mjs
import assert from 'node:assert/strict';

class DocumentError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = 'DocumentError';
    this.code = 'DOCUMENT_INVALID';
  }
}
async function parseDocument(text) {
  try {
    const value = JSON.parse(text);
    if (!value || typeof value.title !== 'string' || value.title.trim() === '') {
      throw new TypeError('缺少合法标题');
    }
    return { title: value.title.trim() };
  } catch (cause) {
    throw new DocumentError('文档内容不符合导入约定', { cause });
  }
}
const results = await Promise.allSettled([
  parseDocument('{"title":"  计划  "}'),
  parseDocument('{broken'),
  parseDocument('{"title":""}')
]);
assert.equal(results[0].value.title, '计划');
assert.equal(results[1].reason.cause.name, 'SyntaxError');
assert.equal(results[2].reason.cause.name, 'TypeError');
console.log(results.map((item) => item.status).join(', '));
```

执行 `node error-cause.mjs`，预期 `fulfilled, rejected, rejected`。类构造器把 cause 交给 Error 的选项对象，原始错误对象没有被压成字符串；业务 code 由本例自己定义，不是假装 Node 内置了这个错误码。结果数组保留输入位置，即使不同任务完成时间不同，也能把错误对应回原始文档。

本例只在一个明确边界转换一次错误。若每经过一个函数都包装一次“操作失败”，堆栈和原因链会变得冗长，却没有增加信息。高层应添加它独有的语义，例如导入步骤和文档编号；低层已经提供的系统错误细节不需要反复复制。对外响应通常只返回安全的业务信息，内部日志保留原因链以便诊断。

## 调试从可重放的最小输入开始

错误堆栈告诉你异常被创建或抛出时的调用关系，但它不是完整的请求历史。跨异步边界的上下文、外部服务响应以及重试次数，需要另外记录。先保存可以复现的最小输入、准确命令和版本，再缩小失败层次，比在整个应用中到处添加日志更有效。

`node --inspect-brk file.mjs` 会在启动时等待调试器，适合从入口逐步观察；默认调试接口应保持本机访问，不要为了方便把它暴露到公网。断点能查看当前作用域、调用栈和对象值，但暂停本身会改变时序，因此并发故障不能只凭一次单步结果下结论。调试结束后关闭调试进程，避免把调试接口留成长期服务。

`node --trace-uncaught file.mjs` 可以在未捕获异常时增加跟踪信息；它不负责恢复程序。日志里优先记录错误对象及其 code、cause 和任务标识，避免只写 `String(error)` 后失去结构。若业务必须输出 JSON 日志，需要自己明确序列化字段，因为 Error 的许多属性并不是普通可枚举属性。

未捕获异常意味着某条失败路径已经逃出了设计的处理边界，进程内部状态可能不再可信。安装一个全局处理器后继续接请求，不是通用恢复方案。更稳妥的服务策略是记录必要诊断、停止接收新工作，并由外部进程管理器重新启动；这与在某个已知、可恢复的业务操作上捕获错误是不同层级。

## 清理错误不能悄悄遮住原始错误

finally 适合释放文件句柄、移除监听器或结束临时资源，因为成功与失败都会经过它。但 finally 中的新异常可能覆盖原来的失败，return 也可能改变原本结果。清理本身可能失败时，应决定怎样同时保留主失败与清理失败，而不是让最后发生的错误自动成为唯一线索。

本章临时目录清理采用明确的独占目录，因此不会误删用户数据。真实上传处理中，资源可能分阶段获得：文件打开了但数据库事务还没开始，或数据库保存成功但响应发送失败。应为每个阶段标记已经拥有的资源，并让清理操作只作用于那些资源。简单地在 catch 中“全部删除”可能把已经提交的业务结果一起破坏。

重试也需要有上限、有条件。语法错误不会因为等一秒就变合法，权限错误通常需要配置变更；短暂网络失败可能值得重试，但写操作必须先确认重复执行是否安全。后续事务章节讲的幂等性，就是把重复请求的效果控制住，而不是简单在 catch 中再次调用。

## 把错误契约接到前端交互

团队助手的前端需要知道的是用户接下来能做什么，而不是服务器内部用了哪个文件函数。输入缺少标题可以对应字段提示，文档解析不支持可以提示更换格式，临时服务不可用可以提供重试入口。后端应把稳定错误分类交给前端，把堆栈和内部路径留在受控日志中。直接把任何异常的 message 原样返回，可能既难以本地化，又暴露内部信息。

状态码、业务代码和日志标识分别承担不同职责。状态码帮助通用客户端理解大类结果，业务代码表达更具体的产品语义，日志标识帮助开发者把一次用户反馈对应到内部记录。不要让前端通过匹配中文提示文字来判断下一步操作，否则改一句文案就可能改变控制流程。

错误响应也需要保持一致的结构。若某些分支返回对象，另一些分支返回普通字符串，前端就会在最需要可靠处理的失败路径上增加猜测。先约定成功与失败的边界，再统一序列化；不要在每一个 catch 中各写一套格式。这个边界将在完整 HTTP 服务中集中实现。

## 用故障注入检查自己真的处理了失败

除了给损坏输入，还可以让一个可注入的依赖按约定失败。例如任务服务接收 `save` 函数，验证时传入一个返回拒绝 Promise 的实现，就能确认服务不会发送成功通知。这里替换的是明确依赖，不是随意修改全局模块对象。这样的实验成本低，也能保持错误发生点稳定。

验证失败路径时同时检查副作用。保存失败后任务计数有没有增加，监听器有没有留下，文件句柄有没有关闭，调用方有没有收到可解释的错误。只断言“抛了一个异常”仍然可能放过状态已经部分修改的问题。尤其当你在 catch 中进行了补偿，更需要证明补偿范围没有超出本次操作拥有的资源。

还要检查错误是否被重复报告。一个底层函数记录日志后重新抛出，上层又记录一次，最后全局边界再记录一次，可能把同一次失败表现成三次事故。通常由了解完整请求上下文的边界记录最终失败，底层保留结构化信息供上层使用；若确实需要阶段日志，应能通过同一标识把它们关联起来。

## 原理在现代写法中的延续

早期 Node 程序经常用错误优先回调表达异步失败，现代代码更多使用 Promise 与 async。变化的是接口形式，仍然成立的原则是：异步操作必须明确报告完成，调用方必须处理失败，每个操作的结果只能结算一次。不能因为语法更接近同步，就重新忽略任务取消、资源所有权和多次回调等底层问题。

回调实现中的“调用两次”到了 Promise 包装后可能表面消失，因为 Promise 只接受第一次结算，但底层重复执行的副作用并不会因此自动消失。包装可以保护结果状态，却不能修复错误的业务实现。排查时既看上层 Promise，也要看触发它的事件、回调或外部操作是否遵守一次完成的契约。

## 练习：区分可选文件缺失与损坏文件

编写 `readOptionalJSON(path)`：文件不存在时返回 `null`，合法 JSON 返回解析值，其他读取错误和 JSON 解析错误都必须向上传播。完整验证应包含缺失、合法和损坏三种输入。提示：只在读取步骤捕获 `ENOENT`，不要把整个函数的所有异常都转换成 null。

<details><summary>参考答案：完整临时文件实验</summary>

```js optional-json.mjs
import { readFile, writeFile, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import assert from 'node:assert/strict';

async function readOptionalJSON(path) {
  let text;
  try { text = await readFile(path, 'utf8'); }
  catch (error) {
    if (error.code === 'ENOENT') return null;
    throw error;
  }
  return JSON.parse(text);
}
const directory = await mkdtemp(join(tmpdir(), 'optional-json-'));
try {
  const file = join(directory, 'state.json');
  assert.equal(await readOptionalJSON(file), null);
  await writeFile(file, '{"tasks":2}', 'utf8');
  assert.deepEqual(await readOptionalJSON(file), { tasks: 2 });
  await writeFile(file, '{broken', 'utf8');
  await assert.rejects(readOptionalJSON(file), SyntaxError);
  console.log('缺失、合法、损坏三种边界通过');
} finally {
  await rm(directory, { recursive: true, force: true });
}
```

执行 `node optional-json.mjs`，预期通过。这个函数表达“可选配置”的语义；若用于必须存在的正式数据文件，就应删除缺失默认分支或者换一个明确命名的接口，不能因函数方便而改变业务要求。

</details>

## 验收与自测

验收要求三个实验都成功，并且你能指出每处失败通过什么通道传播。把练习的损坏内容改成合法内容时，针对 SyntaxError 的断言应失败，这证明测试确实检查了错误路径。恢复损坏输入后再运行通过。自测一：try 中调用 async 函数却不等待，能捕获未来拒绝吗？答案：不能。自测二：为什么不按完整错误消息匹配文件不存在？答案：消息可能随环境变化，应使用相应稳定错误代码。自测三：catch 后只打印错误并返回空数组是否一定正确？答案：不一定，它改变了失败语义，可能让调用方误认成功。

判断一段错误处理是否完成，最终要看调用方是否能够采取正确动作，以及失败后系统状态是否仍符合约定。没有红色日志只是表面现象，不能替代对错误结果、资源释放和副作用边界的验证。

## 本章实际验证范围

三个文件实际运行通过，覆盖读取 ENOENT、语法与字段原因链、缺失和损坏文件的不同处理。未运行交互式调试器或故意留下未处理异常；相应命令用于后续自行调试。

## 官方参考

错误类别、系统错误与传播见 [Errors](https://nodejs.org/docs/latest-v24.x/api/errors.html)；回调转换见 [util.promisify](https://nodejs.org/docs/latest-v24.x/api/util.html#utilpromisifyoriginal)；异步断言见 [Assert](https://nodejs.org/docs/latest-v24.x/api/assert.html)；调试入口见 [Node 调试指南](https://nodejs.org/en/learn/getting-started/debugging)。
