《TypeScript编程实战》3.2 全局错误边界与未捕获异常

本节讲 Result 覆盖不到的失败:Node 进程级的 uncaughtException 与 unhandledRejection 该怎么用;React 错误边界如何捕获渲染期异常,以及它捕获不到事件处理器与异步错误的原因;Next.js App Router 的 error.tsx 与 global-error.tsx 落点。最后给出统一的上报管线与去重、采样、flush 等实战细节。

本节目标:掌握 Result 覆盖不到的那部分失败——进程级未捕获异常与前端渲染期异常。学会正确使用 uncaughtException / unhandledRejection,理解「崩溃重启」为何优于「就地吞掉」;掌握 React 错误边界的类型写法与它的三个捕获盲区;掌握 Next.js App Router 的 error.tsx / global-error.tsx 分工;并搭出一条带去重、采样与 flush 的统一上报管线。

3.2 全局错误边界与未捕获异常

为什么还需要全局边界

上一节的 Result 能覆盖「你预料到」的失败。但真实系统里还有一大类异常根本不会走你的分支:

  • 第三方库在内部 throw,你没读它的源码;
  • 事件回调里的异常,不在任何 await 链上;
  • 忘记 await 的 Promise 被 reject,成了「游离的拒绝」;
  • React 组件在渲染期抛错,整棵组件树被卸载。

这些异常会一路逃逸到进程或浏览器的最外层。全局错误边界就是承接它们的最后一道网:它不负责「修复」,只负责记录、上报、并以可预期的姿态退出或降级。

Node 进程级:uncaughtException 与 unhandledRejection

Node 提供两个进程级钩子:

// bootstrap.ts —— 必须在业务代码之前注册
process.on("uncaughtException", (err, origin) => {
  // origin: 'uncaughtException' | 'unhandledRejection'
  console.error("[fatal] uncaughtException", err, origin);
});

process.on("unhandledRejection", (reason, promise) => {
  console.error("[fatal] unhandledRejection", reason);
});

关键认知:uncaughtException 触发后,进程状态已不可信。抛出点可能正持有一把没释放的锁、一个写了一半的文件、一个半更新的内存结构。Node 官方明确建议:在这里只做「记录并退出」,不要继续处理请求。

process.on("uncaughtException", (err) => {
  logger.fatal({ err }, "uncaught exception, shutting down");
  // 先 flush 日志与上报,再退出
  void flushTelemetry().finally(() => process.exit(1));
});

如果确实需要「观测但不干预」,用 uncaughtExceptionMonitor——它只监听,不接管默认行为:

process.on("uncaughtExceptionMonitor", (err) => {
  // 这里同步上报即可,进程仍会按默认逻辑崩溃
  captureError(err);
});
钩子是否接管默认行为适用场景
uncaughtException是(不调用 process.exit 则进程存活)记录后主动退出
uncaughtExceptionMonitor否,进程仍崩溃纯观测、上报
unhandledRejectionNode 15+ 默认升级为崩溃记录 + 退出
warning否采集 MaxListenersExceeded 等告警

生产环境的推荐姿态:让进程崩溃,由编排器(K8s / PM2 / systemd)拉起新实例。带病运行比崩溃更危险,因为它会持续返回错误数据。优雅关闭与健康检查的配合见 优雅关闭与健康检查 。

React 错误边界

React 的错误边界是一个类组件,必须实现 getDerivedStateFromError 或 componentDidCatch 之一:

import { Component, type ErrorInfo, type ReactNode } from "react";

type Props = { children: ReactNode; fallback: ReactNode };
type State = { hasError: boolean };

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(_error: Error): State {
    // 纯函数:只更新 state,不做副作用
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: ErrorInfo): void {
    // 副作用集中在这里:上报、打日志
    reportError(error, { componentStack: info.componentStack });
  }

  render(): ReactNode {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

两个生命周期的分工必须记牢:getDerivedStateFromError 是静态纯函数,只能返回新 state;componentDidCatch 才是做副作用的地方(上报、埋点)。把上报写进前者会触发 React 的警告。

错误边界有三个捕获盲区,这是最容易踩的坑:

盲区例子正确做法
事件处理器onClick 里抛错用 try/catch 或 Result
异步回调setTimeout / fetch().then 里抛错用 try/catch 或 Result
服务端渲染 / 边界自身边界组件自己抛错用 global-error.tsx 兜底

也就是说,错误边界只覆盖渲染期(render、生命周期、构造函数)的异常。异步与事件里的错误必须自己接住——这正是 Result/Either 与类型化错误 的用武之地。

用函数组件 + 自定义 Hook 触发边界

类组件写法繁琐,社区常用 react-error-boundary,也可以用自定义 Hook 手动把错误「抛回渲染期」:

import { useCallback, useState } from "react";

export function useThrowable() {
  const [error, setError] = useState<unknown>(null);
  if (error !== null) throw error; // 在渲染期抛出,交给 ErrorBoundary

  const capture = useCallback((e: unknown) => setError(e), []);
  return capture;
}

调用方在事件处理器里调用 capture(err),错误被「搬运」到渲染期,从而能被边界接住:

function SubmitButton() {
  const capture = useThrowable();
  return (
    <button
      onClick={() => {
        try {
          doRiskySyncWork();
        } catch (e) {
          capture(e); // 事件里的异常 → 渲染期 → 边界
        }
      }}
    >
      提交
    </button>
  );
}

Next.js App Router 的错误落点

App Router 用文件约定表达边界,层次清晰:

文件作用范围能否捕获自身 layout 的错误
error.tsx同级 page.tsx 及其子路由否
global-error.tsx根 layout 及以上(整站兜底)是
not-found.tsxnotFound() 调用与未匹配路由—

error.tsx 必须是客户端组件,并接收 reset 用于重试:

"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div role="alert">
      <h2>页面出错了</h2>
      <p>{error.digest ?? error.message}</p>
      <button onClick={reset}>重试</button>
    </div>
  );
}

注意 digest:服务端错误在生产环境会被脱敏,只保留一个 hash 值,用来在服务端日志里关联完整堆栈。这与 3.3 的脱敏原则一致——不要把内部细节暴露给用户。App Router 的完整类型体系见 Next.js App Router 类型 。

统一上报管线

无论进程端还是前端,最终都应该收敛到一个 reportError。它需要处理四件事:归一化、去重、采样、flush。

type ReportCtx = Record<string, string | number | undefined>;

const seen = new Map<string, number>();
const DEDUP_WINDOW_MS = 60_000;

function fingerprint(err: Error, ctx: ReportCtx): string {
  return `${err.name}:${err.message}:${ctx.route ?? ""}:${ctx.componentStack ?? ""}`;
}

export function reportError(err: unknown, ctx: ReportCtx = {}): void {
  const error = toError(err); // 见 3.1 的鸭子类型归一化
  const key = fingerprint(error, ctx);
  const now = Date.now();

  const last = seen.get(key) ?? 0;
  if (now - last < DEDUP_WINDOW_MS) {
    return; // 同一错误在窗口内只上报一次,防雪崩
  }
  seen.set(key, now);

  if (Math.random() > 0.1) return; // 采样:高流量下只报 10%

  void send({ name: error.name, message: error.message, stack: error.stack, ctx });
}

归一化是第一步:catch 到的可能是字符串、可能是 { message } 对象,必须先统一成 Error。去重防止某个循环里的错误把配额打爆。采样在 QPS 上万的服务里必不可少。flush 是收尾——进程退出前必须等待上报完成:

async function flushTelemetry(timeoutMs = 2000): Promise<void> {
  await Promise.race([
    Promise.allSettled([logger.flush(), sender.flush()]),
    new Promise((r) => setTimeout(r, timeoutMs)),
  ]);
}

常见坑与报错对照

坑一:在 uncaughtException 里 await 异步清理。 事件回调不等待 Promise,进程可能在清理完成前就退出。正确做法是显式在 finally 里 process.exit:

process.on("uncaughtException", (err) => {
  captureError(err);
  flushTelemetry().finally(() => process.exit(1)); // 不要裸 await
});

坑二:Sentry 等上报 SDK 未 flush 导致事件丢失。 进程退出会丢掉内存中未发送的事件。必须在 process.exit 之前调用 SDK 的 flush / close。

坑三:以为错误边界能捕获一切。 事件与异步里的错误边界一无所知,报错信息往往是:

Uncaught Error: boom
    at onClick (SubmitButton.tsx:12:11)

看到 Uncaught 前缀就说明它逃逸到了最外层,边界没接住。

坑四:getDerivedStateFromError 里做副作用。 会触发 React 警告,且可能被多次调用。

坑五:循环上报。 上报逻辑自身抛错,又被全局钩子捕获,再触发上报——必须在上报内部加 try/catch 短路。

想继续深入可延伸阅读 客户端崩溃日志与可观测性 与 OpenTelemetry 追踪 ——错误上报与链路追踪共用同一套上下文,把它们接在一起才能从「一个错误」还原出「一次请求」。

小结

本节补上了 Result 够不到的那层兜底。要点回顾:

  • uncaughtException 触发后进程状态不可信,只记录并退出;uncaughtExceptionMonitor 用于纯观测。
  • 生产环境的正确姿态是「崩溃 + 编排器重启」,而非带病运行。
  • React 错误边界只捕获渲染期异常,事件处理器与异步回调必须用 Result 或 try/catch 自己接住。
  • Next.js App Router 用 error.tsx / global-error.tsx / not-found.tsx 三层文件约定表达边界。
  • 统一上报管线要做归一化、去重、采样、flush 四件事。

边界接住的每一个错误,都需要留下可检索的现场——否则你只知道「炸了」,不知道「为什么炸」。下一节 结构化日志与脱敏 就来讲怎么把这些现场变成可聚合、可查询、且不泄露敏感信息的日志。

阅读导航:上一节:3.1 Result/Either 与类型化错误 · 下一节:3.3 结构化日志与脱敏 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes