- 发布日期
第 33 讲:错误处理:error.tsx / not-found.tsx / global-error / error boundary
error.tsx、not-found.tsx、global-error.tsx 与 React Error Boundary 的错误处理体系
第 8 讲讲过 LoaderTree 把
layout/page/error/not-found装配成树,第 12 讲讲过 Server Action 的错误处理。本讲把"错误"作为一类一等公民单独深挖:React error boundary 在 App Router 里如何被自动注入、error.tsx/not-found.tsx/global-error.tsx三者的职责边界、为什么error.tsx必须是 Client Component、notFound()和redirect()是怎么"用 throw 实现"的、以及生产环境如何安全地把错误聚合到 Sentry。
学习目标
- 看懂
ErrorBoundaryHandler的实现:getDerivedStateFromError+ navigation reset。 - 知道
notFound()/redirect()是抛NEXT_NOT_FOUND/NEXT_REDIRECT特殊错误,沿组件树向上传播被对应 boundary 捕获。 - 区分 4 类错误的最终归属:组件 render 错(→ 最近的 error.tsx)/ notFound(→ 最近的 not-found.tsx)/ root layout 错(→ global-error.tsx)/ Server Action 错(→ 返回客户端处理)。
- 配置 production 错误聚合:
onRequestError+ Sentry / OTel。 - 排查常见错误处理问题:error.tsx 没生效、dev 看到 overlay 但 prod 白屏、Server Action error 没被客户端 catch。
一、3 个 boundary 文件的角色
app/
├── layout.tsx ← root layout(不可崩,崩了进 global-error)
├── global-error.tsx ← 替换整个 <html> 的最终兜底
├── error.tsx ← 任意 segment 的 error UI(最近的)
├── not-found.tsx ← 任意 segment 的 404 UI(最近的)
├── loading.tsx ← Suspense fallback(第 18 讲)
└── posts/
├── error.tsx ← /posts 子树的 error UI
├── not-found.tsx ← /posts 子树的 404 UI
└── [id]/
├── page.tsx ← 如果 throw / notFound,被上面的 error/not-found 接住
└── error.tsx ← 更内层的 error UI
LoaderTree 装配时,每个 segment 的 error.tsx 会被包成一个 React error boundary 套在 page/layout 外面。因此:
- segment 内 throw → 沿组件树上抛 → 命中最近的 error.tsx
- 不存在 error.tsx 的 segment → 错误继续向上传到祖先 segment
必须是 Client Component
error.tsx 顶部必须 'use client'。原因:
- error boundary 是 React 的 class component(
getDerivedStateFromError) - 需要
useState/useEffect处理 reset 和重试 - 必须在客户端处理交互(用户点 "Try again" 按钮)
global-error.tsx 是终极兜底
// app/global-error.tsx
"use client";
export default function GlobalError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<html>
<body>
<h2>Something went very wrong!</h2>
<button onClick={reset}>Try again</button>
</body>
</html>
);
}
特殊点:
- 必须返回
<html><body>...,因为它替换 root layout(root layout 本身崩了) - dev 模式下不会触发(dev 用 overlay)
- prod 才会真的渲染
二、ErrorBoundary 实现细节
export class ErrorBoundaryHandler extends React.Component<
ErrorBoundaryHandlerProps,
ErrorBoundaryHandlerState
> {
static contextType = AppRouterContext
declare context: AppRouterInstance | null
static getDerivedStateFromError(
thrownValue: unknown
): Partial<ErrorBoundaryHandlerState> {
if (isNextRouterError(thrownValue)) {
// Re-throw if an expected internal Next.js router error occurs
// this means it should be handled by a different boundary (such as a NotFound boundary in a parent segment)
throw thrownValue
}
return { error: { thrownValue } }
}
static getDerivedStateFromProps(
props: ErrorBoundaryHandlerProps,
state: ErrorBoundaryHandlerState
): ErrorBoundaryHandlerState | null {
// ...
if (props.pathname !== state.previousPathname && state.error) {
return {
error: null,
previousPathname: props.pathname,
}
}
return {
error: state.error,
previousPathname: props.pathname,
}
}
reset = () => {
this.setState({ error: null })
}
unstable_retry = () => {
startTransition(() => {
this.context?.refresh()
this.reset()
})
}
// ...
}
3 个关键设计:
isNextRouterError重新抛出:notFound()/redirect()抛出的NEXT_NOT_FOUND/NEXT_REDIRECT不被普通 error.tsx 捕获,而是穿透到对应的 NotFoundBoundary / RedirectBoundary。- 导航自动 reset:用户错误后点 "Try again" 没用怎么办?换个 page 试试。
getDerivedStateFromProps监听 pathname 变化,自动清错误。 unstable_retry= router.refresh() + reset:这是"刷新当前 page"的语义,不是仅仅清错误状态。如果错误来自服务端 render(如 fetch 失败),单纯 reset 没用,必须 refetch。
三、notFound() 与 redirect() 是怎么"用 throw 实现"
// packages/next/src/client/components/not-found.ts (简化)
export function notFound(): never {
const error = new Error(NEXT_NOT_FOUND);
(error as any).digest = NEXT_NOT_FOUND_DIGEST;
throw error;
}
redirect() 类似:
export function redirect(
url: string,
type: RedirectType = RedirectType.push,
): never {
const error = new Error(REDIRECT_ERROR_CODE);
(error as any).digest = `${REDIRECT_ERROR_CODE};${type};${url};...`;
throw error;
}
这种"用异常做控制流"的设计:
| 优点 | 缺点 |
|---|---|
调用点干净:notFound() 一行不需要返回值传播 | try/catch 会误捕获 |
| 跨任意嵌套组件可中断 | 需要框架专门识别 + re-throw |
| 配合 React Suspense 一起工作 | 学习成本高 |
所以你在 server component 里 try/catch fetch 时要小心:
// ❌ 误捕获 notFound
try {
const post = await db.posts.findUnique(...)
if (!post) notFound()
return <Post {...post} />
} catch (err) {
// 这里 catch 到了 NEXT_NOT_FOUND!
return <Fallback />
}
正确:
const post = await db.posts.findUnique(...)
if (!post) notFound() // 移到 try 外
try {
return <Post {...post} />
} catch (err) {
return <Fallback />
}
或者识别 digest:
catch (err) {
if ((err as any).digest?.startsWith('NEXT_NOT_FOUND')) throw err
if ((err as any).digest?.startsWith('NEXT_REDIRECT')) throw err
return <Fallback />
}
四、4 类错误的归属
| 错误来源 | 归属 boundary | 何时触发 |
|---|---|---|
| Server Component render 异常 | 最近的 error.tsx | render 阶段 |
notFound() | 最近的 not-found.tsx | 任意 server/client component |
| root layout 异常 | global-error.tsx | layout 崩 |
| Server Action 异常 | 客户端 catch(无 boundary) | action 调用 |
| Client Component render 异常 | 最近的 error.tsx | client render |
fetch in use() 异常 | 最近的 error.tsx | Suspense 抛 promise 错 |
generateMetadata 异常 | 最近的 error.tsx(page render 失败) | metadata 生成 |
| Route Handler 异常 | 默认 500 响应 | route handler |
Server Action 错误的特殊性
"use server";
export async function submitForm(data: FormData) {
if (Math.random() < 0.5) throw new Error("random fail");
return { ok: true };
}
"use client";
export function Form({ action }: any) {
const [pending, startTransition] = useTransition();
const onSubmit = async (data: FormData) => {
startTransition(async () => {
try {
await action(data);
} catch (err) {
// ✅ 这里能 catch 到
}
});
};
}
关键点:
- Server Action 抛出的 Error 会被 RSC 序列化送回客户端
- prod 模式只发送
digest(hash),不发 stack trace(防泄漏) - 客户端能 catch;推荐用 useActionState + 返回
{ error: '...' }而非 throw
五、Production vs Dev 的错误展示
| 环境 | 行为 |
|---|---|
| dev | red box overlay + 完整 stack + source-mapped 文件名 |
| prod (HTML SSR 阶段) | 渲染 error.tsx;client side console 仅有 digest |
| prod (CSR 转 client 后) | error.tsx + 完整 client 错误信息 |
为什么 prod 隐藏 server stack?
- 暴露服务端文件路径(如
/var/www/...)有信息泄露风险 - 错误消息可能含 SQL / 内部字段名
如果你想看 prod stack,用 experimental.serverComponentsHmrCache 或自己在 onRequestError 里 console.log。
六、错误聚合:onRequestError 钩子
next.config.js 配置:
// instrumentation.ts (新 API,推荐)
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./instrumentation-node')
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./instrumentation-edge')
}
}
export const onRequestError = async (
error: Error,
request: {
path: string
method: string
headers: Record<string, string>
},
context: {
routerKind: 'Pages Router' | 'App Router'
routePath: string
routeType: 'render' | 'route' | 'action' | 'middleware'
renderSource?: 'react-server-components' | 'react-server-components-payload' | 'server-rendering'
revalidateReason?: 'on-demand' | 'stale'
}
) => {
// 发到 Sentry / Datadog / 自家日志系统
await sendToSentry({
error,
extras: { ...request, ...context },
})
}
onRequestError 在每个请求遇到未捕获错误时被调用,包括:
- Server Component render error
- Route Handler 抛出
- Server Action 抛出
- Middleware 抛出
- generateMetadata 抛出
但不包括 client error——客户端要用 error.tsx 里的 useEffect 自己上报:
"use client";
import { useEffect } from "react";
export default function Error({ error }: { error: Error }) {
useEffect(() => {
// 客户端上报
Sentry.captureException(error);
}, [error]);
return <h2>Error: {error.message}</h2>;
}
Sentry 集成模式
// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const Sentry = await import("@sentry/nextjs");
Sentry.init({ dsn: process.env.SENTRY_DSN });
}
}
export const onRequestError = (error: Error, request: any, context: any) => {
const Sentry = require("@sentry/nextjs");
Sentry.captureRequestError(error, request, context);
};
@sentry/nextjs 自动注入 onRequestError,无需手写。
七、try/catch 与 Suspense 的边界
async function Posts() {
const posts = await db.posts.findMany(); // 可能 throw
return <List posts={posts} />;
}
// 父组件
<Suspense fallback={<Loading />}>
<Posts />
</Suspense>;
如果 db.posts.findMany() 抛错:
- throw promise rejection
- 沿组件树上抛
- 跳过 Suspense(Suspense 只接 promise pending,不接 rejection)
- 命中最近的 error boundary
所以 error.tsx 既能接住"sync throw"也能接住"async reject"。
但**use()/unstable_async() 等 React 19 hook 把 promise 转为同步**,仍能被 boundary 捕获。
八、生产排障实战
案例 1:error.tsx 没生效
症状:page 里 throw new Error,但 error.tsx 没渲染,反而是白屏。
排查:
- error.tsx 顶部有
'use client'吗? - error.tsx 是否在比 page 更深的目录?应该跟 page 同级或祖先
- dev 模式被 overlay 盖了?看 console
- 是不是 root layout 抛错?那只能 global-error 接
案例 2:notFound() 不工作
症状:调了 notFound() 但仍渲染 page 内容。
排查:
notFound()之后没 return,代码继续执行了?notFound()类型是never,编译器应警告notFound()被外层 try/catch 误捕获?digest 检查- 没有 not-found.tsx?会用默认 fallback
案例 3:Server Action 抛错客户端没 catch
症状:Server Action throw,浏览器控制台报 "uncaught",没有客户端处理。
排查:
- action 调用没包在 try/catch 或 startTransition?
- 推荐改用
useActionState:
"use client";
import { useActionState } from "react";
function Form() {
const [state, action] = useActionState(submitForm, { error: null });
return (
<form action={action}>
{state.error && <p style={{ color: "red" }}>{state.error}</p>}
...
</form>
);
}
action 里返回 { error: '...' } 而非 throw。
案例 4:dev overlay 太烦,想关掉
不能完全关。但可以:
- 用
__NEXT_SHOW_IGNORE_LISTED=true显示完整 stack(默认折叠 framework frames) - prod build + start 看真实生产行为:
pnpm build && pnpm start
九、配套 fixture
fixtures/lecture-33/ 提供:
/throw:Server Component 抛错 → 最近 error.tsx/notfound:调notFound()→ not-found.tsx/nested/sub:嵌套 error boundary(哪个接住)/action-error:Server Action 抛错的客户端 catch 模式instrumentation.ts:演示onRequestError
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-33
pnpm install && pnpm build && pnpm start
# http://localhost:3033
十、本讲小结
- 3 文件分工:
error.tsx(segment)/not-found.tsx(segment)/global-error.tsx(终极兜底)。 - error.tsx 必须
'use client',本质是 React class component 的getDerivedStateFromError。 notFound()/redirect()是抛特殊 Error,需要框架按 digest 识别并 re-throw 到对应 boundary。onRequestError(instrumentation.ts) 是 production 错误聚合的标准 hook。- Server Action 错误返回客户端,用
useActionState优雅处理。
下讲预告
第 34 讲《Instrumentation、OpenTelemetry、日志与可观测性》。会深入 instrumentation.ts 的 register / onRequestError,next.js 内置的 OpenTelemetry tracer(packages/next/src/server/lib/trace/),span 命名约定,以及把 trace 接到 Jaeger / Honeycomb / Datadog 的实战配置。