发布日期

第 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.tsxSuspense 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 的实战配置。