本节目标:掌握 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 | 否,进程仍崩溃 | 纯观测、上报 |
unhandledRejection | Node 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.tsx | notFound() 调用与未匹配路由 | — |
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 结构化日志与脱敏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。