PhysChen.com
主页
物理
笔记 科普 研究
教学
IB 课程
编程
笔记 项目
随笔
所感 所思
摄影
Shenzhen Portrait Cats Others Wuhan Japan
关于
主页
物理
笔记 科普 研究
编程
笔记 项目
摄影
Shenzhen Portrait Cats Others Wuhan Japan
教学
IB 课程
随笔
所感 所思
关于
文章目录
    JavaScript 8. 错误处理与异常流控制 陈华的个人主页

    文章信息

    • 标题: JavaScript 8. 错误处理与异常流控制
    • 发布时间: 2026 年 7 月 19 日
    • 来源: https://physchen.com/zh-Hans/programming/notes/javascript-error-handling-and-exception-control-flow/
    • 摘要: 系统介绍 JavaScript 的异常控制流、try/catch/finally、内置错误类型、Error.cause、AggregateError、自定义错误、异步错误处理、资源清理与工程化错误边界。

    目录

      JavaScript 8. 错误处理与异常流控制

      发布于 2026 年 7 月 19 日
      • JavaScript 基础
      • JavaScript
      • Error Handling

      错误处理的目的不是让程序“⁠永远不出错⁠”⁠,而是让不同类型的失败能够被准确识别⁠、在合适的层级处理⁠,并在无法恢复时保留足够的信息继续向上传递⁠。

      JavaScript 中的异常是一种控制流机制⁠。执行 throw⁠,或运行时检测到某些非法操作时⁠,当前正常执行会中断⁠,控制权沿调用关系向外查找最近的匹配处理位置⁠。如果没有任何代码处理该异常⁠,最终行为由宿主环境决定⁠,例如浏览器可能报告未捕获错误⁠,Node.js 进程可能根据错误类型和运行配置终止⁠。

      需要区分以下概念⁠:

      • 程序错误⁠:代码违反了语言规则或接口契约⁠,例如调用不可调用值⁠;
      • 输入错误⁠:外部数据不符合预期格式⁠;
      • 业务失败⁠:操作在技术上正常完成⁠,但业务条件不允许继续⁠;
      • 环境失败⁠:网络⁠、磁盘⁠、权限或外部服务不可用⁠;
      • 取消⁠:操作因为用户行为⁠、超时或上层决策而被主动终止⁠;
      • 异常对象⁠:用于描述失败原因的 JavaScript 值⁠;
      • 异常控制流⁠:通过 throw⁠、try⁠、catch 和 finally 改变执行路径⁠。

      错误处理不应把所有失败都压缩成一个布尔值⁠,也不应在每一层重复打印后继续抛出同一个错误⁠。一个可靠的设计通常需要明确⁠:

      1. 哪些错误可以在当前层恢复⁠;
      2. 哪些错误应转换为更高层语义⁠;
      3. 哪些错误必须继续抛出⁠;
      4. 哪些信息可以展示给用户⁠;
      5. 哪些信息只应记录在日志或监控系统中⁠;
      6. 资源如何在成功⁠、失败和取消时统一清理⁠。

      8.1 并非所有异常结果都会抛出错误

      JavaScript 的一些运算会返回特殊值⁠,而不是抛出异常⁠。

      console.log(10 / 0); // Infinity
      console.log(0 / 0);  // NaN

      数组越界读取返回 undefined⁠:

      const values = [
        1,
        2,
        3,
      ];
      
      console.log(values[10]); // undefined

      读取不存在的普通对象属性也返回 undefined⁠:

      const user = {
        name: "Alice",
      };
      
      console.log(user.age); // undefined

      因此⁠,不能假设所有不合理结果都会自动进入 catch⁠。程序仍需要主动验证⁠:

      function divide(
        dividend,
        divisor,
      ) {
        if (divisor === 0) {
          throw new RangeError(
            "divisor 不能为 0",
          );
        }
      
        return dividend / divisor;
      }

      是否应该抛出异常取决于函数契约⁠。如果某个结果在领域中具有明确意义⁠,也可以返回结构化结果⁠:

      function divideSafely(
        dividend,
        divisor,
      ) {
        if (divisor === 0) {
          return {
            ok: false,
            reason: "division-by-zero",
          };
        }
      
        return {
          ok: true,
          value: dividend / divisor,
        };
      }

      异常适合表达无法在当前正常返回通道中继续处理的失败⁠;预期频繁出现的普通分支不一定都需要异常⁠。

      8.2 throw 语句

      8.2.1 抛出异常

      throw new Error(
        "Operation failed",
      );

      执行 throw 后⁠,当前代码路径立即中断⁠:

      function run() {
        console.log("before");
      
        throw new Error("failed");
      
        // 不会执行。
        console.log("after");
      }

      8.2.2 可以抛出任意值

      JavaScript 允许抛出任意值⁠:

      throw "failed";
      throw 404;
      throw null;
      throw {
        reason: "invalid-data",
      };

      但工程代码应优先抛出 Error 或其子类⁠:

      throw new TypeError(
        "options 必须是对象",
      );

      原因包括⁠:

      • 具有统一的 name 和 message⁠;
      • 通常具有调用栈信息⁠;
      • 可以通过错误类型分类⁠;
      • 支持 cause⁠;
      • 更容易被日志和监控工具识别⁠;
      • 可以添加结构化业务字段⁠。

      外部库和旧代码仍可能抛出字符串⁠、对象或其他值⁠,因此 catch 中不应无条件假定捕获值一定是 Error⁠。

      8.2.3 throw 后不能换行

      throw 属于受限产生式⁠,关键字和表达式之间不能出现换行⁠:

      // SyntaxError
      // throw
      //   new Error("failed");

      正确写法⁠:

      throw new Error(
        "failed",
      );

      表达式较长时⁠,可以在同一行开始圆括号⁠:

      throw (
        new Error(
          "failed",
        )
      );

      这与 return 的 ASI 陷阱相关⁠,但 throw 的换行会直接产生语法错误⁠,而不是静默抛出 undefined⁠。

      8.2.4 重新抛出

      try {
        performOperation();
      } catch (error) {
        if (
          error instanceof ExpectedError
        ) {
          recover(error);
        } else {
          throw error;
        }
      }

      throw error 会继续抛出原错误对象⁠,保留其身份和已有信息⁠。

      不要写成⁠:

      throw new Error(
        error.message,
      );

      这种做法会创建新错误并丢失原错误类型⁠、结构化字段和原始错误链⁠。确实需要转换错误语义时⁠,应使用 cause⁠:

      throw new ApplicationError(
        "无法完成用户数据加载",
        {
          cause: error,
        },
      );

      8.3 try…catch

      8.3.1 基本语法

      try {
        const data = JSON.parse(
          sourceText,
        );
      
        console.log(data);
      } catch (error) {
        console.error(
          "解析失败:",
          error,
        );
      }

      try 块中的代码正常完成时⁠,catch 不执行⁠。发生抛出完成时⁠,控制流转入 catch⁠。

      8.3.2 catch 参数的作用域

      try {
        throw new Error("failed");
      } catch (error) {
        console.log(error.message);
      }
      
      // ReferenceError
      // console.log(error);

      catch 参数只在对应 catch 块中可见⁠。

      8.3.3 可选 catch 绑定

      不需要错误值时⁠,可以省略参数⁠:

      try {
        JSON.parse(sourceText);
      } catch {
        return defaultValue;
      }

      适合以下情况⁠:

      • 错误细节确实不影响处理⁠;
      • 所有失败都采用同一明确降级策略⁠;
      • 错误已经在更底层被记录⁠;
      • 只是检测某项操作是否可行⁠。

      不要为了简短而省略有助于诊断的信息⁠:

      try {
        await saveData();
      } catch {
        showMessage(
          "保存失败",
        );
      }

      如果没有任何日志⁠、重试或上报⁠,真实故障原因可能完全丢失⁠。

      8.3.4 catch 可以捕获任意抛出值

      try {
        throw "failed";
      } catch (error) {
        console.log(
          typeof error,
        ); // string
      }

      需要安全读取信息时⁠,可以先标准化⁠:

      function toError(value) {
        if (
          typeof Error.isError === "function"
          && Error.isError(value)
        ) {
          return value;
        }
      
        if (value instanceof Error) {
          return value;
        }
      
        if (
          typeof value === "string"
        ) {
          return new Error(value);
        }
      
        try {
          return new Error(
            `Non-Error thrown: ${
              JSON.stringify(value)
            }`,
            {
              cause: value,
            },
          );
        } catch {
          return new Error(
            "A non-Error value was thrown",
            {
              cause: value,
            },
          );
        }
      }

      这个辅助函数只用于建立统一日志入口⁠。它不会改变原始抛出行为⁠,是否需要包装仍取决于当前错误边界⁠。

      8.4 try…catch 的同步边界

      8.4.1 捕获同步调用链中的异常

      function inner() {
        throw new Error("failed");
      }
      
      function middle() {
        inner();
      }
      
      try {
        middle();
      } catch (error) {
        console.error(error.message);
      }

      异常沿当前同步调用关系向外传播⁠,因此外层可以捕获⁠。

      8.4.2 无法捕获未来任务中的异常

      try {
        setTimeout(() => {
          throw new Error(
            "timer failed",
          );
        }, 0);
      } catch (error) {
        // 不会执行。
        console.error(error);
      }

      外层 try 在定时器回调执行前已经正常结束⁠。未来任务拥有新的调用关系⁠,不处于原 try 的动态执行范围内⁠。

      应该在回调内部处理⁠:

      setTimeout(() => {
        try {
          performTimerWork();
        } catch (error) {
          reportError(error);
        }
      }, 0);

      或者把回调式 API 转换为 Promise⁠,并在 Promise 链中处理⁠。

      8.4.3 Promise 拒绝不是同步 throw

      try {
        Promise.reject(
          new Error("failed"),
        );
      } catch (error) {
        // 不会执行。
      }

      创建被拒绝的 Promise 不会在当前调用点同步抛出相同异常⁠。应处理返回的 Promise⁠:

      Promise.reject(
        new Error("failed"),
      ).catch((error) => {
        console.error(error);
      });

      或者在 async 函数中等待⁠:

      try {
        await Promise.reject(
          new Error("failed"),
        );
      } catch (error) {
        console.error(error);
      }

      await 会在当前异步函数恢复位置把拒绝转换为抛出完成⁠,因此可以由该 try...catch 捕获⁠。

      8.4.4 Promise 执行器中的同步异常

      const promise = new Promise(() => {
        throw new Error(
          "executor failed",
        );
      });

      Promise 构造器会把执行器同步抛出的异常转换为 Promise 拒绝⁠:

      promise.catch((error) => {
        console.error(error.message);
      });

      但执行器启动的未来回调仍需显式 reject()⁠:

      const promise = new Promise(
        (
          resolve,
          reject,
        ) => {
          setTimeout(() => {
            try {
              performWork();
              resolve("completed");
            } catch (error) {
              reject(error);
            }
          }, 0);
        },
      );

      8.5 finally

      8.5.1 基本语义

      finally 会在 try 或 catch 完成后执行⁠:

      try {
        openResource();
        useResource();
      } catch (error) {
        handleError(error);
      } finally {
        closeResource();
      }

      无论正常完成还是异常完成⁠,清理代码都有机会执行⁠。

      try 可以与 finally 直接组合⁠:

      try {
        useResource();
      } finally {
        closeResource();
      }

      8.5.2 finally 在 return 前执行

      function example() {
        try {
          return "result";
        } finally {
          console.log("cleanup");
        }
      }
      
      console.log(example());

      输出⁠:

      cleanup
      result

      返回值已经确定⁠,但函数真正退出前先执行 finally⁠。

      8.5.3 finally 在 throw 继续传播前执行

      function example() {
        try {
          throw new Error("failed");
        } finally {
          console.log("cleanup");
        }
      }
      
      try {
        example();
      } catch (error) {
        console.error(error.message);
      }

      finally 完成后⁠,原异常继续传播⁠。

      8.5.4 finally 中的控制流会覆盖原结果

      function example() {
        try {
          return "from try";
        } finally {
          return "from finally";
        }
      }
      
      console.log(
        example(),
      ); // from finally

      finally 中的 return 覆盖原返回值⁠。

      更严重的是覆盖异常⁠:

      function example() {
        try {
          throw new Error(
            "original failure",
          );
        } finally {
          return "success";
        }
      }
      
      console.log(
        example(),
      ); // success

      原异常完全消失⁠。

      同理⁠,finally 中重新抛出错误会覆盖原来的返回或异常⁠:

      function example() {
        try {
          throw new Error(
            "original failure",
          );
        } finally {
          throw new Error(
            "cleanup failure",
          );
        }
      }

      调用者只能直接收到清理错误⁠。

      因此⁠,finally 通常只做清理⁠,不应包含⁠:

      • return⁠;
      • break⁠;
      • continue⁠;
      • 与清理无关的业务分支⁠;
      • 容易失败且未受控制的复杂操作⁠。

      8.5.5 清理本身可能失败

      let originalError;
      
      try {
        performOperation();
      } catch (error) {
        originalError = error;
        throw error;
      } finally {
        closeResource();
      }

      如果 closeResource() 抛出错误⁠,原错误会被新的清理错误覆盖⁠。

      可以显式保留两者⁠:

      let operationError;
      
      try {
        performOperation();
      } catch (error) {
        operationError = error;
        throw error;
      } finally {
        try {
          closeResource();
        } catch (cleanupError) {
          if (
            operationError !== undefined
          ) {
            throw new AggregateError(
              [
                operationError,
                cleanupError,
              ],
              "操作和资源清理均失败",
            );
          }
      
          throw cleanupError;
        }
      }

      这种模式较复杂⁠。更常见的做法是让资源接口的关闭操作尽量具有幂等性⁠,并使用明确的资源管理抽象⁠。

      8.6 Error 对象

      8.6.1 创建 Error

      const error = new Error(
        "Operation failed",
      );

      Error() 与 new Error() 在标准行为上等价⁠:

      const first = Error("failed");
      const second = new Error(
        "failed",
      );

      为了明确表示创建对象⁠,工程代码通常使用 new Error()⁠。

      8.6.2 message 与 name

      const error = new Error(
        "Operation failed",
      );
      
      console.log(error.name);
      // Error
      
      console.log(error.message);
      // Operation failed

      message 通常是实例的不可枚举自有属性⁠。name 默认从原型继承⁠。

      console.log(
        Object.keys(error),
      ); // []

      因此⁠:

      JSON.stringify(error);

      通常得到⁠:

      {}

      需要序列化错误时⁠,应显式提取字段⁠。

      8.6.3 Error.prototype.toString

      console.log(
        String(
          new TypeError(
            "Invalid value",
          ),
        ),
      );
      // TypeError: Invalid value

      基本组合规则为⁠:

      • name 为空时返回 message⁠;
      • message 为空时返回 name⁠;
      • 两者都存在时返回 "name: message"⁠。

      8.6.4 stack

      现代运行环境通常提供⁠:

      error.stack

      它一般包含错误名称⁠、信息和调用栈⁠,但 stack 尚不是 ECMAScript 2026 的标准属性⁠。其格式⁠、生成时机⁠、可写性⁠、是否包含异步调用关系以及最大深度都取决于运行环境⁠。

      因此⁠:

      • 可以用于调试和日志⁠;
      • 不应按固定字符串格式解析业务数据⁠;
      • 不应把某一引擎的堆栈格式作为跨环境协议⁠;
      • 不应把堆栈直接展示给最终用户⁠;
      • 堆栈可能包含文件路径⁠、源代码位置或其他敏感信息⁠。

      8.6.5 Error.captureStackTrace

      V8 系列环境通常提供⁠:

      Error.captureStackTrace(
        targetObject,
        constructorFunction,
      );

      它可以在自定义错误中控制堆栈起点⁠:

      class ApplicationError
        extends Error {
        constructor(
          message,
          options,
        ) {
          super(
            message,
            options,
          );
      
          this.name = (
            this.constructor.name
          );
      
          if (
            typeof Error.captureStackTrace
            === "function"
          ) {
            Error.captureStackTrace(
              this,
              this.constructor,
            );
          }
        }
      }

      这不是 ECMAScript 标准接口⁠。使用前必须进行功能检测⁠,且不能依赖其输出格式⁠。

      8.7 Error.cause

      8.7.1 基本用法

      标准错误构造器支持 options.cause⁠:

      const lowLevelError
        = new Error(
          "Connection refused",
        );
      
      const highLevelError
        = new Error(
          "Unable to load user data",
          {
            cause: lowLevelError,
          },
        );
      
      console.log(
        highLevelError.cause
        === lowLevelError,
      ); // true

      cause 是不可枚举自有属性⁠,因此不会出现在 Object.keys() 中⁠。

      8.7.2 错误包装

      async function loadConfiguration() {
        try {
          const response = await fetch(
            "/config.json",
          );
      
          if (!response.ok) {
            throw new Error(
              `HTTP ${response.status}`,
            );
          }
      
          return await response.json();
        } catch (error) {
          throw new ConfigurationError(
            "无法加载配置",
            {
              cause: error,
            },
          );
        }
      }

      包装后⁠,上层可以处理统一的业务错误⁠,同时保留底层原因⁠。

      8.7.3 cause 可以是任意值

      const error = new Error(
        "failed",
        {
          cause: {
            code: "E_TIMEOUT",
          },
        },
      );

      标准没有要求 cause 必须是 Error⁠。工程上仍建议优先保存 Error 或结构明确的原因对象⁠。

      8.7.4 不要把 cause 拼进 message

      不推荐⁠:

      throw new Error(
        `Configuration failed: ${
          error.message
        }`,
      );

      问题包括⁠:

      • 机器难以区分高层和底层信息⁠;
      • 丢失底层类型⁠;
      • 丢失结构化字段⁠;
      • 多层包装会形成冗长重复文本⁠;
      • 不利于日志系统遍历原因链⁠。

      推荐⁠:

      throw new ConfigurationError(
        "无法加载配置",
        {
          cause: error,
        },
      );

      用户界面只展示高层信息⁠,日志系统可以记录完整原因链⁠。

      8.8 Error.isError

      ECMAScript 2026 引入 Error.isError()⁠,用于判断对象是否带有规范定义的 [[ErrorData]] 内部槽⁠。它可识别 Error⁠、各原生错误类型⁠、AggregateError⁠,以及继承这些类型的自定义错误实例⁠。

      console.log(
        Error.isError(
          new Error("failed"),
        ),
      ); // true
      
      console.log(
        Error.isError(
          new TypeError("failed"),
        ),
      ); // true
      
      console.log(
        Error.isError(
          new AggregateError(
            [],
            "failed",
          ),
        ),
      ); // true

      普通伪装对象不会通过⁠:

      const fakeError = {
        name: "Error",
        message: "failed",
        [Symbol.toStringTag]: "Error",
      };
      
      console.log(
        Error.isError(fakeError),
      ); // false

      Error.isError() 检查标准内部错误标记⁠,而不是只看原型或属性名称⁠。

      8.8.1 与 instanceof 的区别

      error instanceof Error

      依赖当前 Realm 的 Error.prototype 是否出现在对象原型链中⁠。来自 iframe 等其他 Realm 的 Error 可能无法通过当前 Realm 的 instanceof Error⁠。

      Error.isError() 根据标准内部槽判断⁠,适合识别跨 Realm 的标准错误对象⁠。

      8.8.2 兼容性

      Error.isError() 属于 ECMAScript 2026 新接口⁠,旧运行环境可能尚未实现⁠。

      可以提供兼容路径⁠:

      function isError(value) {
        if (
          typeof Error.isError
          === "function"
        ) {
          return Error.isError(value);
        }
      
        return value instanceof Error;
      }

      后备的 instanceof 不能完整解决跨 Realm 问题⁠,也不能完全模拟标准内部槽判断⁠。它只是旧环境中的近似方案⁠。

      8.9 内置错误类型

      JavaScript 定义了 Error 以及若干原生错误类型⁠。

      类型典型含义
      Error通用错误
      TypeError操作无法用于当前类型或对象状态
      RangeError数值或参数超出允许范围
      ReferenceError无法解析或访问有效引用
      SyntaxError解析的代码或特定语法文本不合法
      URIErrorURI 编码或解码输入不合法
      EvalError历史兼容类型⁠,现代规范不主动使用
      AggregateError一组错误的聚合

      8.9.1 TypeError

      典型情况⁠:

      const value = null;
      
      // TypeError
      // value.name;
      const value = 10;
      
      // TypeError
      // value();
      Object.defineProperty(
        Object.freeze({}),
        "value",
        {
          value: 10,
        },
      );
      // TypeError

      TypeError 不只表示“⁠形参类型错误⁠”⁠。当一个操作在当前对象状态下无法完成⁠,而没有更具体原生错误类型时⁠,也常使用 TypeError⁠。

      8.9.2 RangeError

      // RangeError
      // new Array(-1);
      // RangeError
      // (1).toFixed(101);

      递归过深导致的调用栈错误⁠,在不同引擎中通常表现为 RangeError⁠,但错误信息和具体阈值由实现决定⁠。

      业务参数超出允许范围时⁠,也可以主动抛出 RangeError⁠:

      function setOpacity(value) {
        if (
          typeof value !== "number"
        ) {
          throw new TypeError(
            "opacity 必须是数值",
          );
        }
      
        if (
          value < 0
          || value > 1
        ) {
          throw new RangeError(
            "opacity 必须位于 0 到 1",
          );
        }
      }

      8.9.3 ReferenceError

      // ReferenceError
      // console.log(
      //   missingVariable,
      // );

      暂时性死区访问也会产生 ReferenceError⁠:

      {
        // ReferenceError
        // console.log(value);
      
        const value = 10;
      }

      对象不存在某个属性不会产生 ReferenceError⁠,只会返回 undefined⁠:

      console.log(
        ({
          name: "Alice",
        }).missing,
      ); // undefined

      8.9.4 SyntaxError

      源文件本身存在语法错误时⁠,整个脚本或模块可能在执行前就无法解析⁠:

      // 以下代码不能与当前文件共存并被正常执行。
      // const = 10;

      同一源文件中的 try...catch 无法捕获使该文件整体解析失败的静态语法错误⁠,因为程序尚未开始执行⁠。

      但某些运行时解析操作会抛出可捕获的 SyntaxError⁠:

      try {
        JSON.parse(
          "{ invalid json }",
        );
      } catch (error) {
        console.log(
          error instanceof SyntaxError,
        ); // true
      }
      try {
        new Function(
          "const = 10;",
        );
      } catch (error) {
        console.log(
          error instanceof SyntaxError,
        ); // true
      }

      动态导入的模块解析失败会通过返回 Promise 的拒绝通道传播⁠,而不是由外围同步 try 直接捕获⁠。

      8.9.5 URIError

      try {
        decodeURIComponent("%");
      } catch (error) {
        console.log(
          error instanceof URIError,
        ); // true
      }

      URIError 主要与⁠:

      • encodeURI()⁠;
      • decodeURI()⁠;
      • encodeURIComponent()⁠;
      • decodeURIComponent()⁠。

      等全局 URI 处理函数有关⁠。

      8.9.6 EvalError

      const error = new EvalError(
        "Legacy error type",
      );

      现代 ECMAScript 规范不使用 EvalError 表示 eval() 失败⁠。它保留用于兼容旧版本和既有代码⁠。

      不要因为使用 eval() 就预期捕获 EvalError⁠。实际错误通常是 SyntaxError⁠、ReferenceError⁠、TypeError 或被执行代码主动抛出的其他值⁠。

      8.10 AggregateError

      8.10.1 创建聚合错误

      const error = new AggregateError(
        [
          new Error("First failure"),
          new TypeError(
            "Second failure",
          ),
        ],
        "Multiple operations failed",
      );

      错误集合保存在 errors 属性中⁠:

      console.log(error.errors);

      构造器接收可迭代对象⁠,并把其中值收集为数组⁠。元素不必是 Error⁠,但工程上通常应使用 Error 对象⁠。

      errors 是可写⁠、可配置但不可枚举的自有属性⁠。

      8.10.2 Promise.any

      Promise.any() 在所有输入 Promise 都拒绝时⁠,以 AggregateError 拒绝⁠:

      try {
        await Promise.any([
          Promise.reject(
            new Error("source A failed"),
          ),
          Promise.reject(
            new Error("source B failed"),
          ),
        ]);
      } catch (error) {
        if (
          error instanceof AggregateError
        ) {
          for (
            const cause
            of error.errors
          ) {
            console.error(cause);
          }
        }
      }

      8.10.3 批量操作

      async function saveAll(items) {
        const results
          = await Promise.allSettled(
            items.map(
              item => saveItem(item),
            ),
          );
      
        const errors = [];
      
        for (
          const result
          of results
        ) {
          if (
            result.status
            === "rejected"
          ) {
            errors.push(
              toError(result.reason),
            );
          }
        }
      
        if (
          errors.length > 0
        ) {
          throw new AggregateError(
            errors,
            `共有 ${errors.length} 项保存失败`,
          );
        }
      }

      AggregateError 适合多个相互独立的失败同时需要保留⁠,而不是只记录第一个错误⁠。

      8.10.4 聚合不等于简单字符串拼接

      不推荐⁠:

      throw new Error(
        errors
          .map(
            error => error.message,
          )
          .join("; "),
      );

      这样会丢失⁠:

      • 每个错误的类型⁠;
      • 调用栈⁠;
      • cause⁠;
      • 自定义字段⁠;
      • 错误对象身份⁠。

      AggregateError 保留了结构化错误集合⁠。

      8.11 自定义错误类

      8.11.1 基本定义

      class ApplicationError
        extends Error {
        constructor(
          message,
          options = {},
        ) {
          super(
            message,
            options,
          );
      
          this.name = (
            this.constructor.name
          );
        }
      }

      派生错误⁠:

      class ValidationError
        extends ApplicationError {}
      
      class NetworkError
        extends ApplicationError {}
      
      class ConfigurationError
        extends ApplicationError {}

      8.11.2 结构化字段

      class ValidationError
        extends ApplicationError {
        constructor(
          message,
          {
            field,
            value,
            cause,
          } = {},
        ) {
          super(
            message,
            {
              cause,
            },
          );
      
          this.field = field;
          this.value = value;
        }
      }

      使用⁠:

      throw new ValidationError(
        "email 格式无效",
        {
          field: "email",
          value: input.email,
        },
      );

      调用者可以根据类型和字段处理⁠,而不需要解析 message⁠:

      try {
        validateInput(input);
      } catch (error) {
        if (
          error
            instanceof ValidationError
        ) {
          highlightField(
            error.field,
          );
        } else {
          throw error;
        }
      }

      8.11.3 错误代码

      公共库或跨进程接口有时需要稳定错误代码⁠:

      class ApplicationError
        extends Error {
        constructor(
          message,
          {
            code,
            cause,
            details,
          } = {},
        ) {
          super(
            message,
            {
              cause,
            },
          );
      
          this.name = (
            this.constructor.name
          );
      
          this.code = code;
          this.details = details;
        }
      }
      throw new ApplicationError(
        "用户不存在",
        {
          code: "USER_NOT_FOUND",
          details: {
            userId,
          },
        },
      );

      错误代码应⁠:

      • 稳定⁠;
      • 可枚举文档化⁠;
      • 不依赖本地化文本⁠;
      • 不包含敏感信息⁠;
      • 与 HTTP 状态等外部协议概念保持适当解耦⁠。

      不要根据 message 文本分支⁠:

      // 不可靠
      if (
        error.message
        === "User not found"
      ) {
        // ...
      }

      文本可能因为翻译⁠、措辞修改或运行环境而变化⁠。

      8.11.4 name 的稳定性

      this.name = (
        this.constructor.name
      );

      适合调试⁠,但类名可能被构建工具压缩或重命名⁠。

      如果错误名称必须成为稳定协议⁠,应显式赋值⁠:

      class ValidationError
        extends Error {
        constructor(
          message,
          options,
        ) {
          super(
            message,
            options,
          );
      
          this.name
            = "ValidationError";
        }
      }

      业务分支仍应优先使用类身份或稳定 code⁠,而不是只使用 name 字符串⁠。

      8.11.5 继承 Error 的注意事项

      现代原生类继承通常能够正确建立 Error 原型关系⁠:

      const error
        = new ValidationError(
          "invalid",
        );
      
      console.log(
        error instanceof Error,
      ); // true
      
      console.log(
        error
          instanceof ValidationError,
      ); // true

      如果代码被转换到非常旧的 JavaScript 运行环境⁠,构建工具对内置类继承的转换可能需要额外支持⁠。应通过目标环境测试⁠,而不是依赖现代环境中的结果推断所有旧环境⁠。

      8.12 错误分类与处理层级

      8.12.1 可恢复错误

      例如⁠:

      • 用户输入不合法⁠;
      • 可选配置缺失⁠;
      • 请求可以重试⁠;
      • 非关键功能不可用⁠;
      • 缓存读取失败但可重新计算⁠。
      try {
        return parseUserInput(input);
      } catch (error) {
        if (
          error
            instanceof ValidationError
        ) {
          return {
            ok: false,
            issues: error.details,
          };
        }
      
        throw error;
      }

      8.12.2 不可恢复错误

      例如⁠:

      • 程序内部不变量被破坏⁠;
      • 必需服务持续不可用⁠;
      • 数据状态不一致⁠;
      • 未知编程错误⁠;
      • 安全验证失败⁠。

      当前层无法恢复时⁠,应⁠:

      • 保留错误信息⁠;
      • 执行必要清理⁠;
      • 继续抛出⁠;
      • 由更高层决定是否终止操作或应用⁠。

      8.12.3 预期失败与程序缺陷

      const result = await findUser(
        userId,
      );

      “⁠用户不存在⁠”可能是预期业务结果⁠,可以返回⁠:

      {
        found: false,
      }

      也可以在某些接口中抛出 UserNotFoundError⁠。关键是保持契约一致⁠。

      而访问未定义变量⁠、把字符串当函数调用等通常属于程序缺陷⁠,不应被无条件转换为空结果继续运行⁠。

      8.12.4 取消不一定等同于失败

      用户主动取消上传⁠,通常不应和服务器崩溃采用相同提示和监控级别⁠。

      try {
        await uploadFile(
          file,
          {
            signal,
          },
        );
      } catch (error) {
        if (
          signal.aborted
        ) {
          return {
            status: "cancelled",
            reason: signal.reason,
          };
        }
      
        throw error;
      }

      具体 API 可能抛出 DOMException⁠,其名称可能为 "AbortError"⁠。与其只比较错误文本⁠,更可靠的做法通常是同时检查当前信号状态和 API 文档规定的错误类型⁠。

      8.13 重新抛出的原则

      8.13.1 只处理当前层能够解决的错误

      function parseConfiguration(
        sourceText,
      ) {
        try {
          return JSON.parse(
            sourceText,
          );
        } catch (error) {
          if (
            error instanceof SyntaxError
          ) {
            throw new ConfigurationError(
              "配置文件不是合法 JSON",
              {
                cause: error,
              },
            );
          }
      
          throw error;
        }
      }

      当前函数负责配置解析⁠,因此可以把 JSON SyntaxError 转换为 ConfigurationError⁠。

      如果出现意外 TypeError⁠,不应一并吞掉⁠。

      8.13.2 避免 catch 过宽

      不推荐⁠:

      try {
        const data = JSON.parse(
          sourceText,
        );
      
        return data.profile.id;
      } catch {
        return null;
      }

      这里同时吞掉⁠:

      • JSON 语法错误⁠;
      • profile 缺失导致的 TypeError⁠;
      • getter 抛出的错误⁠;
      • 未来新增逻辑中的错误⁠。

      应缩小捕获范围⁠:

      let data;
      
      try {
        data = JSON.parse(
          sourceText,
        );
      } catch (error) {
        throw new ConfigurationError(
          "配置 JSON 无法解析",
          {
            cause: error,
          },
        );
      }
      
      if (
        data === null
        || typeof data !== "object"
        || data.profile === null
        || typeof data.profile
          !== "object"
      ) {
        throw new ValidationError(
          "配置缺少 profile 对象",
        );
      }
      
      return data.profile.id;

      8.13.3 记录后重新抛出可能造成重复日志

      try {
        await operation();
      } catch (error) {
        console.error(error);
        throw error;
      }

      如果每一层都这样写⁠,同一错误会被记录多次⁠。

      更好的策略通常是⁠:

      • 底层添加结构化上下文或 cause⁠;
      • 中间层只在能够恢复时处理⁠;
      • 顶层错误边界统一记录⁠;
      • 必要时通过标记防止重复上报⁠。

      8.14 Promise 链中的错误传播

      8.14.1 处理器抛出会产生拒绝

      Promise.resolve(
        sourceText,
      )
        .then((text) => {
          return JSON.parse(text);
        })
        .then((data) => {
          return processData(data);
        })
        .catch((error) => {
          console.error(error);
        });

      JSON.parse() 抛出的错误会使当前 .then() 返回的新 Promise 被拒绝⁠,并沿链传播⁠。

      8.14.2 catch 返回值会恢复链

      Promise.reject(
        new Error("failed"),
      )
        .catch((error) => {
          console.error(error);
          return [];
        })
        .then((values) => {
          console.log(values); // []
        });

      catch 正常返回后⁠,后续链恢复为 fulfilled⁠。

      如果当前层无法恢复⁠,应继续抛出⁠:

      .catch((error) => {
        if (
          error
            instanceof OptionalDataError
        ) {
          return [];
        }
      
        throw error;
      });

      8.14.3 同一个 then 的第二参数边界

      promise.then(
        () => {
          throw new Error(
            "onFulfilled failed",
          );
        },
        (error) => {
          // 不能捕获同一次 then
          // 的 onFulfilled 抛出的错误。
        },
      );

      该拒绝处理器只处理上游 Promise 的拒绝⁠。

      后接 .catch() 可以捕获兑现处理器抛出的错误⁠:

      promise
        .then(() => {
          throw new Error(
            "onFulfilled failed",
          );
        })
        .catch((error) => {
          console.error(error);
        });

      8.14.4 finally 失败会覆盖结果

      Promise.resolve(
        "original",
      )
        .finally(() => {
          throw new Error(
            "cleanup failed",
          );
        })
        .catch((error) => {
          console.error(
            error.message,
          );
        });

      输出为清理错误⁠。Promise finally() 与语句 finally 都需要谨慎处理清理失败⁠。

      8.15 async/await 中的错误处理

      8.15.1 基本模式

      async function loadUser(
        userId,
      ) {
        try {
          const response = await fetch(
            `/api/users/${userId}`,
          );
      
          if (!response.ok) {
            throw new NetworkError(
              `HTTP ${response.status}`,
              {
                code: "HTTP_ERROR",
                details: {
                  status: response.status,
                },
              },
            );
          }
      
          return await response.json();
        } catch (error) {
          throw new UserLoadError(
            "无法加载用户数据",
            {
              cause: error,
            },
          );
        }
      }

      8.15.2 不应捕获后立即返回 undefined

      async function loadData() {
        try {
          return await fetchData();
        } catch (error) {
          console.error(error);
        }
      }

      失败时函数会兑现为 undefined⁠,调用者可能误以为操作成功但没有数据⁠。

      除非接口明确把 undefined 定义为降级结果⁠,否则应⁠:

      • 返回明确的后备值⁠;
      • 返回结构化 Result⁠;
      • 或继续抛出⁠。
      async function loadOptionalData() {
        try {
          return await fetchData();
        } catch (error) {
          if (
            error
              instanceof NotFoundError
          ) {
            return null;
          }
      
          throw error;
        }
      }

      8.15.3 return await 的错误边界

      async function wrapper() {
        try {
          return fetchData();
        } catch (error) {
          // 不能捕获 fetchData()
          // 返回 Promise 的未来拒绝。
        }
      }

      这里 try 只覆盖调用 fetchData() 的同步部分⁠。

      为了在当前 catch 中处理返回 Promise 的拒绝⁠,需要⁠:

      async function wrapper() {
        try {
          return await fetchData();
        } catch (error) {
          throw new DataLoadError(
            "加载失败",
            {
              cause: error,
            },
          );
        }
      }

      return await 并非一律冗余⁠。需要当前函数的 try...catch...finally 覆盖拒绝时⁠,它具有明确语义⁠。

      8.15.4 未等待的异步操作

      async function run() {
        try {
          saveData();
        } catch (error) {
          // 无法捕获 saveData()
          // 返回 Promise 的未来拒绝。
        }
      }

      应等待⁠:

      await saveData();

      或者明确处理⁠:

      void saveData().catch(
        reportError,
      );

      “⁠即发即弃⁠”任务仍需要错误处理策略和生命周期管理⁠。

      8.16 未处理的 Promise 拒绝

      8.16.1 浏览器事件

      浏览器可以在全局对象上报告未处理拒绝⁠:

      globalThis.addEventListener(
        "unhandledrejection",
        (event) => {
          console.error(
            "Unhandled rejection:",
            event.reason,
          );
        },
      );

      如果之后补上处理器⁠,还可能出现 rejectionhandled 事件⁠。

      这些事件属于宿主环境接口⁠,不是 ECMAScript Error 对象本身的一部分⁠。

      8.16.2 全局监听不是普通控制流

      全局监听适合⁠:

      • 最后一道遥测⁠;
      • 记录意外遗漏⁠;
      • 显示通用故障界面⁠;
      • 防止错误完全无迹可寻⁠。

      它不应替代局部错误处理⁠:

      // 不推荐依靠全局事件处理
      // 每个请求的正常失败。
      void loadUser();

      局部代码更了解⁠:

      • 当前操作⁠;
      • 是否能重试⁠;
      • 是否允许降级⁠;
      • 用户应看到什么⁠;
      • 需要清理哪些资源⁠。

      8.16.3 Node.js 行为不同

      Node.js 提供 unhandledRejection 和 uncaughtException 等进程事件⁠,但默认策略和退出行为可能随版本和启动参数变化⁠。

      不要把浏览器全局事件和 Node.js 进程错误机制写成一套统一规则⁠。服务端程序还需要考虑⁠:

      • 进程是否处于不可信状态⁠;
      • 是否应停止接收新请求⁠;
      • 是否需要优雅关闭⁠;
      • 日志是否已经持久化⁠;
      • 进程管理器是否负责重启⁠。

      8.17 浏览器全局错误

      8.17.1 error 事件

      globalThis.addEventListener(
        "error",
        (event) => {
          console.error(
            event.error
            ?? event.message,
          );
        },
      );

      浏览器的 error 事件可以报告部分未捕获脚本错误和资源加载错误⁠,但事件对象形式会因目标和错误类型不同而变化⁠。

      8.17.2 window.onerror

      浏览器传统接口⁠:

      globalThis.onerror = (
        message,
        source,
        line,
        column,
        error,
      ) => {
        console.error(
          message,
          error,
        );
      };

      它属于 Web 平台接口⁠,不是 ECMAScript 标准⁠。

      8.17.3 跨源限制

      跨源脚本错误如果缺少适当 CORS 配置⁠,浏览器可能只提供有限的 "Script error." 信息⁠,以避免泄露跨源细节⁠。

      生产环境的错误监控需要同时配置⁠:

      • 脚本的 crossorigin 属性⁠;
      • 服务端 CORS 响应头⁠;
      • source map 上传与访问控制⁠;
      • 版本号和发布标识⁠;
      • 隐私过滤⁠。

      8.18 资源清理

      8.18.1 finally 清理

      const connection
        = await openConnection();
      
      try {
        await useConnection(
          connection,
        );
      } finally {
        await connection.close();
      }

      在 async 函数中⁠,finally 可以使用 await⁠。

      8.18.2 获取资源也可能失败

      let connection;
      
      try {
        connection
          = await openConnection();
      
        await useConnection(
          connection,
        );
      } finally {
        if (
          connection !== undefined
        ) {
          await connection.close();
        }
      }

      只有资源成功创建后才执行关闭⁠。

      8.18.3 多个资源按反向顺序释放

      const database
        = await openDatabase();
      
      let transaction;
      
      try {
        transaction
          = await database
            .beginTransaction();
      
        await performWork(
          transaction,
        );
      
        await transaction.commit();
      } catch (error) {
        if (
          transaction !== undefined
        ) {
          await transaction.rollback();
        }
      
        throw error;
      } finally {
        await database.close();
      }

      后获得的资源通常先释放⁠,因为它可能依赖先获得的资源⁠。

      8.18.4 清理应尽量幂等

      await connection.close();
      await connection.close();

      如果接口允许多次关闭而不产生额外副作用⁠,错误路径会更容易管理⁠。

      不能控制第三方接口时⁠,应在本地记录状态⁠:

      let closed = false;
      
      async function closeOnce() {
        if (closed) {
          return;
        }
      
        closed = true;
        await connection.close();
      }

      仍需考虑第一次关闭失败后是否允许重试⁠,这取决于资源契约⁠。

      8.19 重试

      8.19.1 不是所有错误都适合重试

      适合重试⁠:

      • 临时网络中断⁠;
      • 服务器暂时过载⁠;
      • 限流后等待⁠;
      • 可重放的读取请求⁠;
      • 明确标记为暂时失败的操作⁠。

      不适合自动重试⁠:

      • 输入验证失败⁠;
      • 权限不足⁠;
      • 资源不存在⁠;
      • 程序逻辑错误⁠;
      • 非幂等写入且没有幂等键⁠;
      • 已经明确取消的操作⁠。

      8.19.2 基本重试函数

      async function retry(
        operation,
        {
          attempts = 3,
          delay = 500,
          shouldRetry = () => true,
        } = {},
      ) {
        let lastError;
      
        for (
          let attempt = 1;
          attempt <= attempts;
          attempt += 1
        ) {
          try {
            return await operation(
              attempt,
            );
          } catch (error) {
            lastError = error;
      
            const canRetry = (
              attempt < attempts
              && shouldRetry(error)
            );
      
            if (!canRetry) {
              throw error;
            }
      
            await new Promise(
              (resolve) => {
                setTimeout(
                  resolve,
                  delay,
                );
              },
            );
          }
        }
      
        throw lastError;
      }

      8.19.3 指数退避与抖动

      多个客户端同时失败后⁠,如果都按固定时间重试⁠,可能形成同步冲击⁠。

      function calculateDelay(
        attempt,
        baseDelay = 500,
      ) {
        const exponential = (
          baseDelay
          * 2 ** (attempt - 1)
        );
      
        const jitter = (
          Math.random()
          * baseDelay
        );
      
        return exponential + jitter;
      }

      实际策略应考虑⁠:

      • 服务端 Retry-After⁠;
      • 最大等待时间⁠;
      • 总超时⁠;
      • 用户取消⁠;
      • 操作幂等性⁠;
      • 并发限制⁠;
      • 业务优先级⁠。

      8.19.4 保留最终 cause

      try {
        return await retry(
          () => fetchData(),
          {
            attempts: 3,
            shouldRetry: isTemporaryError,
          },
        );
      } catch (error) {
        throw new DataLoadError(
          "多次重试后仍无法加载数据",
          {
            cause: error,
          },
        );
      }

      不要把每次失败都无条件包装成新错误⁠,否则原因链可能变得过长⁠。可以在重试器内部保存错误列表⁠,并在最终失败时使用 AggregateError⁠。

      8.20 超时与取消

      8.20.1 Promise.race 只决定结果⁠,不取消操作

      const result
        = await Promise.race([
          fetchData(),
          timeoutPromise(5000),
        ]);

      超时 Promise 获胜后⁠,fetchData() 仍可能继续运行⁠。

      需要真实取消时⁠,应使用底层 API 支持的取消机制⁠,例如 AbortController⁠:

      async function fetchWithTimeout(
        url,
        timeout,
      ) {
        const controller
          = new AbortController();
      
        const timerId = setTimeout(
          () => {
            controller.abort(
              new Error(
                `请求超过 ${timeout} ms`,
              ),
            );
          },
          timeout,
        );
      
        try {
          return await fetch(
            url,
            {
              signal: controller.signal,
            },
          );
        } finally {
          clearTimeout(timerId);
        }
      }

      8.20.2 区分超时⁠、取消和网络失败

      try {
        await fetchWithTimeout(
          url,
          5000,
        );
      } catch (error) {
        if (
          signal.aborted
        ) {
          handleCancellation(
            signal.reason,
          );
      
          return;
        }
      
        if (
          isTimeoutError(error)
        ) {
          showRetryOption();
          return;
        }
      
        throw error;
      }

      不同 API 对中止原因和错误类型的表现可能不同⁠,应依据接口文档设计⁠,而不是仅比较某个浏览器的错误文本⁠。

      8.21 Result 模式

      异常不是所有失败表示的唯一选择⁠。对于频繁⁠、预期且调用者必须分支处理的失败⁠,可以返回 Result 对象⁠。

      function parseNumber(text) {
        const value = Number(text);
      
        if (
          Number.isNaN(value)
        ) {
          return {
            ok: false,
            error: new ValidationError(
              "输入不是有效数值",
            ),
          };
        }
      
        return {
          ok: true,
          value,
        };
      }

      使用⁠:

      const result = parseNumber(
        input,
      );
      
      if (result.ok) {
        console.log(result.value);
      } else {
        console.error(
          result.error.message,
        );
      }

      8.21.1 适用场景

      Result 适合⁠:

      • 失败是正常业务分支⁠;
      • 调用者必须显式处理⁠;
      • 不希望通过异常跳过多层控制流⁠;
      • 需要函数式组合⁠;
      • 接口跨越 Worker 或序列化边界⁠;
      • 错误需要作为普通数据保存⁠。

      异常适合⁠:

      • 当前操作无法正常继续⁠;
      • 错误应沿调用链传播⁠;
      • 大多数调用者无法就地恢复⁠;
      • 语言和库 API 已经使用异常契约⁠;
      • 需要保留调用栈⁠。

      8.21.2 不要混乱使用

      同一函数不应有时返回⁠:

      {
        ok: false,
      }

      有时又对同一类失败抛出异常⁠,除非接口文档明确区分两种通道⁠。

      Result 也不意味着永远不抛出异常⁠。程序缺陷⁠、运行时错误和无法建立 Result 的故障仍可能抛出⁠。

      8.22 错误日志

      8.22.1 记录上下文

      仅记录⁠:

      console.error(
        error.message,
      );

      通常不够⁠。

      更有价值的上下文包括⁠:

      • 操作名称⁠;
      • 请求或任务标识⁠;
      • 用户会话的非敏感标识⁠;
      • 应用版本⁠;
      • 环境⁠;
      • 相关参数摘要⁠;
      • 错误类型⁠;
      • code⁠;
      • cause 链⁠;
      • 调用栈⁠;
      • 是否重试⁠;
      • 是否取消⁠。
      reportError(
        error,
        {
          operation: "load-user",
          userId,
          release: APP_VERSION,
        },
      );

      8.22.2 不记录敏感数据

      避免记录⁠:

      • 密码⁠;
      • 访问令牌⁠;
      • Cookie⁠;
      • 完整身份证件⁠;
      • 银行信息⁠;
      • 医疗隐私⁠;
      • 私有消息⁠;
      • 原始请求体中的敏感字段⁠;
      • 不必要的精确位置⁠。

      日志系统本身也是数据系统⁠,需要⁠:

      • 访问控制⁠;
      • 保留期限⁠;
      • 脱敏⁠;
      • 加密⁠;
      • 合规管理⁠。

      8.22.3 结构化日志

      console.error({
        level: "error",
        event: "user-load-failed",
        error: {
          name: error.name,
          message: error.message,
          code: error.code,
        },
        context: {
          userId,
        },
      });

      实际日志平台通常会提供专门 API⁠。不要依赖 console 对对象的显示格式作为长期协议⁠。

      8.22.4 序列化原因链

      function serializeError(
        error,
        seen = new Set(),
      ) {
        if (
          !isError(error)
        ) {
          return {
            value: String(error),
          };
        }
      
        if (seen.has(error)) {
          return {
            name: error.name,
            message: error.message,
            circular: true,
          };
        }
      
        seen.add(error);
      
        const result = {
          name: error.name,
          message: error.message,
        };
      
        if (
          typeof error.stack
          === "string"
        ) {
          result.stack = error.stack;
        }
      
        if (
          Object.hasOwn(
            error,
            "code",
          )
        ) {
          result.code = error.code;
        }
      
        if (
          Object.hasOwn(
            error,
            "cause",
          )
        ) {
          result.cause
            = serializeError(
              error.cause,
              seen,
            );
        }
      
        if (
          error
            instanceof AggregateError
        ) {
          result.errors
            = error.errors.map(
              item => (
                serializeError(
                  item,
                  seen,
                )
              ),
            );
        }
      
        return result;
      }

      生产环境还应⁠:

      • 限制 cause 深度⁠;
      • 限制 AggregateError 数量⁠;
      • 截断超长 message 和 stack⁠;
      • 清理敏感字段⁠;
      • 防止 getter 在序列化时抛错⁠;
      • 避免把整个任意对象直接上传⁠。

      8.23 面向用户的错误信息

      8.23.1 用户信息与技术信息分离

      技术日志⁠:

      POST /api/orders returned 503
      after 3 retries

      用户提示⁠:

      暂时无法提交订单,请稍后重试。

      不要直接展示⁠:

      error.stack

      也不要把数据库⁠、服务器路径⁠、内部服务名称或安全细节暴露给用户⁠。

      8.23.2 信息应可行动

      较差⁠:

      发生错误。

      较好⁠:

      文件无法上传。请检查网络连接,或稍后重试。

      如果用户无法采取任何行动⁠,应说明系统接下来会做什么⁠:

      保存失败。内容已保留在本地草稿中。

      8.23.3 不应伪造成功

      try {
        await saveData();
      } catch {
        showMessage(
          "保存成功",
        );
      }

      错误降级不能通过误导用户实现⁠。即使后台会重试⁠,也应准确表达当前状态⁠:

      暂时无法同步,数据已保存在本地,恢复连接后将再次尝试。

      8.24 错误边界

      错误边界是能够统一接收下层未处理错误的架构层级⁠。

      浏览器应用中的边界可能包括⁠:

      • 页面入口⁠;
      • 路由⁠;
      • UI 组件树⁠;
      • 用户操作处理器⁠;
      • Worker 消息处理器⁠;
      • 网络请求层⁠;
      • 后台任务调度器⁠。

      服务端边界可能包括⁠:

      • 单个 HTTP 请求⁠;
      • 消息队列消费⁠;
      • 定时任务⁠;
      • CLI 命令⁠;
      • Worker 线程⁠;
      • 进程顶层⁠。

      8.24.1 请求级边界

      async function handleRequest(
        request,
      ) {
        try {
          return await routeRequest(
            request,
          );
        } catch (error) {
          const normalized
            = toError(error);
      
          reportError(
            normalized,
            {
              requestId:
                request.id,
            },
          );
      
          return createErrorResponse(
            normalized,
          );
        }
      }

      边界负责⁠:

      • 统一日志⁠;
      • 转换外部响应⁠;
      • 防止错误污染其他请求⁠;
      • 保证必要清理⁠;
      • 关联请求上下文⁠。

      8.24.2 边界不应无条件继续

      某些错误表示程序状态已经不可信⁠:

      • 核心不变量破坏⁠;
      • 内存或资源耗尽⁠;
      • 初始化配置错误⁠;
      • 安全边界失效⁠;
      • 数据库事务状态未知⁠。

      此时继续运行可能产生更大损害⁠。边界应根据系统类型决定⁠:

      • 终止当前操作⁠;
      • 重新加载页面⁠;
      • 关闭 Worker⁠;
      • 优雅停止服务⁠;
      • 交由进程管理器重启⁠。

      8.25 断言与不变量

      8.25.1 断言函数

      function invariant(
        condition,
        message,
      ) {
        if (!condition) {
          throw new Error(
            `Invariant violation: ${message}`,
          );
        }
      }

      使用⁠:

      invariant(
        currentUser !== null,
        "currentUser 必须已经初始化",
      );

      断言用于检测开发者认为必然成立的内部条件⁠,不应代替普通用户输入验证⁠。

      8.25.2 输入验证与断言的区别

      用户输入错误⁠:

      throw new ValidationError(
        "email 格式无效",
      );

      程序不变量错误⁠:

      throw new Error(
        "Invariant violation: "
        + "order total is negative",
      );

      前者通常可以向用户反馈并继续操作⁠;后者通常意味着程序或数据逻辑存在缺陷⁠,需要上报和中止当前流程⁠。

      8.25.3 console.assert

      console.assert(
        condition,
        "message",
      );

      console.assert() 属于宿主调试接口⁠,通常只输出信息⁠,不会稳定地抛出异常⁠。不能用它维护生产代码不变量⁠。

      8.26 测试错误路径

      8.26.1 测试同步异常

      function expectThrows(
        action,
        ErrorType,
      ) {
        let thrown = false;
      
        try {
          action();
        } catch (error) {
          thrown = true;
      
          if (
            !(
              error
              instanceof ErrorType
            )
          ) {
            throw new Error(
              "抛出了错误类型,"
              + "但不是预期类型",
              {
                cause: error,
              },
            );
          }
        }
      
        if (!thrown) {
          throw new Error(
            "预期函数抛出异常",
          );
        }
      }

      实际项目应使用测试框架提供的错误断言⁠。

      8.26.2 测试 Promise 拒绝

      不正确⁠:

      // 测试可能在 Promise 完成前结束。
      saveData().catch(
        () => {
          // assertion
        },
      );

      应返回或等待 Promise⁠:

      await assertRejects(
        () => saveData(),
        NetworkError,
      );

      8.26.3 测试 cause

      try {
        await loadConfiguration();
      } catch (error) {
        assert(
          error
            instanceof ConfigurationError,
        );
      
        assert(
          error.cause
            instanceof SyntaxError,
        );
      }

      8.26.4 测试 finally 清理

      let closed = false;
      
      const resource = {
        close() {
          closed = true;
        },
      };
      
      try {
        useResource(resource);
      } catch {
        // 预期错误。
      } finally {
        resource.close();
      }
      
      assert(closed);

      错误路径不能只靠人工阅读保证⁠,应对⁠:

      • 成功⁠;
      • 预期失败⁠;
      • 未知失败⁠;
      • 取消⁠;
      • 超时⁠;
      • 清理失败⁠;
      • 多错误聚合⁠。

      分别编写测试⁠。

      8.27 常见错误处理问题

      8.27.1 空 catch

      try {
        performOperation();
      } catch {}

      这会完全隐藏错误⁠。只有在失败确实可以无条件忽略⁠,并且该行为已经被清楚记录时才可使用⁠。

      8.27.2 捕获后只打印

      try {
        return await loadData();
      } catch (error) {
        console.error(error);
      }

      函数失败时变为返回 undefined⁠。调用者通常无法区分失败和正常空值⁠。

      8.27.3 抛出字符串

      throw "failed";

      缺少统一错误结构和调用栈支持⁠。应使用⁠:

      throw new Error("failed");

      8.27.4 解析 message

      if (
        error.message.includes(
          "not found",
        )
      ) {
        // ...
      }

      信息文本不是稳定接口⁠。使用类⁠、code 或结构化字段⁠。

      8.27.5 所有错误都转为同一错误

      catch (error) {
        throw new Error(
          "Operation failed",
        );
      }

      如果没有 cause⁠,底层信息丢失⁠。即使有 cause⁠,也不应在每一层无意义包装⁠。

      8.27.6 重复日志

      catch (error) {
        console.error(error);
        throw error;
      }

      多层重复会制造噪声⁠。应明确由哪个错误边界负责最终记录⁠。

      8.27.7 把业务分支全部写成异常

      try {
        return getCachedValue(key);
      } catch {
        return computeValue(key);
      }

      如果“⁠缓存未命中⁠”是正常情况⁠,更适合返回 undefined⁠、null 或 Result⁠,而不是频繁构造异常⁠。

      8.27.8 过度依赖 instanceof

      跨 Realm⁠、重复安装的包副本或不同构建上下文⁠,可能导致看似同类的错误无法通过当前类的 instanceof⁠。

      公共库可以同时提供⁠:

      • 稳定错误 code⁠;
      • 类类型⁠;
      • name⁠;
      • 明确文档⁠。

      ECMAScript 2026 的 Error.isError() 只判断是否为标准 Error 对象⁠,不会判断是否属于某个自定义错误类别⁠。

      8.27.9 忽略清理错误

      finally {
        await resource.close();
      }

      如果关闭失败⁠,它可能覆盖原操作错误⁠。关键资源应明确设计清理失败策略⁠。

      8.27.10 把全局错误监听当作恢复机制

      未捕获异常发生后⁠,应用状态可能已经部分更新⁠。全局处理器通常只适合记录⁠、展示故障页或终止当前边界⁠,而不是假设程序可以无条件继续⁠。

      8.28 完整示例⁠:配置加载错误链

      8.28.1 错误类型

      class ApplicationError
        extends Error {
        constructor(
          message,
          {
            code,
            cause,
            details,
          } = {},
        ) {
          super(
            message,
            {
              cause,
            },
          );
      
          this.name = (
            this.constructor.name
          );
      
          this.code = code;
          this.details = details;
        }
      }
      
      class HttpError
        extends ApplicationError {}
      
      class ConfigurationError
        extends ApplicationError {}
      
      class ConfigurationValidationError
        extends ConfigurationError {}

      8.28.2 请求层

      async function fetchText(
        url,
        {
          signal,
        } = {},
      ) {
        let response;
      
        try {
          response = await fetch(
            url,
            {
              signal,
            },
          );
        } catch (error) {
          if (
            signal?.aborted
          ) {
            throw error;
          }
      
          throw new HttpError(
            "网络请求失败",
            {
              code: "NETWORK_FAILURE",
              cause: error,
              details: {
                url,
              },
            },
          );
        }
      
        if (!response.ok) {
          throw new HttpError(
            `服务器返回 HTTP ${
              response.status
            }`,
            {
              code: "HTTP_STATUS_ERROR",
              details: {
                url,
                status: response.status,
              },
            },
          );
        }
      
        return response.text();
      }

      8.28.3 解析层

      function parseConfiguration(
        sourceText,
      ) {
        let data;
      
        try {
          data = JSON.parse(
            sourceText,
          );
        } catch (error) {
          throw new ConfigurationError(
            "配置文件不是合法 JSON",
            {
              code: "CONFIG_SYNTAX_ERROR",
              cause: error,
            },
          );
        }
      
        if (
          data === null
          || typeof data !== "object"
          || Array.isArray(data)
        ) {
          throw (
            new ConfigurationValidationError(
              "配置根节点必须是对象",
              {
                code: "CONFIG_INVALID_ROOT",
                details: {
                  receivedType:
                    data === null
                      ? "null"
                      : typeof data,
                },
              },
            )
          );
        }
      
        if (
          typeof data.apiBaseUrl
          !== "string"
        ) {
          throw (
            new ConfigurationValidationError(
              "配置缺少 apiBaseUrl",
              {
                code:
                  "CONFIG_MISSING_API_URL",
                details: {
                  field: "apiBaseUrl",
                },
              },
            )
          );
        }
      
        return data;
      }

      8.28.4 业务层

      async function loadConfiguration(
        url,
        options,
      ) {
        try {
          const sourceText
            = await fetchText(
              url,
              options,
            );
      
          return parseConfiguration(
            sourceText,
          );
        } catch (error) {
          if (
            error
              instanceof ConfigurationError
          ) {
            throw error;
          }
      
          throw new ConfigurationError(
            "无法加载应用配置",
            {
              code: "CONFIG_LOAD_FAILED",
              cause: error,
              details: {
                url,
              },
            },
          );
        }
      }

      8.28.5 顶层边界

      async function startApplication() {
        const controller
          = new AbortController();
      
        try {
          const configuration
            = await loadConfiguration(
              "/config.json",
              {
                signal:
                  controller.signal,
              },
            );
      
          await initializeApplication(
            configuration,
          );
        } catch (error) {
          const normalized
            = toError(error);
      
          reportError(
            normalized,
            {
              operation:
                "application-startup",
            },
          );
      
          showFatalError(
            "应用暂时无法启动。"
            + "请稍后重新加载页面。",
          );
        }
      }

      这个分层结构具有以下特点⁠:

      • 请求层负责网络和 HTTP 语义⁠;
      • 解析层负责 JSON 和字段验证⁠;
      • 业务层负责统一配置加载语义⁠;
      • 顶层边界负责日志和用户提示⁠;
      • cause 保留完整底层原因⁠;
      • 错误代码供程序分支和监控聚合⁠;
      • 用户界面不直接暴露内部技术细节⁠。

      8.29 错误处理设计原则

      8.29.1 失败契约应明确

      函数应说明⁠:

      • 会抛出哪些错误⁠;
      • 哪些失败通过返回值表示⁠;
      • 是否可能取消⁠;
      • 是否自动重试⁠;
      • 是否会包装底层错误⁠;
      • 调用者是否需要清理资源⁠。

      8.29.2 捕获范围应尽量小

      只把预期可能失败⁠、并且能够准确分类的语句放入 try⁠。

      范围越大⁠,越容易无意捕获与当前处理无关的程序错误⁠。

      8.29.3 在有能力恢复的层级处理

      底层通常负责⁠:

      • 检测失败⁠;
      • 添加技术上下文⁠;
      • 保留 cause⁠。

      高层通常负责⁠:

      • 重试⁠;
      • 降级⁠;
      • 用户提示⁠;
      • 终止操作⁠;
      • 统一日志⁠。

      8.29.4 不要丢失原始原因

      使用⁠:

      new Error(
        "High-level message",
        {
          cause: originalError,
        },
      );

      而不是只复制 message⁠。

      8.29.5 不要隐藏未知错误

      只处理明确识别的错误⁠,其他错误重新抛出⁠。

      8.29.6 清理与业务逻辑分离

      finally 负责资源释放⁠,不负责决定成功结果⁠。

      8.29.7 日志与用户提示分离

      日志用于诊断⁠,用户提示用于帮助用户采取下一步行动⁠。

      8.29.8 对错误路径进行测试

      错误处理不是附加功能⁠,而是程序主要控制路径的一部分⁠。

      8.30 本章小结

      本章系统介绍了 JavaScript 的错误对象与异常控制流⁠:

      1. JavaScript 的许多异常结果会返回 NaN⁠、Infinity 或 undefined⁠,不会自动抛出错误⁠;
      2. throw 可以抛出任意值⁠,但工程代码应优先抛出 Error 或其子类⁠;
      3. throw 与后续表达式之间不能换行⁠;
      4. try...catch 只能捕获其动态执行范围内的抛出完成⁠;
      5. 外层同步 try...catch 无法捕获未来定时器回调中的异常⁠;
      6. Promise 拒绝不是当前调用点的同步 throw⁠,必须通过 .catch() 或 await 处理⁠;
      7. finally 会在返回或异常继续传播前执行⁠;
      8. finally 中的 return 或 throw 会覆盖原来的返回值或错误⁠;
      9. Error 标准属性主要包括 name⁠、message 和可选的 cause⁠;
      10. stack 被广泛实现但不属于 ECMAScript 2026 的标准属性⁠,不能依赖固定格式⁠;
      11. Error.captureStackTrace() 是 V8 专有接口⁠,需要功能检测⁠;
      12. Error.cause 用于保留底层失败原因⁠,比拼接错误信息更加结构化⁠;
      13. ECMAScript 2026 的 Error.isError() 根据标准内部错误标记识别 Error⁠,对跨 Realm 场景比 instanceof Error 更可靠⁠;
      14. Error.isError() 是新接口⁠,旧环境可能需要近似后备方案⁠;
      15. TypeError⁠、RangeError⁠、ReferenceError⁠、SyntaxError 和 URIError 分别表达不同原生失败类型⁠;
      16. EvalError 在现代规范中保留用于兼容⁠,但规范不主动使用它⁠;
      17. 源文件自身的静态语法错误通常无法由同一文件中的 try...catch 捕获⁠;
      18. AggregateError 通过 errors 属性保存多个失败⁠,适合批量和并发操作⁠;
      19. 自定义错误类应提供稳定类型⁠、可选错误代码和结构化字段⁠;
      20. 错误文本不应成为程序分支协议⁠,优先使用类型⁠、code 和字段⁠;
      21. 当前层只应处理能够恢复或转换语义的错误⁠,未知错误应继续抛出⁠;
      22. 捕获范围越大⁠,越容易无意吞掉程序缺陷⁠;
      23. Promise .catch() 正常返回会使链恢复为 fulfilled⁠;
      24. return await 在需要当前 try...catch...finally 覆盖 Promise 拒绝时具有明确用途⁠;
      25. 未等待的 Promise 必须有独立拒绝处理策略⁠;
      26. 浏览器的 error 和 unhandledrejection 事件适合作为最终遥测边界⁠,而不是替代局部处理⁠;
      27. 资源应在成功⁠、失败和取消路径中统一清理⁠,并注意清理错误可能覆盖原错误⁠;
      28. Promise 竞争只改变聚合结果⁠,不会自动取消底层操作⁠;
      29. 重试只适合暂时性且可以安全重放的失败⁠;
      30. Result 模式适合频繁⁠、预期并要求调用者显式分支处理的失败⁠;
      31. 错误日志应包含必要上下文⁠,但必须避免记录敏感数据⁠;
      32. 面向用户的错误信息应准确⁠、可行动⁠,并与内部技术日志分离⁠;
      33. 错误边界负责统一记录⁠、转换外部响应和终止受影响的操作⁠;
      34. 错误路径⁠、取消路径和清理路径都应通过自动化测试验证⁠。

      参考资料

      1. javascript.info: Error handling, try…catch
      2. javascript.info: Custom errors, extending Error
      3. javascript.info: Promises chaining
      4. javascript.info: Error handling with promises
      5. javascript.info: Async/await
      6. MDN JavaScript Guide: Control flow and error handling
      7. MDN JavaScript Reference: try…catch
      8. MDN JavaScript Reference: throw
      9. MDN JavaScript Reference: Error
      10. MDN JavaScript Reference: Error.cause
      11. MDN JavaScript Reference: AggregateError
      12. MDN JavaScript Reference: Error.isError
      13. MDN Web API: Window error event
      14. MDN Web API: Window unhandledrejection event
      15. ECMAScript 2026 Language Specification: The throw Statement
      16. ECMAScript 2026 Language Specification: The try Statement
      17. ECMAScript 2026 Language Specification: Error Objects
      18. ECMAScript 2026 Language Specification: Error.isError
      19. ECMAScript 2026 Language Specification: AggregateError Objects
      20. ECMAScript 2026 Language Specification: InstallErrorCause
      上一篇 JavaScript 7. 现代语法、协议与元编程 2026 年 7 月 19 日 下一篇 JavaScript 9. 模块系统与包管理 2026 年 7 月 19 日
      © 2026 CHEN Hua All rights reserved
      闽ICP备2026003335号 · 粤公网安备44030002014022号
      © Hua Chen / PhysChen.com