Skip to content

错误、调试与回归:保留足够的信息找到原因 ​

用户说“保存失败”,程序只打印“出错了”。这样的日志像一张写着“东西坏了”的维修单,诚实,但几乎无法定位。错误处理要回答:失败发生在哪一层、调用方能否恢复、原始原因是否保留。

从解析失败走到有业务含义的错误 ​

js
class ReadingError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = 'ReadingError';
  }
}
function parseReading(text) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (cause) {
    throw new ReadingError('阅读记录不是合法 JSON', { cause });
  }
  if (!data || typeof data !== 'object' ||
      typeof data.title !== 'string') {
    throw new ReadingError('阅读记录缺少文本标题');
  }
  return data.title;
}
try {
  parseReading('{');
} catch (error) {
  console.log(error.name); // ReadingError
  console.log(error.cause instanceof SyntaxError); // true
}

外层消息说明在做什么,cause 保留底层解析错误。不是每层都需要包装:只有增加有用上下文时才做,否则只是制造一串“失败导致失败”。输入合法性错误、网络暂时不可用和程序内部 bug,也不应该一律变成空数组继续运行。

JavaScript 可以 throw 任意值,所以处理外部代码抛出的结果时不能无条件假定有 message。自己编写的接口尽量抛 Error 实例,便于携带栈和原因。

try/catch 只覆盖其执行链 ​

js
async function main() {
  try {
    await Promise.reject(new Error('异步失败'));
  } catch (error) {
    console.log(error.message); // 异步失败
  }
}
await main();

这里 await 将拒绝重新表现为当前 async 函数中的抛出,因此 catch 能接到。若只创建 Promise 却不 await/return 它,try 块可能早已结束。setTimeout 回调后来抛出的异常,也不会穿越时间回到调度它的旧 try 块中。

finally 无论正常返回还是抛出都用于收尾,但里面的 return 或 throw 会覆盖原来的完成结果:

js
function misleading() {
  try { throw new Error('真正的问题'); }
  finally { return '看起来成功'; }
}
console.log(misleading()); // 看起来成功

因此清理代码一般不要在 finally 返回业务结果。Promise.finally 通常透传原来的结果,但回调如果抛错或返回拒绝 Promise,也会改变最终结果。

一次具体调试:总和为何变成 105 ​

下面是一个没有抛异常却算错的程序。直接在 .mjs 或浏览器控制台运行:

js
function sumPrices(rows) {
  let total = 0;
  for (const row of rows) {
    total += row.price;
  }
  return total;
}
console.log(sumPrices([{ price: 10 }, { price: '5' }])); // 105

业务期望 15,实际结果却是字符串 '105'。先不要在函数最后加 Number:Number('105') 只会得到错误的数字 105,丢失正确相加的机会。错误已经发生在第二次 +=。

调试过程可以具体到每一步:

  1. 在 total += row.price 行设置断点,第一次暂停查看 total、row.price 及各自 typeof:都是数字。
  2. 继续到第二次暂停,total 为 10,row.price 为字符串 '5'。观察已经推翻了“所有价格字段都是数字”的假设。
  3. 单步执行该行:加号遇到字符串参与连接,total 变成 '105'。后面的显示层只是忠实展示错误结果,不是根因。
  4. 回到数据进入系统的位置,确定契约:允许数字字符串并显式解析,还是拒绝它?必须选一种,不能让隐式转换代替决定。

数据很长时,可设条件断点 typeof row.price !== 'number',直接停在第一条违反假设的记录,而不是输出几千行日志。若错误只有特定输入才出现,保留那条最小输入;先把问题缩到两个值,比在整个页面里盲目追踪更容易。

下面采用“核心计算只接受非负安全整数分”的约定:

js
function sumCents(rows) {
  let total = 0;
  for (const [index, row] of rows.entries()) {
    if (!row || !Number.isSafeInteger(row.price) || row.price < 0) {
      throw new TypeError(`第 ${index + 1} 条价格不是非负安全整数分`);
    }
    total += row.price;
    if (!Number.isSafeInteger(total)) throw new RangeError('合计超出安全整数');
  }
  return total;
}
console.log(sumCents([{ price: 10 }, { price: 5 }])); // 15
try { sumCents([{ price: 10 }, { price: '5' }]); }
catch (error) { console.log(error.message); } // 第 2 条价格不是非负安全整数分

如果表单输入天然是字符串,可在输入边界解析并校验,再交给该函数。直接在任何地方都 Number(value) 还会把空字符串变成 0、把某些非法值变成 NaN,因此转换不是完整验证。

调用栈与异常暂停各回答什么 ​

调用栈说明当前路径怎样到达这里;异常暂停能停在真正 throw 的位置,包括稍后被 catch 吞掉的异常。需要追踪“程序明明没报错却进入兜底”时,可启用捕获异常也暂停,而不是只看最终控制台。

异步等待后,工具能展示多少异步因果关系取决于环境。error.stack 的格式也不是跨引擎稳定协议,不能用固定字符串切割把它当业务输入。压缩后的生产代码需要适当的 source map 才能方便对应源文件;发布时需明确映射是否暴露源码。

日志应服务于区分假设:本例记录值与 typeof 就有帮助,记录完整用户资料并不会让加号更容易理解。保留必要输入和状态,避免密码、令牌等与诊断无关的敏感内容。

用最小失败输入建立回归 ​

如果发现 parseReading('null') 导致不清楚的属性访问错误,就把它缩成一个测试。测试应说明期待的业务错误,而不是锁定引擎偶然的报错文字。

js
// 放在定义 parseReading 和 ReadingError 的同一 .mjs 文件尾部运行
import assert from 'node:assert/strict';
assert.equal(parseReading('{"title":"事件"}'), '事件');
assert.throws(() => parseReading('null'), ReadingError);
assert.throws(() => parseReading('{'), error =>
  error instanceof ReadingError && error.cause instanceof SyntaxError);
console.log('回归断言通过');

断言通过说明这些明确输入的契约成立,不意味着所有输入都被证明正确。记录修复触发条件、保留最小复现,下一次修改才有可复用的证据。

练习 ​

  1. 为什么 finally 中的 return 会掩盖错误?应把清理代码怎样放置?

  2. 为缺少 title 的合法 JSON 增加回归断言,并说明它与 JSON 语法错误的区别。

  3. sumPrices得到字符串105时,在return前加Number为什么不能修好?应该在哪个位置观察什么值?

查看本节答案

用心记录每一步成长