Next.js

错误处理

深入理解 Next.js 错误处理机制,包括预期错误、未捕获异常、错误边界、404 处理和优雅降级策略

概述

错误可以分为两类:

  1. 预期错误:在应用程序正常运行期间可能发生的错误,例如服务器端表单验证或失败的请求
  2. 未捕获异常:意外的运行时错误

Next.js 提供了不同的机制来处理这两类错误。

处理预期错误

预期错误应该被显式处理并返回给客户端。对于这些错误,避免使用 try/catch 块和抛出错误,而是将预期错误建模为返回值。

Server Functions 中的预期错误

使用 useActionState hook 处理 Server Functions 中的预期错误:

'use server'

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

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

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

  return { message: 'Post created successfully' }
}

在客户端组件中使用:

'use client'

import { useActionState } from 'react'
import { createPost } from './actions'

const initialState = {
  message: '',
}

export default function Page() {
  const [state, formAction, isPending] = useActionState(
    createPost,
    initialState
  )

  return (
    <form action={formAction}>
      <input type="text" name="title" />
      <textarea name="content" />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
      {state.message && <p>{state.message}</p>}
    </form>
  )
}

Server Components 中的预期错误

在 Server Components 中,可以使用 redirect 将用户重定向到错误页面:

import { redirect } from 'next/navigation'

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

  if (!res.ok) {
    redirect('/error')
  }

  const data = await res.json()
  return <div>{data.name}</div>
}

处理未捕获异常

未捕获异常是意外错误,表示应用程序正常流程中的错误或问题。这些应该通过抛出错误来处理,然后由错误边界捕获。

嵌套错误边界

通过在路由段内添加 error.js 文件并导出 React 组件来创建错误边界:

'use client' // 错误边界必须是客户端组件

import { useEffect } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // 将错误记录到错误报告服务
    console.error(error)
  }, [error])

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

工作原理

  • error.js 将路由段及其嵌套子级包装在 React 错误边界中
  • 当边界内抛出错误时,错误组件显示为后备 UI
  • 错误边界内的错误会向上冒泡到最近的父错误边界
  • 这允许通过在路由层次结构的不同级别放置 error.js 文件来进行细粒度的错误处理

Props

error

转发到 error.js 客户端组件的 Error 对象实例。

export default function Error({
  error,
}: {
  error: Error & { digest?: string }
}) {
  return (
    <div>
      <h2>Error: {error.message}</h2>
      <p>Digest: {error.digest}</p>
    </div>
  )
}

在生产环境中,转发到客户端的 Error 对象不包含原始错误的详细信息,以避免泄露敏感信息。error.message 包含有关错误的通用消息,error.digest 包含可用于匹配服务器端日志中相应错误的自动生成的错误哈希。

在开发期间,转发到客户端的 Error 对象将被序列化并包含原始错误的 message 以便于调试。

reset

重置错误边界的函数。执行时,函数将尝试重新渲染错误边界的内容。如果成功,后备错误组件将被重新渲染的结果替换。

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

在 Server Components 中抛出错误

在 Server Components 中抛出的错误将由最近的父错误边界处理:

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

  if (!res.ok) {
    throw new Error('Failed to fetch data')
  }

  const data = await res.json()
  return <div>{data.name}</div>
}

在 Client Components 中抛出错误

在 Client Components 中,在事件处理器内抛出错误时使用 startTransition

'use client'

import { useTransition } from 'react'

export default function Page() {
  const [isPending, startTransition] = useTransition()

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

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

全局错误

虽然不太常见,但可以使用位于根 app 目录中的 global-error.js 文件处理根布局中的错误,即使在使用国际化时也是如此。

全局错误 UI 必须定义自己的 <html><body> 标签,因为它在活动时替换根布局或模板。

'use client' // 错误边界必须是客户端组件

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    // global-error 必须包含 html 和 body 标签
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <button onClick={() => reset()}>Try again</button>
      </body>
    </html>
  )
}

404 处理

Next.js 提供了两种约定来处理 404 情况:

  1. not-found.js:当在路由段中调用 notFound 函数时使用
  2. global-not-found.js:用于定义整个应用程序中未匹配路由的全局 404 页面

notFound 函数

notFound 函数允许你在路由段内渲染 not-found 文件,并注入 <meta name="robots" content="noindex" /> 标签。

import { notFound } from 'next/navigation'

async function fetchUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`)
  if (!res.ok) return undefined
  return res.json()
}

export default async function Profile({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const user = await fetchUser(id)

  if (!user) {
    notFound()
  }

  return <div>User: {user.name}</div>
}

not-found.js 文件

not-found.js 文件用于在路由段内调用 notFound 函数时渲染 UI。

import Link from 'next/link'

export default function NotFound() {
  return (
    <div>
      <h2>Not Found</h2>
      <p>Could not find requested resource</p>
      <Link href="/">Return Home</Link>
    </div>
  )
}

HTTP 状态码

Next.js 将为流式响应返回 200 HTTP 状态码,为非流式响应返回 404

数据获取

可以在 not-found.js 中获取数据以显示更多上下文:

import Link from 'next/link'
import { headers } from 'next/headers'

export default async function NotFound() {
  const headersList = await headers()
  const domain = headersList.get('host')
  const data = await getSiteData(domain)

  return (
    <div>
      <h2>Not Found: {data.name}</h2>
      <p>Could not find requested resource</p>
      <p>
        View <Link href="/blog">all posts</Link>
      </p>
    </div>
  )
}

如果需要使用客户端组件 hooks(如 usePathname)来根据路径显示内容,必须在客户端获取数据。

global-not-found.js(实验性)

global-not-found.js 文件允许你为整个应用程序定义 404 页面。与在路由级别工作的 not-found.js 不同,当请求的 URL 根本不匹配任何路由时使用此文件。

Next.js 跳过渲染并直接返回此全局页面。

import Link from 'next/link'

export default function GlobalNotFound() {
  return (
    <html lang="en">
      <body>
        <div>
          <h1>404 - Page Not Found</h1>
          <p>The page you are looking for does not exist.</p>
          <Link href="/">Go back home</Link>
        </div>
      </body>
    </html>
  )
}

启用 global-not-found.js

next.config.js 中启用实验性功能:

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    globalNotFound: true,
  },
}

export default nextConfig

元数据

可以导出 metadata 对象或 generateMetadata 函数来自定义 404 页面的元数据:

import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Not Found',
  description: 'The page you are looking for does not exist.',
}

export default function GlobalNotFound() {
  return (
    <html lang="en">
      <body>
        <div>
          <h1>Not Found</h1>
          <p>The page you are looking for does not exist.</p>
        </div>
      </body>
    </html>
  )
}

Next.js 自动为返回 404 状态码的页面(包括 global-not-found.js 页面)注入 <meta name="robots" content="noindex" />

优雅的错误恢复

自定义错误边界

当客户端渲染失败时,显示最后已知的服务器渲染 UI 可以提供更好的用户体验:

'use client'

import React, { Component, createRef } from 'react'

class GracefullyDegradingErrorBoundary extends Component<
  { children: React.ReactNode; onError?: (error: Error, errorInfo: React.ErrorInfo) => void },
  { hasError: boolean }
> {
  contentRef = createRef<HTMLDivElement>()

  constructor(props: any) {
    super(props)
    this.state = { hasError: false }
  }

  static getDerivedStateFromError(_: Error) {
    return { hasError: true }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    if (this.props.onError) {
      this.props.onError(error, errorInfo)
    }
  }

  render() {
    if (this.state.hasError) {
      // 渲染当前 HTML 内容而不进行水合
      return (
        <>
          <div
            ref={this.contentRef}
            suppressHydrationWarning
            dangerouslySetInnerHTML={{
              __html: this.contentRef.current?.innerHTML || '',
            }}
          />
          <div className="fixed bottom-0 left-0 right-0 bg-red-600 text-white py-4 px-6 text-center">
            <p className="font-semibold">
              An error occurred during page rendering
            </p>
          </div>
        </>
      )
    }

    return <div ref={this.contentRef}>{this.props.children}</div>
  }
}

export default GracefullyDegradingErrorBoundary

错误处理最佳实践

1. 区分预期错误和未捕获异常

  • 预期错误:使用返回值,不要抛出错误
  • 未捕获异常:使用错误边界捕获
// 预期错误 - 返回错误状态
export async function createPost(formData: FormData) {
  'use server'
  
  if (!formData.get('title')) {
    return { error: 'Title is required' }
  }
  
  // 处理逻辑
}

// 未捕获异常 - 抛出错误
export default async function Page() {
  const data = await fetch('https://...')
  
  if (!data.ok) {
    throw new Error('Failed to fetch')
  }
  
  return <div>{/* 内容 */}</div>
}

2. 使用嵌套错误边界进行细粒度控制

在不同的路由层级放置 error.js 文件以提供更具体的错误处理:

app/
├── error.tsx           # 根级别错误边界
├── dashboard/
│   ├── error.tsx       # 仪表板错误边界
│   └── settings/
│       └── error.tsx   # 设置错误边界

3. 记录错误到监控服务

在错误组件中使用 useEffect 记录错误:

'use client'

import { useEffect } from 'react'
import * as Sentry from '@sentry/nextjs'

export default function Error({
  error,
}: {
  error: Error & { digest?: string }
}) {
  useEffect(() => {
    // 记录到 Sentry 或其他服务
    Sentry.captureException(error)
  }, [error])

  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>Error ID: {error.digest}</p>
    </div>
  )
}

4. 提供有意义的错误消息

根据环境提供不同级别的错误详情:

'use client'

export default function Error({
  error,
}: {
  error: Error & { digest?: string }
}) {
  const isDevelopment = process.env.NODE_ENV === 'development'

  return (
    <div>
      <h2>Something went wrong!</h2>
      {isDevelopment && (
        <>
          <p>Error: {error.message}</p>
          <pre>{error.stack}</pre>
        </>
      )}
      {!isDevelopment && (
        <p>We're working on fixing this issue. Please try again later.</p>
      )}
    </div>
  )
}

5. 实现重试逻辑

使用 reset 函数允许用户重试操作:

'use client'

import { useState } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  const [retryCount, setRetryCount] = useState(0)

  const handleReset = () => {
    setRetryCount(prev => prev + 1)
    reset()
  }

  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>Retry attempts: {retryCount}</p>
      <button onClick={handleReset}>
        Try again
      </button>
    </div>
  )
}

6. 使用 notFound 处理资源不存在

对于资源不存在的情况,使用 notFound 而不是抛出错误:

import { notFound } from 'next/navigation'

export default async function Post({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const post = await getPost(id)

  if (!post) {
    notFound() // 使用 notFound 而不是 throw new Error
  }

  return <article>{post.content}</article>
}

常见错误处理模式

表单验证错误

'use server'

import { z } from 'zod'

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
})

export async function signup(prevState: any, formData: FormData) {
  const validatedFields = schema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // 处理注册逻辑
  return { success: true }
}
'use client'

import { useActionState } from 'react'
import { signup } from './actions'

export default function SignupPage() {
  const [state, formAction, isPending] = useActionState(signup, null)

  return (
    <form action={formAction}>
      <input type="email" name="email" />
      {state?.errors?.email && <p>{state.errors.email}</p>}
      
      <input type="password" name="password" />
      {state?.errors?.password && <p>{state.errors.password}</p>}
      
      <button type="submit" disabled={isPending}>
        Sign up
      </button>
    </form>
  )
}

API 错误处理

export default async function Dashboard() {
  try {
    const res = await fetch('https://api.example.com/data', {
      cache: 'no-store',
    })

    if (!res.ok) {
      // 对于 API 错误,抛出以触发错误边界
      throw new Error(`API Error: ${res.status}`)
    }

    const data = await res.json()
    return <div>{data.content}</div>
  } catch (error) {
    // 这将被最近的错误边界捕获
    throw error
  }
}

条件错误边界

'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  // 根据错误类型显示不同的 UI
  if (error.message.includes('API Error: 401')) {
    return (
      <div>
        <h2>Unauthorized</h2>
        <p>Please log in to continue.</p>
        <a href="/login">Go to login</a>
      </div>
    )
  }

  if (error.message.includes('API Error: 404')) {
    return (
      <div>
        <h2>Not Found</h2>
        <p>The resource you're looking for doesn't exist.</p>
        <button onClick={reset}>Go back</button>
      </div>
    )
  }

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

总结

Next.js 提供了全面的错误处理机制:

  1. 预期错误:使用返回值和 useActionState 处理表单验证和已知错误
  2. 未捕获异常:使用错误边界(error.jsglobal-error.js)捕获运行时错误
  3. 404 处理:使用 notFound 函数和 not-found.js/global-not-found.js 文件
  4. 嵌套边界:在不同路由层级提供细粒度的错误处理
  5. 优雅降级:使用自定义错误边界保留服务器渲染的 UI
  6. 错误恢复:使用 reset 函数允许用户重试操作

通过合理使用这些错误处理机制,可以构建既健壮又用户友好的应用程序。

在 GitHub 上编辑

上次更新于