Next.js 错误处理:预期失败、路由边界和全局恢复

应用里的错误可以分为两类:正常业务流程中可能出现的预期失败,以及意味着程序缺陷或异常状态的未捕获异常。前者应显式建模为返回值,让用户看到可处理的信息;后者通过错误边界显示后备界面,避免整个应用的界面一起崩溃。

处理预期错误

服务器端表单校验失败、请求返回业务错误,都属于正常运行中可以预期的情况。这类失败应明确返回给客户端,而不是统一抛成异常。

Server Functions 与表单状态

可以用 React 的 useActionState 接收 Server Functions 返回的状态。把预期的业务失败写成返回值,避免用抛异常和 try/catch 模拟普通校验分支;网络连接异常等意外失败则仍需要按应用的异常策略处理。

以下分别是 app/actions.ts 与 app/actions.js。官方源例子直接把对象交给原生 fetch 的 body,这不是有效的 BodyInit;这里明确改成 JSON 字符串并加上相应请求头,同时校验表单字符串并保证成功分支也返回一致的状态。接口地址保留官方示例,实际接口的认证、响应格式和请求契约需另行确认;本文没有调用该接口。

'use server'

export async function createPost(prevState: any, formData: FormData) {
  const title = formData.get('title')
  const content = formData.get('content')

  if (typeof title !== 'string' || typeof content !== 'string' ||
      !title.trim() || !content.trim()) {
    return { message: 'Title and content are required' }
  }

  const res = await fetch('https://api.vercel.app/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, content }),
  })

  if (!res.ok) {
    return { message: 'Failed to create post' }
  }

  return { message: 'Post created' }
}
'use server'

export async function createPost(prevState, formData) {
  const title = formData.get('title')
  const content = formData.get('content')

  if (typeof title !== 'string' || typeof content !== 'string' ||
      !title.trim() || !content.trim()) {
    return { message: 'Title and content are required' }
  }

  const res = await fetch('https://api.vercel.app/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, content }),
  })

  if (!res.ok) {
    return { message: 'Failed to create post' }
  }

  return { message: 'Post created' }
}

把 action 传入 useActionState,用返回的 state 显示信息,并用 pending 禁用重复提交。表单分别放在 app/ui/form.tsx 与 app/ui/form.js;界面文本沿用官方代码。

'use client'

import { useActionState } from 'react'
import { createPost } from '@/app/actions'

const initialState = {
  message: '',
}

export function Form() {
  const [state, formAction, pending] = useActionState(createPost, initialState)

  return (
    <form action={formAction}>
      <label htmlFor="title">Title</label>
      <input type="text" id="title" name="title" required />
      <label htmlFor="content">Content</label>
      <textarea id="content" name="content" required />
      {state?.message && <p aria-live="polite">{state.message}</p>}
      <button disabled={pending}>Create Post</button>
    </form>
  )
}
'use client'

import { useActionState } from 'react'
import { createPost } from '@/app/actions'

const initialState = {
  message: '',
}

export function Form() {
  const [state, formAction, pending] = useActionState(createPost, initialState)

  return (
    <form action={formAction}>
      <label htmlFor="title">Title</label>
      <input type="text" id="title" name="title" required />
      <label htmlFor="content">Content</label>
      <textarea id="content" name="content" required />
      {state?.message && <p aria-live="polite">{state.message}</p>}
      <button disabled={pending}>Create Post</button>
    </form>
  )
}

这里的 aria-live="polite" 让状态更新可以被辅助技术读出。客户端的 required 只改善交互,服务器仍必须校验输入;服务器返回的错误信息也不应包含凭据、堆栈或内部数据。

Server Components

在 Server Component 中获取数据时,可以依据响应渲染错误信息,或调用 redirect。下面是 app/page.tsx 和 app/page.js 的结构示例。https://... 和返回内容均为官方占位符,须替换成自己的数据源与界面;这里把状态检查放在解析 JSON 之前,避免错误响应并非 JSON 时掩盖预期错误。

export default async function Page() {
  const res = await fetch(`https://...`)

  if (!res.ok) {
    return 'There was an error.'
  }

  const data = await res.json()
  // Render the actual response data here.
  return '...'
}
export default async function Page() {
  const res = await fetch(`https://...`)

  if (!res.ok) {
    return 'There was an error.'
  }

  const data = await res.json()
  // Render the actual response data here.
  return '...'
}

HTTP 非成功状态与连接中断是不同情况。上面的分支处理响应状态;fetch 抛出异常时则不会进入该分支。是否把特定连接失败映射成业务提示,要根据产品和重试策略决定。

未找到资源

在路由段里调用 notFound,再通过 not-found.js 显示 404 界面。

以下对应 app/blog/[slug]/page.tsx 和 app/blog/[slug]/page.js。动态参数是 Promise,需要先 await params。getPostBySlug 是项目中的数据访问函数;源例子假定它同步返回记录或空值。如果你的实现返回 Promise,也必须等待结果后再判断。

import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/posts'

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = getPostBySlug(slug)

  if (!post) {
    notFound()
  }

  return <div>{post.title}</div>
}
import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/posts'

export default async function Page({ params }) {
  const { slug } = await params
  const post = getPostBySlug(slug)

  if (!post) {
    notFound()
  }

  return <div>{post.title}</div>
}

该目录下的 not-found.tsx 与 not-found.js 可以分别写为:

export default function NotFound() {
  return <div>404 - Page Not Found</div>
}
export default function NotFound() {
  return <div>404 - Page Not Found</div>
}

处理未捕获异常

未捕获异常是正常业务流程不应出现的失败,例如程序缺陷或无效内部状态。可以抛出错误,让错误边界捕获并显示后备界面。

嵌套错误边界

Next.js 的错误边界捕获子组件的异常,用后备界面替换失败的组件树。在路由段中创建 error.js,导出 React 组件即可。该组件必须是 Client Component。

app/dashboard/error.tsx 与 app/dashboard/error.js 分别如下:

'use client' // Error boundaries must be Client Components

import { useEffect } from 'react'

export default function ErrorPage({
  error,
  retry,
}: {
  error: Error & { digest?: string }
  retry: () => void
}) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error)
  }, [error])

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button
        onClick={
          // Attempt to recover by re-fetching and re-rendering the segment
          () => retry()
        }
      >
        Try again
      </button>
    </div>
  )
}
'use client' // Error boundaries must be Client Components

import { useEffect } from 'react'

export default function ErrorPage({ error, retry }) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error)
  }, [error])

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button
        onClick={
          // Attempt to recover by re-fetching and re-rendering the segment
          () => retry()
        }
      >
        Try again
      </button>
    </div>
  )
}

异常向上冒泡到最近的父级错误边界。在路由组件层级的不同层放置 error.tsx,可以控制恢复范围:局部页面的失败可以由局部边界接管;局部没有处理时继续交给上层。边界捕获其子树的错误,不应假定它能包住同一层位于其外部的布局。

原文的层级图表达“异常从失败子组件向最近上层边界冒泡,未命中再向更高层传播”。这一教学关系已在文字中说明,官方图链接保留在文末资料中;本文不制作运行截图。

需要组件级恢复时,当前文档提供 catchError,它返回一个能包裹任意组件子树的边界。app/custom-error-boundary.tsx 和 .js:

'use client'

import { catchError, type ErrorInfo } from 'next/error'

function ErrorFallback(props: { title: string }, { error, retry }: ErrorInfo) {
  return (
    <div>
      <h2>{props.title}</h2>
      <p>{error.message}</p>
      <button onClick={() => retry()}>Try again</button>
    </div>
  )
}

export default catchError(ErrorFallback)
'use client'

import { catchError } from 'next/error'

function ErrorFallback(props, { error, retry }) {
  return (
    <div>
      <h2>{props.title}</h2>
      <p>{error.message}</p>
      <button onClick={() => retry()}>Try again</button>
    </div>
  )
}

export default catchError(ErrorFallback)

在布局或页面中用返回的组件包住需要保护的子树,例如 app/some-component.tsx 和 .js:

import ErrorBoundary from './custom-error-boundary'

export default function Component({ children }: { children: React.ReactNode }) {
  return <ErrorBoundary title="Dashboard Error">{children}</ErrorBoundary>
}
import ErrorBoundary from './custom-error-boundary'

export default function Component({ children }) {
  return <ErrorBoundary title="Dashboard Error">{children}</ErrorBoundary>
}

此处的 retry、catchError 与导入路径对应本次读取的官方文档版本 16.3.8。旧项目可能使用不同的恢复 API,不能只替换名称就假定行为相同;应先核对已安装版本和该版本的 API 说明。

事件处理器与异步代码

错误边界主要捕获渲染期间的错误。普通点击事件和多数在渲染后执行的异步回调不会自动由它接管。此时手动捕获错误,用 useState 或 useReducer 保存状态,再更新界面告知用户。

官方事件示例使用 useState(null) 并把 catch 的未知值直接传入,严格 TypeScript 下类型不匹配;其后备 UI 也只是注释。下例明确补成 Error | null,归一化未知异常并返回实际提示界面。它故意抛错用于展示处理分支,不表示已执行测试。

'use client'

import { useState } from 'react'

export function Button() {
  const [error, setError] = useState<Error | null>(null)

  const handleClick = () => {
    try {
      // do some work that might fail
      throw new Error('Exception')
    } catch (reason) {
      setError(reason instanceof Error ? reason : new Error(String(reason)))
    }
  }

  if (error) {
    return <p role="alert">操作失败:{error.message}</p>
  }

  return (
    <button type="button" onClick={handleClick}>
      Click me
    </button>
  )
}

但通过 useTransition 的 startTransition 抛出的未处理错误会向最近的错误边界冒泡:

'use client'

import { useTransition } from 'react'

export function Button() {
  const [pending, startTransition] = useTransition()

  const handleClick = () =>
    startTransition(() => {
      throw new Error('Exception')
    })

  return (
    <button type="button" onClick={handleClick}>
      Click me
    </button>
  )
}

因此,不能把“所有异步错误都不会被边界处理”作为规则。需区分普通事件回调、渲染阶段和由 React transition 管理的工作。

全局错误

根布局发生错误时,可以在 app 根目录创建 global-error.js,国际化项目同样可用。它激活时会替换根布局或模板,因此必须自己提供 <html> 与 <body>。

app/global-error.tsx 和 app/global-error.js:

'use client' // Error boundaries must be Client Components

export default function GlobalError({
  error,
  retry,
}: {
  error: Error & { digest?: string }
  retry: () => void
}) {
  return (
    // global-error must include html and body tags
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <button onClick={() => retry()}>Try again</button>
      </body>
    </html>
  )
}
'use client' // Error boundaries must be Client Components

export default function GlobalError({ error, retry }) {
  return (
    // global-error must include html and body tags
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <button onClick={() => retry()}>Try again</button>
      </body>
    </html>
  )
}

错误后备界面负责恢复交互,错误报告和告警则负责发现问题;二者不是同一个机制。原文的 console.error 只是日志位置示意,实际错误上报需按项目的隐私与运维要求配置。

API 与延伸资料


来源:Next.js 官方文档:Error Handling,Vercel 及文档贡献者。覆盖原文各章节与 TypeScript/JavaScript 两套示例;修改 action 请求体、成功返回状态、服务端 JSON 解析顺序和事件示例的类型及后备 UI,其他代码保留。所有代码仅静态审核,未编译或运行。官方 MIT 许可:Copyright (c) 2025 Vercel, Inc. 完整许可声明随本地稿的 LICENSE-nextjs.txt 保存,继续转载代码与文档须保留该版权及许可声明。

MIT 许可声明

The MIT License (MIT)

Copyright (c) 2025 Vercel, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容