第 28 章

Error 与 Completion Record:try/catch/finally 规范语义

finally 里的 return 会覆盖 try 里的 return——这不是 bug,是规范定义的 Completion Record 替换语义。理解这套机制,你才能解释所有"奇怪"的 try/catch/finally 行为,而不只是背特例。

🔹 Level 1 · 你需要知道的

7种内置 Error 类型

错误类型 触发场景 典型代码
Error 通用错误基类,通常用于自定义 throw new Error('msg')
TypeError 类型操作错误(null 解引用、非函数调用等) null.propertyundefined()
RangeError 数值超出合法范围 new Array(-1)Number.toFixed(200)
ReferenceError 访问不存在的变量 undeclaredVar
SyntaxError 语法解析错误(通常在编译阶段) eval('{{')
URIError encodeURI/decodeURI 参数非法 decodeURIComponent('%')
EvalError eval() 相关(现代已很少见)

ES2021 新增:AggregateError(聚合错误,Promise.any 全部 reject 时抛出)。

try/catch/finally 基本规则

try {
  // 可能抛出错误的代码
  throw new Error('问题');
} catch (e) {
  // 捕获错误(e 是错误对象)
  console.log(e.message);  // '问题'
  // 在这里可以选择:处理错误(不再传播)或重新抛出
  // throw e;  // 重新抛出
} finally {
  // 无论 try 还是 catch 如何结束,这里总是执行
  // 即使上面有 return,这里也会执行
  console.log('cleanup');
}

关键规则:

  1. catch 只捕获同步代码的错误(await 之前的同步抛出不会被 async 函数后面的 catch 捕获)
  2. finally 总是执行,无一例外(除非进程崩溃或 process.exit()
  3. finally 里的 return/throw覆盖 try/catch 里的 return/throw

获取错误堆栈

try {
  someFunction();
} catch (e) {
  console.error(e.message);  // 错误消息
  console.error(e.stack);    // 堆栈追踪(各引擎格式不同,非标准)
  console.error(e.name);     // 'TypeError'、'RangeError' 等
}

自定义 Error 类

class NetworkError extends Error {
  constructor(message, statusCode) {
    super(message);         // 必须先调用 super
    this.name = 'NetworkError';  // 修正 name(默认是 'Error')
    this.statusCode = statusCode;
    // 修复 stack trace(V8 特有,可选)
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, NetworkError);
    }
  }
}

try {
  throw new NetworkError('Connection refused', 503);
} catch (e) {
  if (e instanceof NetworkError) {
    console.log(`HTTP ${e.statusCode}: ${e.message}`);
  } else {
    throw e;  // 不认识的错误,重新抛出
  }
}

🔸 Level 2 · 它是怎么运行的

Completion Record:JS 语句执行结果的通用容器

ECMAScript 规范中,每条语句执行后都产生一个 Completion Record(完成记录)。这是一个内部规范类型,不是 JavaScript 对象,开发者无法直接访问。

Completion Record 的结构:
┌──────────────────────────────────────────────┐
│  [[Type]]                                    │
│    normal | return | throw | break | continue│
│                                              │
│  [[Value]]                                   │
│    该完成的关联值                             │
│    normal: 语句的计算结果(表达式值)         │
│    return: 返回值                            │
│    throw: 异常值(Error 对象等)             │
│    break/continue: empty                     │
│                                              │
│  [[Target]]                                  │
│    break/continue 的目标标签                 │
│    其他情况:empty                           │
└──────────────────────────────────────────────┘

示例:

// 每条语句产生的 Completion Record(内部语义):

let x = 5;
// → { type: normal, value: undefined, target: empty }

return 42;
// → { type: return, value: 42, target: empty }

throw new Error();
// → { type: throw, value: Error{}, target: empty }

break myLabel;
// → { type: break, value: empty, target: 'myLabel' }

try/catch/finally 的执行逻辑(ASCII流程图)

┌────────────────────────────────────────────────────────────────┐
│  try { B } catch(e) { C } finally { F } 执行流程               │
│                                                                │
│  1. 执行 try block B,得到 Completion Record = tryResult       │
│        │                                                       │
│        ├── tryResult.type === 'throw'?                        │
│        │     │ 是 → 绑定 e = tryResult.value                  │
│        │     │       执行 catch block C,得到 catchResult      │
│        │     │       tryResult ← catchResult                  │
│        │     │ 否 → tryResult 不变                            │
│        │                                                       │
│  2. 执行 finally block F,得到 finallyResult                   │
│        │                                                       │
│        ├── finallyResult.type 是 normal?                      │
│        │     │ 是 → 返回 tryResult(finally 不覆盖)           │
│        │     │ 否 → 返回 finallyResult(finally 覆盖一切!)   │
│        │                                                       │
│  结束                                                          │
└────────────────────────────────────────────────────────────────┘

关键规则: finally 的 Completion Record 只有在 type === normal 时才不覆盖之前的结果;任何 returnthrowbreakcontinue 都会完全替换之前的结果。

具体场景演示

场景1:finally 的 return 覆盖 try 的 return

function f() {
  try {
    return 1;                       // Completion: {type: return, value: 1}
  } finally {
    return 2;                       // Completion: {type: return, value: 2}
    // finally 产生 return,覆盖 try 的 return
  }
}

console.log(f());  // 2,不是 1

场景2:finally 的 throw 覆盖 catch 的"成功"

function g() {
  try {
    throw new Error('original');
  } catch (e) {
    console.log('caught:', e.message);  // 打印 'caught: original'
    return 'handled';                   // Completion: {type: return, value: 'handled'}
  } finally {
    throw new Error('from finally');    // Completion: {type: throw, value: Error}
    // 覆盖 catch 的 return!
  }
}

try {
  g();
} catch (e) {
  console.log('outer caught:', e.message);  // 'outer caught: from finally'
}

场景3:finally 的 break 覆盖正常流程

function h() {
  outer: try {
    return 'from try';
  } finally {
    break outer;  // 这是一个语法陷阱!
    // finally 里 break 到 try 的标签外
  }
  return 'after try';  // 这里会被执行!
}

console.log(h());  // 'after try'
// (finally 的 break 打断了 try 的 return)

注意:这种写法在严格模式下依然合法(不像 with),但被认为是极差的代码风格。

Completion Record 的传播:UpdateEmpty 操作

当代码块(Block)中的最后一条语句产生的 Completion 是 normalvalueempty 时,规范使用 UpdateEmpty 操作将 value 设置为前一个值:

UpdateEmpty(completionRecord, value):
  1. 若 completionRecord.[[Value]] 不是 empty,返回 completionRecord
  2. 返回 Completion { type: completionRecord.[[Type]],
                        value: value,
                        target: completionRecord.[[Target]] }

这解释了为什么 try {} finally {} 的整体结果是 try block 或 finally block 的最后一个值(当 finally 是 normal 时)。

Promise 与 try/catch 的交互

// 错误示例:try/catch 无法捕获异步错误
async function wrong() {
  try {
    // 注意:这里的 fetch 调用在 await 之前
    // 如果 fetch 同步抛出(几乎不会,但假设),可以捕获
    const response = await fetch('/api');  // 这里之前的同步代码是安全的
    return await response.json();
  } catch (e) {
    console.log('caught:', e);  // 捕获 fetch rejection 和 response.json() 的错误
  }
}

// 真正的陷阱:
function syncThrow() {
  throw new Error('sync before await');  // 同步抛出
  return fetch('/api');
}

async function caller() {
  try {
    const result = await syncThrow();  // syncThrow 同步抛出
  } catch (e) {
    // 能捕获到吗?
    console.log('caught:', e.message);  // YES,能捕获!
    // 因为 async 函数会将同步异常包装为 rejected Promise
  }
}

关键: async 函数内的同步异常会被包装为 rejected Promise,所以 await 语句的 try/catch 能捕获。但在 async 函数外部直接调用时,必须用 .catch() 而非 try/catch

Error 继承链的内部结构

Error(基类)
├── TypeError       — 类型错误
├── RangeError      — 范围错误
├── ReferenceError  — 引用错误
├── SyntaxError     — 语法错误
├── URIError        — URI 错误
├── EvalError       — eval 相关(已弃用)
└── AggregateError  — 聚合错误(ES2021)
    属性:errors[](包含所有失败原因)

自定义继承:
class AppError extends Error {
  constructor(message, code) {
    super(message);
    this.name = 'AppError';
    this.code = code;
  }
}

🔺 Level 3 · 规范怎么定义的

规范 §6.2.4:Completion Record

ECMAScript 2024 规范在 §6.2.4 定义 Completion Record:

6.2.4 The Completion Record Specification Type

The Completion type is a Record used to explain the runtime propagation of values and control flow such as the behaviour of statements (break, continue, return, and throw) that perform nonlocal transfers of control.

Values of the Completion type are Record values whose fields are defined as:

Field Name Value Meaning
[[Type]] normal, break, continue, return, or throw The type of completion that occurred
[[Value]] any ECMAScript language value or empty The value that was produced
[[Target]] a String or empty The target label for directed control transfers

规范定义了简写:

A normal completion containing value v is denoted as NormalCompletion(v). A throw completion containing value v is denoted as ThrowCompletion(v). An abrupt completion is any completion with a [[Type]] value other than normal.

规范 §14.15:The try Statement

try 语句的运行时语义(Runtime Semantics: Evaluation):

不含 catch,只有 finally(try…finally):

  1. Let B be the result of evaluating Block.
  2. Let F be the result of evaluating Finally.
  3. If F.[[Type]] is normal, return Completion(B).
  4. Return Completion(UpdateEmpty(F, B.[[Value]])).

含 catch 和 finally(try…catch…finally):

  1. Let B be the result of evaluating Block.
  2. If B.[[Type]] is throw, then: a. Let C be the result of performing CatchClauseEvaluation of the CatchClause with argument B.[[Value]].
  3. Else, let C be B.
  4. Let F be the result of evaluating Finally.
  5. If F.[[Type]] is normal, return Completion(C).
  6. Return Completion(UpdateEmpty(F, C.[[Value]])).

规范的精确定义揭示:

规范 §20.5:Error Objects

Error 对象的创建(new Error(message))内部步骤:

  1. Let O be ? OrdinaryCreateFromConstructor(NewTarget, "%Error.prototype%", « [[ErrorData]] »).
  2. If message is not undefined, then a. Let msgString be ? ToString(message). b. Perform CreateNonEnumerableDataPropertyOrThrow(O, "message", msgString).
  3. Perform ? InstallErrorCause(O, options).(ES2022 新增 Error Cause)
  4. Return O.

Error.captureStackTrace(V8 特有,非标准):规范中没有定义 stack 属性的格式,各引擎实现各异。这意味着任何解析 Error.stack 字符串的代码都是不可移植的。

AggregateError(ES2021)

AggregateError 规范(§20.5.7):
  - 继承自 Error
  - 具有额外的 [[Errors]] 内部插槽
  - 构造函数:new AggregateError(errors, message, options)
  - errors 属性:所有错误的数组(在实例上创建 'errors' 数据属性)

触发场景:Promise.any(promises) 当所有 Promise 都 reject 时,用所有 rejection 原因创建 AggregateError

try {
  await Promise.any([
    Promise.reject(new Error('err1')),
    Promise.reject(new Error('err2')),
    Promise.reject(new Error('err3')),
  ]);
} catch (e) {
  console.log(e instanceof AggregateError);  // true
  console.log(e.errors.length);              // 3
  console.log(e.errors[0].message);          // 'err1'
}

💎 Level 4 · 边界与陷阱

陷阱1:finally 里的 return 静默覆盖 try 的 return

function getUser(id) {
  try {
    if (!id) throw new Error('Invalid ID');
    return fetchUserSync(id);  // 假设同步返回用户对象
  } finally {
    cleanup();  // 如果 cleanup 里有 return...
  }
}

// 如果 cleanup() 内部有 return(如下),函数行为会变)
function cleanup() {
  return 'cleaned';  // 这个 return 在 finally 里调用 cleanup 时
                     // 不会影响外层 getUser,因为 cleanup 是独立函数调用
}

// 危险版本:直接在 finally 里 return
function getUser2(id) {
  try {
    return fetchUserSync(id);  // 返回用户
  } finally {
    return null;  // 静默覆盖!调用者永远得到 null
  }
}

// 很难发现,因为没有错误,只是静默地返回了错误的值

真实 bug 场景: 开发者在 finally 里写了 return 来确保某个"清理值"被返回,却不知道这会覆盖 try 的正常返回值。ESLint 的 no-unsafe-finally 规则专门检测这类问题。

陷阱2:finally 里的 throw 覆盖已处理的异常

class ResourceManager {
  constructor(resource) {
    this.resource = resource;
  }

  async doWork() {
    try {
      await this.resource.process();
    } catch (e) {
      // 我们正确处理了错误
      logger.error('Processing failed:', e);
      return { success: false, error: e.message };
    } finally {
      // 这里的清理操作如果抛出...
      await this.resource.close();  // 如果 close() reject 了呢?
    }
  }
}

// 如果 resource.close() 抛出异常:
// 1. catch 已经"处理"了 process() 的错误
// 2. finally 里 close() 的错误覆盖了 catch 的返回值
// 3. 调用者看到的是 close() 的错误,而不是 { success: false }

// 正确写法:
async doWork() {
  try {
    await this.resource.process();
    return { success: true };
  } catch (e) {
    logger.error('Processing failed:', e);
    return { success: false, error: e.message };
  } finally {
    try {
      await this.resource.close();  // 单独 try,防止覆盖外层
    } catch (closeError) {
      logger.error('Close failed:', closeError);
      // 不重新抛出,防止覆盖外层的 return
    }
  }
}

陷阱3:async/await 与同步 try/catch 的边界

// 陷阱:以为 try/catch 能捕获所有错误
function riskySetup() {
  throw new Error('Setup failed');  // 同步抛出
}

async function main() {
  try {
    const promise = riskySetup();  // riskySetup 在 await 之前同步执行
    await promise;  // 从未执行到这里
  } catch (e) {
    console.log('caught');  // 能捕获吗?
  }
}

// 答案:能!
// async 函数内的同步异常会被 async 函数包装为 rejected Promise
// try/catch 会捕获这个 rejected Promise

// 但是,以下情况不能捕获:
async function main2() {
  const promise = new Promise((resolve, reject) => {
    setTimeout(() => reject(new Error('late rejection')), 100);
  });

  // 不 await,直接返回
  return 'done';  // try/catch 在这里结束,100ms 后的 rejection 无人处理
}

main2().catch(e => console.log('main2 error'));  // 不会触发!
// 100ms 后:未处理的 rejection

陷阱4:Error.stack 格式不可依赖

// 各引擎的 stack 格式不同:

// V8 (Chrome/Node.js):
// Error: something went wrong
//     at Object.<anonymous> (/path/to/file.js:10:5)
//     at Module._compile (node:internal/modules/cjs/loader:1356:14)

// Firefox:
// fn@/path/to/file.js:10:5
// @/path/to/file.js:20:1

// Safari:
// fn@file:///path/to/file.js:10:5

// 错误:解析 stack 字符串提取调用信息
function getCallerName(error) {
  const lines = error.stack.split('\n');
  const callerLine = lines[2];  // "第2行是调用者"——在各引擎格式不同!
  return callerLine.match(/at (\w+)/)?.[1];  // 仅在 V8 中有效
}

// 正确:stack 只用于调试显示,不用于逻辑控制
try {
  doSomething();
} catch (e) {
  // 仅用于日志记录
  logger.error({ message: e.message, stack: e.stack });
  // 不要解析 stack 字符串
}

陷阱5:AggregateError 中单个错误的访问模式

// Promise.any 的正确错误处理
async function tryMultipleSources(urls) {
  try {
    const result = await Promise.any(
      urls.map(url => fetch(url).then(r => r.json()))
    );
    return result;
  } catch (e) {
    if (e instanceof AggregateError) {
      // 访问所有失败原因
      const reasons = e.errors.map(err => ({
        type: err.constructor.name,
        message: err.message,
      }));
      console.log('All sources failed:', reasons);

      // 找出是否所有失败都是同类型
      const allNetworkErrors = e.errors.every(err => err instanceof TypeError);
      if (allNetworkErrors) {
        throw new Error('Network unavailable');  // 聚合为更具体的错误
      }
    }
    throw e;  // 非 AggregateError 则重新抛出
  }
}

// AggregateError 的 errors 数组顺序与 Promise 数组顺序一致
// 即使某个 Promise 先 reject,其错误在 errors 中的位置也与输入顺序对应

小结

  1. 每条 JS 语句执行后产生 Completion Record,包含 type(normal/return/throw/break/continue)、value、target 三个字段。
  2. finally 的 Completion 只要不是 normal,就会完全覆盖 try/catch 的结果——包括静默覆盖正常 return 值。
  3. async 函数内的同步异常会被包装为 rejected Promise,try/catch 可以捕获;但 async 函数外部需要用 .catch()try/catch + await
  4. Error.stack 格式不是标准,各引擎实现不同,不应在业务逻辑中解析。
  5. AggregateError(ES2021)携带所有失败原因数组,用于 Promise.any 全部 reject 时的错误聚合。
本章评分
4.5  / 5  (3 评分)

💬 留言讨论