错误处理

解释JavaScript异常如何跨越同步与Promise边界,并给出精确恢复、资源清理、错误包装和聚合的方法。

难度 进阶 时长 标准深度约12分钟
版本 Node 24
what

JavaScript错误处理把失败的操作,与能够恢复、转换、报告或继续传播该失败的 代码分开。

trap

try...catch只能观察当前同步执行中的抛出,以及在try块内确实等待的 Promise所产生的拒绝。

fix

在恢复策略明确的边界捕获错误,抛出Error对象,用cause保留原始失败, 并把清理逻辑放在finally中。

是什么,为什么存在

错误处理是一套控制流协议,用于处理无法产生正常结果的操作。函数不再返回, 而是抛出一个值;JavaScript随即把控制权转移给最近的catch。如果当前调用链中 没有处理器,异常就会到达宿主环境。

Error对象是约定俗成的失败值。它的标准数据包括name、message和可选的 cause;运行时通常还会提供非标准的stack,供诊断使用。TypeError、 RangeError、ReferenceError和SyntaxError等内置子类区分了宽泛的语言级 失败类别。

JavaScript在技术上允许用throw抛出任何值。因此,在检查之前,必须把catch 绑定视为未知值。应用代码仍应约定抛出Error,因为它以工具和人都熟悉的结构 携带类型、上下文以及通常可用的堆栈跟踪(stack trace)。

失败操作与普通分支

当操作无法履行契约,而直接调用方又未必知道恢复策略时,应使用异常。配置语法 无效、不变量被破坏或依赖调用失败,都符合这个模型。对于“没有搜索结果”这类正常 且预期内的备选结果,使用undefined、null或显式结果对象通常更清楚。

两者的区别取决于API契约,而不是事件发生的频率。同一个记录缺失,在某一层可以是 预期的查询结果,在另一层却可能意味着必要条件失败。应在拥有策略的边界转换这两种 含义,不要让每个底层函数都决定如何通知用户。

每个捕获边界的四项决策

有效的处理器会明确作出一项决策:用契约规定的值恢复,按有界策略重试,在保留原因的 同时转换失败,或先完成本地工作再重新抛出。只写日志不等于恢复。捕获错误后继续使用 临时编造的值,会悄悄改变函数契约。

在策略允许的范围内,try区域应尽量小。过宽的代码块可能把后续代码中的编程错误, 误判成预期的输入或依赖失败。范围收窄后,处理器究竟在描述哪个操作也会更清楚。

工作原理

抛出与栈展开

执行throw value会产生突然完成(abrupt completion)。JavaScript停止执行当前 代码块中的剩余语句,并沿活跃调用向外寻找catch。这种 错误传播(error propagation) 会保留抛出的原值,不会自动把字符串、对象或其他值转换为Error实例。

找到处理器后,它的绑定会接收被抛出的原值。处理器正常结束时,执行从整个 try...catch...finally语句之后继续。如果处理器重新抛出,搜索就会带着新值或 原值继续向外进行。

图中有两个入口会进入失败处理。同步抛出会立即展开当前调用。被拒绝的Promise则通过 自身的敲定状态报告失败,之后的await或拒绝处理器才是恢复普通异常式控制流的 位置。

try、catch与finally的职责

try块包含受同一项处理策略约束的操作。存在finally时可以省略catch;不需要 错误值时,也可以省略catch参数。JavaScript没有条件捕获的专用语法,应先检查值, 处理自己负责的情况,再重新抛出其他情况。

无论前面的路径是正常结束、返回、跳出循环还是抛出,finally都会在控制权离开整个 结构前运行。这里适合释放资源或恢复局部状态。但它不能保证在进程关闭时运行;强制 终止可能让JavaScript没有机会继续执行代码。

finally内的return或throw会替换已经在进行的完成结果。因此,它既可能丢掉 有效返回值,也可能丢掉正在传播的异常。清理代码通常应正常结束,让原来的结果继续 存在。

Promise拒绝是另一条通道

async函数总会返回Promise。函数体任意位置的抛出都会拒绝该Promise,包括第一个 await之前的抛出。调用方要么在try...catch中等待Promise,要么把它返回给其他 所有者,要么附加拒绝处理器。

在try块中调用异步函数却不使用await,不会把它稍后的拒绝连接到这个catch。 调用本身成功产生Promise,随后同步控制流离开代码块,Promise才完成敲定。缺失的 所有权通常会表现为未处理的拒绝,而不是预期的恢复路径。

Promise链遵循同一原则。.then()回调中的抛出会拒绝该.then()返回的Promise, 而.catch(handler)是拒绝处理器的简写。如果处理器正常返回,下一个Promise会用其 返回值兑现;要让链保持失败,处理器必须抛出或返回被拒绝的Promise。

错误应跨越所有权边界

底层代码通常应附加它知道的事实,再由调用方决定展示方式。解析器可以指出无效字段; HTTP处理器则可以把该领域错误映射为状态码和公开消息。混合这两项决策,会让错误类 绑定到某一种界面,也使同一领域操作难以复用。

启动异步工作的边界也拥有它的拒绝。应等待它、返回它、把它放进显式管理的任务组, 或附加终结处理器来记录有意采用的即发即弃策略。只创建一个Promise并不构成所有权 策略。

示例

在输入边界验证

这个边界区分两种情况:JSON本身损坏,以及对象有效但业务字段无效。它会处理两个已知 情况,并重新抛出策略范围之外的错误。自定义类为调用方提供稳定的类型和字段,无需解析 错误消息。

validate-order.js
class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}

function readQuantity(source) {
  const order = JSON.parse(source);
  if (!Number.isInteger(order.quantity) || order.quantity < 1) {
    throw new ValidationError(
      "quantity",
      "Quantity must be a positive integer",
    );
  }
  return order.quantity;
}

for (const source of ['{"quantity":3}', '{"quantity":0}', "not json"]) {
  try {
    console.log(`accepted: ${readQuantity(source)}`);
  } catch (error) {
    if (error instanceof ValidationError) {
      console.log(`${error.field}: ${error.message}`);
    } else if (error instanceof SyntaxError) {
      console.log("body: Invalid JSON");
    } else {
      throw error;
    }
  }
}
accepted: 3
quantity: Quantity must be a positive integer
body: Invalid JSON

JSON.parse()抛出的是运行时SyntaxError,所以这个处理器能够观察到它。另一种情况是 语法错误导致外围脚本根本无法解析;此时脚本完全不会运行,其中的try语句自然也无法 处理解析失败。

添加操作上下文,同时保留原因

底层知道连接已关闭,订单层知道失败的是哪个操作。 错误包装(error wrapping) 会同时记录这两项事实。把捕获值作为cause传入,可以保留诊断信息,又不必把不稳定的 底层措辞塞进公开消息。

load-order.js
class OrderLoadError extends Error {
  constructor(orderId, options) {
    super(`Could not load order ${orderId}`, options);
    this.name = "OrderLoadError";
    this.orderId = orderId;
  }
}

async function requestOrder() {
  throw new TypeError("connection closed");
}

async function loadOrder(orderId) {
  try {
    return await requestOrder(orderId);
  } catch (cause) {
    throw new OrderLoadError(orderId, { cause });
  }
}

async function main() {
  try {
    await loadOrder("A-17");
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
    console.log(`cause: ${error.cause.name}: ${error.cause.message}`);
  }
}

main().catch(console.error);
OrderLoadError: Could not load order A-17
cause: TypeError: connection closed

loadOrder()内的await很重要。如果在try中直接返回requestOrder(orderId), 函数会在返回的Promise拒绝前离开,因此本地catch无法包装失败。边界必须转换拒绝时, return await有明确用途。

释放资源,同时保留失败

代码在受保护操作之前获取资源,并通过finally在该操作的所有退出路径上释放锁。 调用方仍会收到原始写入错误。实际输出也证明,清理发生在外层处理器恢复执行之前。

release-lock.js
class Lock {
  constructor(key) {
    this.key = key;
    console.log(`acquired: ${key}`);
  }

  release() {
    console.log(`released: ${this.key}`);
  }
}

async function saveInvoice(invoiceId) {
  const lock = new Lock(`invoice:${invoiceId}`);
  try {
    throw new Error("write failed");
  } finally {
    lock.release();
  }
}

async function main() {
  try {
    await saveInvoice(42);
  } catch (error) {
    console.log(`handled: ${error.message}`);
  }
}

main().catch(console.error);
acquired: invoice:42
released: invoice:42
handled: write failed

如果受保护操作与清理都抛出错误,普通finally语义会暴露清理错误,并替换先前的错误。 两项失败都很重要时,清理层必须有意记录或聚合它们;JavaScript不会自动完成组合。

报告批处理中的每项结果

Promise.allSettled()会等待每个输入兑现或拒绝,并按输入顺序保留结果。代码先保留成功 任务的名称,再把收集到的拒绝原因转换为一个 聚合错误(aggregate error) 。 这种策略适合所有尝试都应结束,但整个批次仍必须报告失败的场景。

settle-batch.js
const tasks = [
  ["catalog", () => Promise.resolve("updated")],
  ["inventory", () => Promise.reject(new Error("inventory unavailable"))],
  ["receipt", () => Promise.resolve("sent")],
];

async function runBatch(entries) {
  const settled = await Promise.allSettled(
    entries.map(([, run]) => run()),
  );
  const fulfilled = [];
  const failures = [];

  settled.forEach((result, index) => {
    const [name] = entries[index];
    if (result.status === "fulfilled") {
      fulfilled.push(name);
    } else {
      failures.push(result.reason);
    }
  });

  console.log(`fulfilled: ${fulfilled.join(",")}`);
  if (failures.length > 0) {
    throw new AggregateError(failures, `${failures.length} task failed`);
  }
}

runBatch(tasks).catch((error) => {
  console.log(`${error.name}: ${error.message}`);
  console.log(`causes: ${error.errors.map((item) => item.message).join(",")}`);
});
fulfilled: catalog,receipt
AggregateError: 1 task failed
causes: inventory unavailable

每个拒绝原因仍然可以是任意JavaScript值。生产环境中的聚合逻辑应先规范化或检查原因, 再读取.message,尤其是在任务调用了批处理所有者无法控制的代码时。

陷阱

吞掉失败

修复: 只有回退值属于函数书面契约时才进行恢复。其他情况下,应添加有用上下文后 重新抛出,或在完成必要的本地报告后重新抛出原始错误。

假定每个捕获值都是Error

修复: 使用Error属性前,先通过error instanceof Error收窄该值。在外部边界, 可以把非错误值统一规范化为一个新的Error,并让它的cause保留原值。

省略await而丢失拒绝

修复: 如果当前作用域拥有处理策略,就使用await;否则返回Promise,把所有权交给 调用方。对于有意创建的后台工作,应附加终结拒绝处理器,并记录关闭、取消和可观测性行为。

从finally返回

修复: 让finally专注于清理并正常结束。如果清理本身可能失败,应定义哪项失败优先, 或创建能同时保留两者的聚合错误,不要依赖偶然的优先级。

把Promise.all()当作取消机制

修复: 应显式选择失败拓扑。每项结果都重要时使用allSettled();某项失败后应停止 其他工作时,向可取消操作传入共享取消信号,同时仍应等待敲定,让清理拥有明确的负责人。

深入 作为契约的错误对象

作为契约的错误对象

内置类别与应用含义

标准构造器描述语言级类别。TypeError表示操作接收或遇到了不兼容值;RangeError 表示值超出允许范围;ReferenceError与引用解析有关。JSON.parse()等解析器也能在 运行时产生SyntaxError。

应用错误应表达调用方能够据此作出的决定。带稳定field的ValidationError,或带 orderId的OrderLoadError,比许多只改变英文措辞的类更有用。不要迫使调用方根据 message分支,因为措辞面向人,会随着编辑或本地化而变化。

自定义子类应调用super(message, options),由标准初始化安装消息与可选的cause。 继承得到的默认名称是Error,所以设置name能让日志显示领域类名。现代JavaScript 类语义会维护子类原型链,无需额外使用Object.setPrototypeOf()修复。

instanceof在同一个realm和依赖图中很方便,但跨iframe、worker边界、重复安装的包 或重建数据时可能失败。除本地类之外,公开边界可能还需要稳定代码或经过验证的带标签 结构。未经验证的输入即使看起来像内部错误码,也绝不能直接信任。

原因链与诊断数据

cause选项会保留下层失败,无需把它的全部文本拼接进新消息。只有上下文确实能帮助 调用方时,各层才应添加操作信息。每经过一次调用都重新包装,只会产生没有新信息的 嘈杂链条。

堆栈跟踪(stack trace) 是运行时诊断数据,不是可移植的 API契约。它是否存在以及采用何种格式,都因引擎而异;向客户端暴露它还可能泄漏文件 路径、代码布局或内部依赖。应把它记录到可信诊断系统,同时返回经过专门设计的公开错误结构。

多数有用的Error属性不可枚举,因此JSON.stringify(error)通常会遗漏name、 message、stack和cause。所以序列化需要显式允许列表。这也正是执行秘密脱敏、限制 嵌套原因深度,以及安全处理循环或不可序列化原因值的位置。

分类与恢复

分类应依据决定后续动作的语义,而不是罗列所有实现细节。验证失败可以返回给调用方, 暂时性依赖失败可能符合有界重试条件,编程不变量失败则通常必须继续传播。相同的消息文本 不足以证明两个失败应采用相同策略。

重试应位于了解幂等性、截止时间与取消的操作之上。通用的捕获后重试包装器可能重复付款、 重试永久性验证失败,或在调用方不再需要结果后继续工作。错误类型只是这项决策的一项输入。

突然完成与清理

完成结果的优先级

在语言层面,正常流、return、throw、break和continue是语句的不同完成方式。 其中一项结果可能已经待处理,此时finally块开始运行。如果finally正常结束, JavaScript会恢复待处理结果;如果它突然结束,新结果就会替换旧结果。

这条规则不只解释finally中的return。清理抛出可能隐藏主要操作错误,循环控制语句 也可能将其抑制。审查finally时应查找所有控制流出口,而不只是显式返回语句。

必须保留两项错误时,应有意选择表示方式。AggregateError可以容纳两者;如果这种顺序 符合领域语义,也可以让清理错误以主要错误为原因。记录一个再抛出另一个同样是一种策略, 但它不应只是finally优先级的意外副作用。

资源所有权

应在使用资源的最小受保护区域之前立即获取资源,再在finally中释放。如果获取本身 失败,往往没有任何东西需要释放,所以把获取操作放在try外通常能简化不变量。如果 可能只获取了部分资源,则要记录哪些资源已经归当前代码所有。

清理通常必须能够安全地恰好调用一次。如果释放操作是异步的,应在异步finally中等待 它,避免函数返回的Promise在清理完成前敲定。等待本身也可能拒绝,于是再次触发完成 结果优先级规则。

Node的uncaughtException和未处理拒绝事件等进程级钩子,是用于报告和受控关闭的最后 手段。它们不会恢复应用不变量,也无法让程序在未知失败后安全恢复任意工作。局部所有权 边界仍是主要的设计工具。

Promise失败拓扑

顺序链

await不会把异步工作变成同步工作,而是暂停当前异步函数,直到Promise敲定。发生拒绝 时,await会在该源码位置抛出拒绝原因。外围try...catch...finally随即可以应用与 直接抛出相同的控制流规则。

在链中,.then(onFulfilled, onRejected)的第二个参数处理输入Promise的拒绝,不处理 之后由onFulfilled抛出的异常。放在后面的.catch()既处理原始拒绝,也处理前面兑现 回调的抛出,通常更不容易让人误解。

拒绝处理器返回回退值,会让链重新变成兑现状态。这可能是正确策略,但下游代码必须知道 哪些值是真实结果,哪些值来自降级。与没有文档的undefined相比,带标签的结果通常更能 清楚表达部分服务。

并发任务组

Promise.all()会在兑现数组中保持输入顺序,并在某个输入拒绝后进入拒绝状态。提前拒绝 只会改变调用方得知失败的时间,不会让其他底层操作停止。取消需要这些操作通过 AbortSignal等API主动配合。

Promise.allSettled()等待每个输入,并按输入顺序返回带状态标签的结果。它适合相互独立 的任务、审计流程,或部分结果仍有意义的批处理。输入拒绝不会让它自身拒绝,因此调用方必须 检查每项结果,再判断组合操作是否成功。

Promise.any()会用第一个兑现结果兑现。只有所有输入都拒绝时,它才以AggregateError 拒绝;Promise.race()则跟随最先发生的任一种敲定。这些组合器编码了不同的成功条件, 选择哪一个属于错误策略决策,而不只是性能选择。

AggregateError 会在errors属性中保留一组单独原因, 自身也可以拥有cause。其中的值不保证是Error对象。如果只有位置列表会导致歧义, 使用方应为每项原因同时保留任务标识。

延伸阅读

检查点

4个问题 · 1道输出预测题 · 1道找错题

前置内容 JavaScript基础函数 Promises 即将上线
下一篇 Async await 即将上线 Promise methods 即将上线 Fetch api 即将上线 Console 即将上线
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?