🙋 用户遇到错误时看到白屏?良好的错误处理能提升用户体验,也方便开发调试。
错误边界概述#
Next.js 提供了多层错误处理机制:
| 文件 | 作用 | 触发条件 |
|---|---|---|
error.tsx | 捕获运行时错误 | 组件渲染错误、数据获取失败 |
not-found.tsx | 处理 404 | 调用 notFound() 或路由不存在 |
global-error.tsx | 全局错误边界 | 根布局错误 |
error.tsx#
基础用法#
app/├── error.tsx # 全局错误边界└── dashboard/ ├── error.tsx # dashboard 路由的错误边界 └── page.tsx'use client' // 必须是 Client Component
export default function Error({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { return ( <div className="p-8 text-center"> <h2 className="text-2xl font-bold text-red-600 mb-4">出错了</h2> <p className="text-gray-600 mb-4">{error.message}</p> <button onClick={reset} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700" > 重试 </button> </div> )}🔶 注意:error.tsx 必须是 Client Component,因为需要使用 reset 函数。
错误信息处理#
'use client'
import { useEffect } from 'react'
export default function Error({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { useEffect(() => { // 上报错误到监控服务 console.error('Error:', error)
// 例如发送到 Sentry // Sentry.captureException(error) }, [error])
return ( <div className="min-h-screen flex items-center justify-center"> <div className="text-center max-w-md"> <h1 className="text-6xl font-bold text-gray-200 mb-4">500</h1> <h2 className="text-xl font-semibold mb-2">服务器错误</h2> <p className="text-gray-500 mb-6"> {process.env.NODE_ENV === 'development' ? error.message : '抱歉,服务器出现了问题,请稍后重试。'} </p> {error.digest && ( <p className="text-sm text-gray-400 mb-4">错误代码: {error.digest}</p> )} <div className="space-x-4"> <button onClick={reset} className="px-4 py-2 bg-blue-600 text-white rounded" > 重试 </button> <a href="/" className="px-4 py-2 border rounded"> 返回首页 </a> </div> </div> </div> )}嵌套错误边界#
app/├── error.tsx # 捕获所有子路由错误├── layout.tsx└── dashboard/ ├── error.tsx # 只捕获 dashboard 内的错误 ├── layout.tsx └── settings/ ├── error.tsx # 只捕获 settings 内的错误 └── page.tsx错误会向上冒泡,直到被最近的 error.tsx 捕获。
not-found.tsx#
基础用法#
import Link from 'next/link'
export default function NotFound() { return ( <div className="min-h-screen flex items-center justify-center"> <div className="text-center"> <h1 className="text-9xl font-bold text-gray-200">404</h1> <h2 className="text-2xl font-semibold mb-4">页面未找到</h2> <p className="text-gray-500 mb-8">您访问的页面不存在或已被删除。</p> <Link href="/" className="px-6 py-3 bg-blue-600 text-white rounded-lg"> 返回首页 </Link> </div> </div> )}手动触发 404#
import { notFound } from 'next/navigation'
async function getPost(slug: string) { const res = await fetch(`https://api.example.com/posts/${slug}`)
if (!res.ok) { return null }
return res.json()}
export default async function PostPage({ params,}: { params: Promise<{ slug: string }>}) { const { slug } = await params const post = await getPost(slug)
if (!post) { notFound() // 触发 not-found.tsx }
return <article>{post.content}</article>}路由级 not-found#
app/├── not-found.tsx # 全局 404└── blog/ ├── not-found.tsx # blog 路由的 404 └── [slug]/ └── page.tsximport Link from 'next/link'
export default function BlogNotFound() { return ( <div className="p-8 text-center"> <h2 className="text-2xl font-bold mb-4">文章未找到</h2> <p className="text-gray-500 mb-4">该文章可能已被删除或尚未发布。</p> <Link href="/blog" className="text-blue-600"> 浏览所有文章 </Link> </div> )}global-error.tsx#
用于捕获根布局中的错误:
'use client'
export default function GlobalError({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { return ( <html> <body> <div className="min-h-screen flex items-center justify-center bg-gray-100"> <div className="text-center"> <h2 className="text-2xl font-bold text-red-600 mb-4"> 应用发生严重错误 </h2> <p className="text-gray-600 mb-4">请刷新页面或联系支持。</p> <button onClick={reset} className="px-4 py-2 bg-blue-600 text-white rounded" > 刷新页面 </button> </div> </div> </body> </html> )}🔶 注意:global-error.tsx 必须定义自己的 <html> 和 <body> 标签。
Server Actions 错误处理#
返回错误状态#
'use server'
import { z } from 'zod'
const schema = z.object({ email: z.string().email(), password: z.string().min(8),})
export async function login(prevState: any, formData: FormData) { const result = schema.safeParse({ email: formData.get('email'), password: formData.get('password'), })
if (!result.success) { return { success: false, errors: result.error.flatten().fieldErrors, } }
try { // 登录逻辑 await authenticateUser(result.data)
return { success: true } } catch (error) { return { success: false, message: '登录失败,请检查邮箱和密码', } }}'use client'
import { useActionState } from 'react'import { login } from '@/app/actions'
export function LoginForm() { const [state, formAction, isPending] = useActionState(login, null)
return ( <form action={formAction} className="space-y-4"> <div> <input name="email" type="email" placeholder="邮箱" className="w-full border rounded px-3 py-2" /> {state?.errors?.email && ( <p className="text-red-500 text-sm">{state.errors.email[0]}</p> )} </div>
<div> <input name="password" type="password" placeholder="密码" className="w-full border rounded px-3 py-2" /> {state?.errors?.password && ( <p className="text-red-500 text-sm">{state.errors.password[0]}</p> )} </div>
{state?.message && <p className="text-red-500">{state.message}</p>}
<button type="submit" disabled={isPending} className="w-full bg-blue-600 text-white py-2 rounded disabled:opacity-50" > {isPending ? '登录中...' : '登录'} </button> </form> )}抛出错误#
'use server'
export async function dangerousAction() { // 抛出的错误会被最近的 error.tsx 捕获 throw new Error('操作失败')}Route Handlers 错误处理#
import { NextRequest, NextResponse } from 'next/server'
export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params const user = await db.user.findUnique({ where: { id } })
if (!user) { return NextResponse.json({ error: 'User not found' }, { status: 404 }) }
return NextResponse.json(user) } catch (error) { console.error('Error fetching user:', error)
return NextResponse.json( { error: 'Internal server error' }, { status: 500 } ) }}统一错误处理#
export class ApiError extends Error { constructor( public statusCode: number, message: string ) { super(message) }}
export function handleApiError(error: unknown) { console.error('API Error:', error)
if (error instanceof ApiError) { return NextResponse.json( { error: error.message }, { status: error.statusCode } ) }
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })}import { NextRequest, NextResponse } from 'next/server'import { ApiError, handleApiError } from '@/lib/api-error'
export async function POST(request: NextRequest) { try { const data = await request.json()
if (!data.title) { throw new ApiError(400, 'Title is required') }
const post = await db.post.create({ data }) return NextResponse.json(post, { status: 201 }) } catch (error) { return handleApiError(error) }}数据获取错误处理#
try-catch 模式#
async function getUsers() { try { const res = await fetch('https://api.example.com/users')
if (!res.ok) { throw new Error(`HTTP error! status: ${res.status}`) }
return { data: await res.json(), error: null } } catch (error) { console.error('Failed to fetch users:', error) return { data: null, error: 'Failed to load users' } }}
export default async function UsersPage() { const { data: users, error } = await getUsers()
if (error) { return ( <div className="p-4 bg-red-50 rounded"> <p className="text-red-600">{error}</p> <p className="text-gray-500 mt-2">请稍后重试</p> </div> ) }
return ( <ul> {users.map((user: any) => ( <li key={user.id}>{user.name}</li> ))} </ul> )}抛出错误让 error.tsx 处理#
async function getUsers() { const res = await fetch('https://api.example.com/users')
if (!res.ok) { throw new Error('Failed to fetch users') }
return res.json()}
export default async function UsersPage() { const users = await getUsers() // 错误会被 error.tsx 捕获
return ( <ul> {users.map((user: any) => ( <li key={user.id}>{user.name}</li> ))} </ul> )}错误监控#
集成 Sentry#
pnpm add @sentry/nextjsnpx @sentry/wizard@latest -i nextjs'use client'
import * as Sentry from '@sentry/nextjs'import { useEffect } from 'react'
export default function Error({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { useEffect(() => { Sentry.captureException(error) }, [error])
return ( <div className="p-8 text-center"> <h2 className="text-xl font-bold mb-4">出错了</h2> <button onClick={reset} className="text-blue-600"> 重试 </button> </div> )}自定义错误日志#
type LogLevel = 'info' | 'warn' | 'error'
interface LogEntry { level: LogLevel message: string timestamp: string context?: Record<string, any>}
export function log( level: LogLevel, message: string, context?: Record<string, any>) { const entry: LogEntry = { level, message, timestamp: new Date().toISOString(), context, }
// 开发环境打印到控制台 if (process.env.NODE_ENV === 'development') { console[level](JSON.stringify(entry, null, 2)) return }
// 生产环境发送到日志服务 fetch('/api/logs', { method: 'POST', body: JSON.stringify(entry), }).catch(console.error)}
export const logger = { info: (message: string, context?: Record<string, any>) => log('info', message, context), warn: (message: string, context?: Record<string, any>) => log('warn', message, context), error: (message: string, context?: Record<string, any>) => log('error', message, context),}实战示例#
完整的错误处理设置#
'use client'
import { useEffect } from 'react'import Link from 'next/link'
export default function Error({ error, reset,}: { error: Error & { digest?: string } reset: () => void}) { useEffect(() => { // 错误上报 fetch('/api/error-report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: error.message, digest: error.digest, stack: error.stack, url: window.location.href, userAgent: navigator.userAgent, }), }).catch(console.error) }, [error])
return ( <div className="min-h-[400px] flex items-center justify-center"> <div className="text-center max-w-lg px-4"> <div className="text-6xl mb-4">😵</div> <h1 className="text-2xl font-bold mb-2">哎呀,出错了</h1> <p className="text-gray-500 mb-6"> 别担心,这不是你的问题。我们的团队已经收到通知,正在处理中。 </p>
<div className="flex justify-center gap-4"> <button onClick={reset} className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" > 重试 </button> <Link href="/" className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50" > 返回首页 </Link> </div>
{process.env.NODE_ENV === 'development' && ( <details className="mt-8 text-left bg-gray-100 p-4 rounded"> <summary className="cursor-pointer font-medium"> 错误详情(开发模式) </summary> <pre className="mt-2 text-sm overflow-auto">{error.stack}</pre> </details> )} </div> </div> )}import Link from 'next/link'
export default function NotFound() { return ( <div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="text-center"> <h1 className="text-[150px] font-bold text-gray-200 leading-none"> 404 </h1> <h2 className="text-2xl font-semibold text-gray-800 mb-4"> 页面走丢了 </h2> <p className="text-gray-500 mb-8 max-w-md"> 您访问的页面可能已被移动、删除,或者从未存在过。 </p> <div className="flex justify-center gap-4"> <Link href="/" className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700" > 返回首页 </Link> <Link href="/sitemap" className="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-100" > 站点地图 </Link> </div> </div> </div> )}常见问题#
🤔 Q: error.tsx 为什么必须是 Client Component?
因为需要使用 reset 函数来重新渲染页面,这需要客户端的 React 状态管理。
🤔 Q: 如何区分开发和生产环境的错误显示?
// 开发环境显示详细错误,生产环境显示友好提示{ process.env.NODE_ENV === 'development' ? ( <pre>{error.stack}</pre> ) : ( <p>抱歉,出现了问题</p> )}🤔 Q: error.tsx 能捕获布局错误吗?
不能。error.tsx 无法捕获同级 layout.tsx 的错误,需要在父级添加 error.tsx。
恭喜你完成了数据处理部分!下一篇将进入优化与性能部分,介绍图片优化。
-EOF-