错误与调试:从回调到 async 的传播边界
辨认同步抛错、错误优先回调与 Promise 拒绝的不同通道,保留错误因果,并建立可以重放的调试与失败验收方法。
建议先读:事件循环原理:谁在执行,谁在等待
本页内容
本章解决什么问题#
在 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 中清理;不会访问已有业务文件。
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,使调用方既能得到稳定业务分类,也能追查原始错误。
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。
参考答案:完整临时文件实验
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,预期通过。这个函数表达“可选配置”的语义;若用于必须存在的正式数据文件,就应删除缺失默认分支或者换一个明确命名的接口,不能因函数方便而改变业务要求。
验收与自测#
验收要求三个实验都成功,并且你能指出每处失败通过什么通道传播。把练习的损坏内容改成合法内容时,针对 SyntaxError 的断言应失败,这证明测试确实检查了错误路径。恢复损坏输入后再运行通过。自测一:try 中调用 async 函数却不等待,能捕获未来拒绝吗?答案:不能。自测二:为什么不按完整错误消息匹配文件不存在?答案:消息可能随环境变化,应使用相应稳定错误代码。自测三:catch 后只打印错误并返回空数组是否一定正确?答案:不一定,它改变了失败语义,可能让调用方误认成功。
判断一段错误处理是否完成,最终要看调用方是否能够采取正确动作,以及失败后系统状态是否仍符合约定。没有红色日志只是表面现象,不能替代对错误结果、资源释放和副作用边界的验证。
本章实际验证范围#
三个文件实际运行通过,覆盖读取 ENOENT、语法与字段原因链、缺失和损坏文件的不同处理。未运行交互式调试器或故意留下未处理异常;相应命令用于后续自行调试。
官方参考#
错误类别、系统错误与传播见 Errors;回调转换见 util.promisify;异步断言见 Assert;调试入口见 Node 调试指南。