发布日期

第 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。

学习目标

  1. 看懂 ErrorBoundaryHandler 的实现:getDerivedStateFromError + navigation reset。
  2. 知道 notFound() / redirect() 是抛 NEXT_NOT_FOUND / NEXT_REDIRECT 特殊错误,沿组件树向上传播被对应 boundary 捕获。
  3. 区分 4 类错误的最终归属:组件 render 错(→ 最近的 error.tsx)/ notFound(→ 最近的 not-found.tsx)/ root layout 错(→ global-error.tsx)/ Server Action 错(→ 返回客户端处理)。
  4. 配置 production 错误聚合:onRequestError + Sentry / OTel。
  5. 排查常见错误处理问题: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 实现细节

116:packages/next/src/client/components/error-boundary.tsx
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 个关键设计:

  1. isNextRouterError 重新抛出:notFound() / redirect() 抛出的 NEXT_NOT_FOUND / NEXT_REDIRECT 不被普通 error.tsx 捕获,而是穿透到对应的 NotFoundBoundary / RedirectBoundary。
  2. 导航自动 reset:用户错误后点 "Try again" 没用怎么办?换个 page 试试。getDerivedStateFromProps 监听 pathname 变化,自动清错误。
  3. 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.tsxrender 阶段
notFound()最近的 not-found.tsx任意 server/client component
root layout 异常global-error.tsxlayout 崩
Server Action 异常客户端 catch(无 boundary)action 调用
Client Component render 异常最近的 error.tsxclient render
fetch in use() 异常最近的 error.tsxSuspense 抛 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 的错误展示

环境行为
devred 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() 抛错:

  1. throw promise rejection
  2. 沿组件树上抛
  3. 跳过 Suspense(Suspense 只接 promise pending,不接 rejection)
  4. 命中最近的 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 没渲染,反而是白屏。

排查:

  1. error.tsx 顶部有 'use client' 吗?
  2. error.tsx 是否在比 page 更深的目录?应该跟 page 同级或祖先
  3. dev 模式被 overlay 盖了?看 console
  4. 是不是 root layout 抛错?那只能 global-error 接

案例 2:notFound() 不工作

症状:调了 notFound() 但仍渲染 page 内容。

排查:

  1. notFound() 之后没 return,代码继续执行了?notFound() 类型是 never,编译器应警告
  2. notFound() 被外层 try/catch 误捕获?digest 检查
  3. 没有 not-found.tsx?会用默认 fallback

案例 3:Server Action 抛错客户端没 catch

症状:Server Action throw,浏览器控制台报 "uncaught",没有客户端处理。

排查:

  1. action 调用没包在 try/catch 或 startTransition?
  2. 推荐改用 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

十、本讲小结

  1. 3 文件分工:error.tsx(segment)/ not-found.tsx(segment)/ global-error.tsx(终极兜底)。
  2. error.tsx 必须 'use client',本质是 React class component 的 getDerivedStateFromError。
  3. notFound() / redirect() 是抛特殊 Error,需要框架按 digest 识别并 re-throw 到对应 boundary。
  4. onRequestError(instrumentation.ts) 是 production 错误聚合的标准 hook。
  5. 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 的实战配置。