HCT Picker

Monochrome
Neutral
Tonal Spot
Vibrant
Expressive
Fidelity
Content
Rainbow
Fruit Salad
2021
2025
OK
JavaScript Promise 錯誤處理拓撲與同步異常防禦架構示意圖

Overview

Overview is generated by AI and may contain errors.

本文從底層微任務調度與 ECMAScript 規範出發,徹底解密 Promise 錯誤處理中同級互斥與鏈式承接的拓撲分歧,揭示同步異常在異步工廠中逃逸的架構缺陷,並給出基於 TypeScript Result 類型與 Promise.try 的企業級防禦性解決方案。

Expand
Copy Link

JavaScript/TypeScript 異步邊界防禦指南:從 ECMAScript Reaction 規範到強類型異常控制流

在 JavaScript 異步編程中,許多開發者將 .then(onFulfilled, onRejected).then(onFulfilled).catch(onRejected) 視為語意等價的寫法。這種認知偏差是生產環境中未捕獲異常(Unhandled Rejection)的重要誘因。更嚴重的是,當同步異常在 Promise 鏈初始化之前逃逸時,調用端的任何異步錯誤捕獲機制都將徹底失效。本文將從 ECMAScript 規範的底層微任務 Reaction 機制出發,深入剖析異步控制流的分歧點,並構建具備完全類型安全與邊界防禦能力的異步架構。

語義分歧:.then(f, g).then(f).catch(g) 的拓撲差異

在 ECMAScript 的 Promise 設計中,.then(onFulfilled, onRejected).then(onFulfilled).catch(onRejected) 構建的是兩種截然不同的狀態機轉換拓撲。

互斥執行模型

傳遞給同一個 .then() 實例的兩個回調函數處於同級的「互斥」關係。onFulfilled 僅在當前 Promise 決議為 fulfilled 時調用,而 onRejected 僅在當前 Promise 決議為 rejected 時調用。

這意味著:如果 onFulfilled 在執行過程中自身拋出異常,與其處於同一調用中的 onRejected 絕對無法捕獲該異常。

// 反模式:同級互斥導致的異常逃逸
function fetchAndProcessUserData(userId: string): Promise<void> {
  return fetchUserApi(userId).then(
    (response) => {
      // 若 JSON 解析失敗或數據斷言錯誤拋出異常
      const data = JSON.parse(response.rawBody);
      updateDashboard(data);
    },
    (networkError) => {
      // 這裡只能捕獲 fetchUserApi 的拒絕,無法捕獲上方 JSON.parse 的同步異常
      console.error('Network request failed:', networkError);
    }
  );
}

在上述反模式代碼中,若 fetchUserApi 成功 Resolve,但 JSON.parse 拋出語法錯誤,該錯誤將無法被同級的錯誤處理函數捕獲,而是轉化為 fetchAndProcessUserData 返回的 Promise 實例的 Reject 狀態。如果上層調用者沒有附加捕獲邏輯,將直接觸發運行時的 UnhandledPromiseRejection

鏈式下游承接模型

與之相對,.catch(onRejected) 本質上是 .then(null, onRejected) 的語法糖。當我們採用 .then(f).catch(g) 結構時,實際上構建了兩個串行連接的 Promise 實例:

// 最佳實踐:下游捕獲拓撲
function fetchAndProcessUserDataSafely(userId: string): Promise<void> {
  return fetchUserApi(userId)
    .then((response) => {
      const data = JSON.parse(response.rawBody);
      updateDashboard(data);
    })
    .catch((error) => {
      // 既能捕獲 fetchUserApi 的失敗,也能捕獲 then 回調內部的拋錯
      console.error('Pipeline execution failed:', error);
    });
}

其內部拓撲結構對比如下:

模式 A: promise.then(onFulfilled, onRejected)
[ Promise A ]

      ├── (Resolve) ──> onFulfilled ──> (Throws Error) ──> [ Promise B: Rejected ] (未被捕獲!)
      └── (Reject)  ──> onRejected

模式 B: promise.then(onFulfilled).catch(onRejected)
[ Promise A ]

      └── (Resolve) ──> onFulfilled ──> (Throws Error) ──> [ Promise B: Rejected ]

                                                                  └── (Reject) ──> onRejected (成功攔截)

PromiseReaction 隊列與錯誤冒泡機制

要從底層理解上述行為,必須查閱 ECMA-262 規範中關於 Promise.prototype.thenPerformPromiseThen 的定義。

PromiseReaction Records 的內部結構

在引擎內部(如 V8),每個 Promise 實例維護著內部插槽(Internal Slots):

  • [[PromiseState]]"pending""fulfilled""rejected"
  • [[PromiseResult]]:決議的值或拒絕的原因。
  • [[PromiseFulfillReactions]]:存儲成功時觸發的 Reaction 列表。
  • [[PromiseRejectReactions]]:存儲失敗時觸發的 Reaction 列表。

當調用 promise.then(onFulfilled, onRejected) 時,底層算法 PerformPromiseThen 會執行以下核心步驟:

  1. 創建一個新的 Promise 實例,記為 resultCapability.[[Promise]]
  2. onFulfilled 包裝為 PromiseReaction Record,存入上游 Promise 的 [[PromiseFulfillReactions]]
  3. onRejected 包裝為 PromiseReaction Record,存入上游 Promise 的 [[PromiseRejectReactions]]
  4. 這兩個 Reaction Record 均持有對 resultCapability 的引用。

錯誤穿透(Fallthrough)的實現

如果調用 .then(onFulfilled) 而未傳遞 onRejected,引擎會創建一個默認的「穿透型 Identity 處理器」:

// 規範內部默認的 Reject 處理邏輯偽代碼
function defaultThrower(reason: unknown) {
  throw reason;
}

當原始 Promise 被 Reject 時:

  1. 引擎調度微任務遍歷其 [[PromiseRejectReactions]]
  2. 執行默認的 defaultThrower,它會再次拋出該原因。
  3. 由於被包裝在 Reaction 執行上下文中,拋出的異常會使下游的 resultCapability.[[Promise]] 轉移為 Rejected 狀態。
  4. 錯誤沿著這條鏈路持續向下傳遞,直到遇見顯式定義了 onRejected 的 Reaction Record。

這解釋了為什麼 .catch() 可以放置在鏈條的最末端並捕獲整條鏈路上的任何錯誤,而同級的 .then(f, g) 無法攔截自身 f 的錯誤。

同步異常逃逸:異步封裝中的致命盲區

在構建底層庫或業務 SDK 時,最危險的反模式是在返回 Promise 實例之前拋出同步異常。這種行為打破了 API 的契約,使得異步捕獲機制徹底失效。

混合型拋錯的危害

觀察以下常見的防禦性編程反模式:

// 危險反模式:同步拋錯與異步 Promise 混合
function queryDatabase(sql: string, params: unknown[]): Promise<QueryResult> {
  // 同步參數校驗
  if (!sql.startsWith('SELECT')) {
    throw new Error('Only SELECT queries are supported.'); // 同步異常在此處拋出!
  }

  // 構造異步操作
  return new Promise((resolve, reject) => {
    driver.execute(sql, params, (err, result) => {
      if (err) reject(err);
      else resolve(result);
    });
  });
}

當調用者以標準的 Promise 方式調用此函數時:

// 調用端代碼
function executePipeline() {
  queryDatabase('DELETE FROM users', [])
    .then((data) => console.log(data))
    .catch((err) => console.error('Caught error:', err)); // 無法捕獲同步異常!
}

此時,queryDatabase 在執行到第 4 行時直接終止調用棧,根本不會返回 Promise 實例。調用端的 .catch() 無法被附加,導致該異常變為全局未捕獲的同步異常(Uncaught Exception),直接引發 Node.js 進程崩潰或前端未捕獲錯誤事件。

異常邊界分析

要理解異常的捕獲邊界,必須區分以下三種代碼塊的執行環境:

  1. 函數體前置同步代碼:在 new Promise 構造之前,異常直接在當前調用棧拋出。
  2. Promise Executor 內部代碼:傳給 new Promise((resolve, reject) => { ... }) 的回調函數是同步執行的,但規範規定 Executor 內部被 try-catch 包裹,任何拋出的同步異常會自動轉換為 reject(err)
  3. Promise .then / .catch 回調代碼:在微任務隊列中執行,拋出的異常由下游 Promise 接收。
function traceExecution() {
  console.log('1. 同步執行開始');
  
  const p = new Promise((resolve, reject) => {
    console.log('2. Executor 同步執行');
    throw new Error('Executor 內部異常'); // 自動轉化為 reject
  });

  p.catch((err) => {
    console.log('4. 微任務隊列捕獲:', err.message);
  });

  console.log('3. 同步執行結束');
}

traceExecution();
// 輸出順序:
// 1. 同步執行開始
// 2. Executor 同步執行
// 3. 同步執行結束
// 4. 微任務隊列捕獲: Executor 內部異常

統一異步安全邊界方案

為了消除同步異常逃逸,必須保證函數始終返回一個 Rejected Promise,而不是同步向調用棧拋出錯誤。

方案 A:使用 async 語法糖包裝

async 函數具備由語言級別保障的特性:其函數體內部的任何同步 throw 都會被隱式轉換為 Rejected Promise

async function queryDatabaseSafe(sql: string, params: unknown[]): Promise<QueryResult> {
  if (!sql.startsWith('SELECT')) {
    throw new Error('Only SELECT queries are supported.'); // 自動封裝為 Rejected Promise
  }
  return driver.executeAsync(sql, params);
}

方案 B:使用 ECMAScript 提議的 Promise.try

在原生未引入 async 封裝開銷或需要保持純函數調用鏈時,Promise.try(Stage 4)提供了最標準的解法:

function queryDatabaseWithPromiseTry(sql: string, params: unknown[]): Promise<QueryResult> {
  return Promise.try(() => {
    if (!sql.startsWith('SELECT')) {
      throw new Error('Only SELECT queries are supported.');
    }
    return driver.executeAsync(sql, params);
  });
}

Promise.try(fn) 的 Polyfill 實現精確體現了防禦性邊界的思想:

if (!Promise.try) {
  Promise.try = function <T>(fn: () => T | PromiseLike<T>): Promise<T> {
    return new Promise((resolve) => {
      resolve(fn()); // 利用 Executor 自動吸收同步 throw 並轉化為 reject 的特性
    });
  };
}

從強類型 Result 到安全控制流

在 TypeScript 生態中,原生 Promise<T> 存在一個重大的架構缺陷:它只標註了 Resolved 的類型 T,而 Reject 的類型永遠是隱式的 anyunknown。這導致調用者無法在編譯期感知可能出現的業務異常。

為了構建企業級的防禦性架構,我們可以融合函數式編程中的 Railway Oriented Programming 思想與 TypeScript 的 Discriminated Unions。

設計強類型 Result 異步包裝器

定義不可變的、具備強類型區分標識的 Result 類型:

export type Result<T, E = Error> =
  | { readonly ok: true; readonly data: T; readonly error: null }
  | { readonly ok: false; readonly data: null; readonly error: E };

export const Result = {
  success<T>(data: T): Result<T, never> {
    return { ok: true, data, error: null };
  },
  failure<E>(error: E): Result<never, E> {
    return { ok: false, data: null, error };
  }
};

實現防禦性異步邊界容器 safeAsync

構建一個完全防止同步異常逃逸、消除未捕獲 Rejection,並將異常轉換為強類型數據結構的高階函數:

export async function safeAsync<T, E = Error>(
  promiseOrFn: Promise<T> | (() => Promise<T> | T),
  errorMapper?: (err: unknown) => E
): Promise<Result<T, E>> {
  try {
    // 兼顧直接傳入 Promise 實例與傳入工廠函數(防禦同步異常)
    const executedPromise = typeof promiseOrFn === 'function' ? promiseOrFn() : promiseOrFn;
    const data = await executedPromise;
    return Result.success(data);
  } catch (rawError: unknown) {
    const mappedError = errorMapper
      ? errorMapper(rawError)
      : (rawError instanceof Error ? rawError : new Error(String(rawError))) as unknown as E;
    
    return Result.failure(mappedError);
  }
}

消費端利用 Type Narrowing 實現零崩潰業務流

通過此架構,消費端代碼無需編寫脆弱且容易遺漏的 try-catch.catch(),而是強制通過 TypeScript 的類型收窄處理錯誤:

// 定義具體的業務異常類型
class NetworkError extends Error { readonly _tag = 'NetworkError'; }
class ValidationError extends Error { readonly _tag = 'ValidationError'; }

type DomainError = NetworkError | ValidationError;

async function executeUserWorkflow(userId: string) {
  // 完全消除未捕獲異常與同步崩潰
  const result = await safeAsync<UserData, DomainError>(() => {
    if (!userId) throw new ValidationError('Invalid User ID');
    return api.fetchUser(userId);
  });

  // 編譯器強制要求進行分支判斷
  if (!result.ok) {
    // 此處 result.error 收窄為 DomainError
    console.error(`Workflow failed with error: ${result.error.message}`);
    return;
  }

  // 此處 result.data 安全收窄為 UserData
  console.log(`User loaded: ${result.data.name}`);
}

生產環境邊界與反模式深度剖析

時序一致性原則

在異步防禦性設計中,最嚴重的架構反模式之一是「釋放 Zalgo」(Releasing Zalgo)——即一個函數在某種條件下是同步執行的,而在另一種條件下是異步執行的

// 反模式:不一致的時序(Zalgo)
const cache = new Map<string, string>();

function getDataWithZalgo(key: string, cb: (val: string) => void) {
  if (cache.has(key)) {
    cb(cache.get(key)!); // 同步回調!
  } else {
    fetch(`/data/${key}`).then((res) => res.text()).then((val) => {
      cache.set(key, val);
      cb(val); // 異步微任務回調!
    });
  }
}

這種代碼會導致調用方的狀態變更時序完全混亂。防禦性編程要求:API 的時序契約必須完全一致,永遠不要在異步 API 中同步觸發回調。即使命中緩存,也必須通過 Promise.resolve()queueMicrotask 將結果延遲至微任務隊列中分發。

調用棧可追溯性與 async/await 優勢

在長 Promise 鏈中,頻繁使用 .then() 進行手動嵌套和返回會導致 V8 引擎難以追蹤原始的異步調用棧。

當使用現代 async/await 時,V8 引擎利用其內建的 Zero-Cost Async Stack Traces 技術,能夠在執行微任務時自動還原完整的異步因果鏈(Causality Chain)。

// 鏈式 Promise:調用棧在跨越微任務邊界時可能丟失上游上下文
function chainTrace() {
  return serviceA().then(() => serviceB()).then(() => serviceC());
}

// async/await:V8 引擎在堆棧中保留父層協程幀,提供完整的 Stack Trace
async function modernTrace() {
  await serviceA();
  await serviceB();
  await serviceC();
}

全局進程守護的正確定位

防禦性編程是業務層面的第一道防線,但不能替代進程級的容災底線。

在 Node.js 生產環境中,必須明確 uncaughtExceptionunhandledRejection 的職責邊界:

  • 業務代碼中嚴禁將全局事件監聽器作為業務控制流的一部分。
  • 一旦觸發 uncaughtException,說明進程的內存狀態已處於不確定(Corrupted)狀態,最安全的做法是記錄致命日誌並執行優雅退出(Graceful Shutdown)。
// 基礎設施層級的全局守護標準模版
process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
  // 記錄結構化日誌並上報監控系統
  logger.error('Unhandled Rejection at:', {
    promise,
    reason: reason instanceof Error ? { message: reason.message, stack: reason.stack } : reason
  });
});

process.on('uncaughtException', (error: Error) => {
  logger.fatal('Uncaught Exception thrown. Initiating graceful shutdown...', {
    error: error.message,
    stack: error.stack
  });

  // 關閉 HTTP 服務器、斷開數據庫連接並退出進程
  server.close(() => {
    process.exit(1);
  });

  // 設置超時強退保護
  setTimeout(() => process.exit(1), 5000).unref();
});

架構原則總結

構建具備工業級魯棒性的異步代碼,需嚴格遵循以下架構原則:

  1. 拓撲隔離原則:永遠不要將 .then(onFulfilled, onRejected) 作為整體的錯誤處理方案,必須在下游使用獨立的 .catch() 或改用 async/awaittry-catch 塊。
  2. 零同步逃逸原則:任何返回 Promise 的函數,其內部的參數校驗與初始化邏輯必須被封裝在 asyncPromise.try 邊界之內,杜絕同步 throw
  3. 時序確定性原則:保持 API 行為的一致性,異步接口在任何分支路徑下均不得同步返回或同步執行回調。
  4. 顯式類型化原則:利用 TypeScript Discriminated Unions 構建 Result<T, E> 容器,將不可控的異步運行時異常轉化為可控的編譯期數據流。