本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。
应用的质量不仅体现在正常运行时,更体现在出错和加载场景下的用户体验。因此,做好错误和边界处理是构建健壮应用的核心之一。Next.js 通过特殊文件约定,使这些"边缘情况"的处理变得系统化、规范化。
一、Next.js 的"文件即配置"理念
前面我们已经深入讲解过,在 App Router 中,Next.js的理念是"文件即配置",路由系统就是在这样一套机制下建立起来的。同样,在Next.js中错误处理和加载状态也是通过特定命名的文件实现,而非全局配置:
bsah
app/
├── layout.tsx # 根布局
├── page.tsx # 首页
├── loading.tsx # 首页加载状态
├── error.tsx # 首页错误边界
├── not-found.tsx # 404 页面
├── global-error.tsx # 全局错误边界
└── blog/
├── page.tsx # 博客列表页
├── loading.tsx # 博客列表加载状态(覆盖父级)
├── error.tsx # 博客错误边界(仅影响博客路由)
└── [slug]/
├── page.tsx # 文章详情页
└── error.tsx # 文章详情错误边界
核心特性 :每个文件的作用范围限定在其所在目录及子目录。blog/error.tsx 仅处理博客相关路由的错误,不影响其他部分。
二、Loading处理:流式渲染的加载骨架
loading.tsx 定义路由段加载期间的 UI,基于 React Suspense 机制。当同级 page.tsx 等待数据时,立即显示加载状态。
1. 基础用法
ts
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
<div className="h-8 bg-gray-200 rounded animate-pulse w-1/2" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-24 bg-gray-100 rounded animate-pulse"
/>
))}
</div>
</div>
);
}
2. 骨架屏 vs Loading Spinner
在传统的处理中,当用户在等待时,我们会使用Loading Spinner(比如一朵旋转的菊花)方案来提醒用户。这种方式某些程度上会造成一些心智负担。随着骨架屏的出现,越来越多的应用都考虑使用骨架屏来替代Loading Spinner。
(1)Loading Spinner 的问题:
- 用户无法预知等待时间
- 缺乏内容结构预期
- 容易产生焦虑感
(2)骨架屏的优势:
- 展示页面大致结构
- 降低用户心理负担
- 提升感知性能
ts
// components/ArticleCardSkeleton.tsx
export function ArticleCardSkeleton() {
return (
<div className="border rounded-xl overflow-hidden animate-pulse">
{/* 图片占位 */}
<div className="aspect-video bg-gray-200" />
<div className="p-4 space-y-3">
{/* 标题占位 */}
<div className="h-6 bg-gray-200 rounded w-3/4" />
{/* 描述占位 */}
<div className="h-4 bg-gray-100 rounded" />
<div className="h-4 bg-gray-100 rounded w-5/6" />
{/* 作者信息占位 */}
<div className="flex items-center gap-2 mt-4">
<div className="w-8 h-8 bg-gray-200 rounded-full" />
<div className="h-4 bg-gray-100 rounded w-24" />
</div>
</div>
</div>
);
}
ts
// app/blog/loading.tsx
import { ArticleCardSkeleton } from '@/components/ArticleCardSkeleton';
export default function Loading() {
return (
<div className="container mx-auto py-8">
{/* 页面标题骨架 */}
<div className="h-10 bg-gray-200 rounded w-48 mb-8 animate-pulse" />
{/* 文章卡片网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<ArticleCardSkeleton key={i} />
))}
</div>
</div>
);
}
3. 局部 Suspense:精细化加载控制
loading.tsx 作用于整个路由段。如需对特定区域独立控制,使用 React Suspense 组件:
ts
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserStats } from '@/components/UserStats';
import { RecentActivity } from '@/components/RecentActivity';
import { StatsSkeleton } from '@/components/skeletons';
export default function DashboardPage() {
return (
<div className="dashboard-grid">
{/* 统计数据:独立加载 */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
{/* 最近活动:稍后加载 */}
<Suspense fallback={<div className="text-gray-500">加载动态...</div>}>
<RecentActivity />
</Suspense>
</div>
);
}
流式渲染优势:
- 各区域并行加载
- 数据就绪即显示
- 避免"全或无"的等待体验
三、Error处理:局部错误边界
error.tsx 创建 React 错误边界,捕获同级 page.tsx 或子组件抛出的错误,不影响应用其他部分。
1. 基础实现
ts
// app/blog/error.tsx
'use client'; // 必须为客户端组件
import { useEffect } from 'react';
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void; // 重试函数
}
export default function BlogError({ error, reset }: ErrorProps) {
useEffect(() => {
// 记录错误到监控系统
console.error('[Blog Error]', error);
// errorTrackingService.capture(error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-96 gap-6 p-8">
<div className="text-6xl" role="img" aria-label="困惑表情">😕</div>
<h2 className="text-2xl font-bold text-gray-900">
博客内容加载失败
</h2>
<p className="text-gray-500 text-center max-w-md">
{error.message || '发生了一个意外错误,请稍后再试'}
</p>
<button
onClick={() => reset()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
重试
</button>
</div>
);
}
为什么必须是客户端组件? 错误边界需要维护状态(错误状态)和注册事件处理函数(reset),这些都是客户端特性。
2. 错误边界作用域
理解错误捕获范围对调试至关重要:
bash
app/
├── error.tsx # 捕获根级错误(不捕获 layout.tsx 错误)
├── layout.tsx # ← 此处的错误 error.tsx 无法捕获
└── blog/
├── error.tsx # 捕获 blog/page.tsx 及子路由错误
├── layout.tsx # ← 此处的错误 blog/error.tsx 无法捕获
└── page.tsx # 此处错误被 blog/error.tsx 捕获
关键规则 :error.tsx 无法捕获同级 layout.tsx 的错误,因为错误边界包裹的是"兄弟"(page),而非"父亲"(layout)。
四、全局错误处理:最终防线
当根 layout.tsx 出现错误时,由 global-error.tsx 处理:
ts
// app/global-error.tsx
'use client';
interface GlobalErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function GlobalError({
error,
reset
}: GlobalErrorProps) {
return (
// 需手动提供 html 和 body 标签(根 layout 已崩溃)
<html lang="zh-CN">
<body>
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="text-center p-8">
<h1 className="text-4xl font-bold text-red-600 mb-4">
应用出现严重错误
</h1>
<p className="text-gray-600 mb-6">
错误代码:{error.digest || '未知错误'}
</p>
<button
onClick={() => reset()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
刷新页面
</button>
</div>
</div>
</body>
</html>
);
}
global-error.tsx 是应用的最后保障,触发频率极低,但确保了应用永不陷入完全不可用状态。
五、404 页面
1. 基础实现
ts
// app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-6 p-8">
<div className="text-9xl font-bold text-gray-200">404</div>
<h2 className="text-2xl font-bold text-gray-900">
页面不存在
</h2>
<p className="text-gray-500 text-center max-w-md">
你访问的页面可能已被移除或地址有误
</p>
<Link
href="/"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
回到首页
</Link>
</div>
);
}
2. 服务端触发 404
ts
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
// 文章不存在,触发 404
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
{/* ... */}
</article>
);
}
notFound() 抛出特殊错误,Next.js 捕获后显示最近的 not-found.tsx。这不被视为"错误",而是正常的业务逻辑分支。
六、Server Actions 中的错误处理
根据错误类型选择合适的处理方式:
方式一:返回错误状态(可预期错误)
适用于表单验证、业务逻辑校验等场景:
typescript
// app/actions/auth.ts
'use server';
import { redirect } from 'next/navigation';
interface LoginState {
error?: string;
}
export async function login(
prevState: LoginState,
formData: FormData
): Promise<LoginState> {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
// 查找用户
const user = await findUserByEmail(email);
// 验证凭证
if (!user || !await verifyPassword(password, user.hashedPassword)) {
// 返回错误状态,UI 显示提示信息
return { error: '邮箱或密码错误' };
}
// 创建会话
await createSession(user.id);
// 重定向
redirect('/dashboard');
}
方式二:抛出错误(不可预期错误)
适用于数据库异常、网络故障等场景:
typescript
export async function updateProfile(formData: FormData) {
'use server';
try {
// 执行更新操作
await db.users.update({ /* ... */ });
// 缓存失效
revalidatePath('/profile');
} catch (error) {
// 抛出的错误被最近的 error.tsx 捕获
console.error('Profile update failed:', error);
throw new Error('更新失败,请稍后重试');
}
}
七、错误监控集成
生产环境需实施错误监控,在用户反馈前发现问题。
1. 使用Sentry 集成
bash
npx @sentry/wizard@latest -i nextjs
Sentry 自动捕获未处理错误并发送至 Dashboard,包含完整调用栈和用户上下文。
2. 自定义错误日志
即使不使用第三方服务,也应记录错误:
ts
'use client';
import { useEffect } from 'react';
interface ErrorPageProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function ErrorPage({ error, reset }: ErrorPageProps) {
useEffect(() => {
// 发送至自有日志系统
fetch('/api/log-error', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: error.message,
stack: error.stack,
digest: error.digest,
timestamp: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent,
}),
}).catch(() => {
// 日志失败不应影响错误页面展示
});
}, [error]);
return (
// ... 错误 UI
);
}
八、最佳实践总结
1. 差异化恢复策略
根据错误类型提供不同的解决方案:
| 错误类型 | 恢复策略 | 示例 |
|---|---|---|
| 网络抖动 | 重试按钮 | API 请求超时 |
| 数据异常 | 刷新页面 | 缓存数据损坏 |
| 权限问题 | 重新登录 | Token 过期 |
| 资源缺失 | 返回首页 | 文章已删除 |
2. 隐藏技术细节
ts
// ❌ 危险:暴露内部实现
<p>{error.message}</p>
<p>{error.stack}</p>
// ✅ 安全:友好提示
<p>抱歉,加载内容时遇到问题。我们已记录此错误,将尽快修复。</p>
// 技术细节仅发送至日志系统
3. 区分错误类型
- 用户错误(4xx):帮助用户修正输入
- 系统错误(5xx):显示错误页面并提供恢复选项
4. 保持错误页面简洁
错误页面应避免复杂的数据获取,防止自身出错导致无限循环。
5. 渐进增强原则
- 优先保证核心功能可用
- 次要功能降级显示
- 优雅地处理部分失败
九、本章小结
通过本章学习,你应该掌握了:
- Next.js 特殊文件的命名约定和作用域
loading.tsx与骨架屏的实现方法error.tsx错误边界的捕获范围global-error.tsx的最终保障机制not-found.tsx与notFound()函数的使用- Server Actions 中的两种错误处理方式
- 错误监控服务的集成方法
- 生产环境的错误处理最佳实践
下一章将深入探讨认证鉴权与中间件------这是所有实际应用都必须面对的核心安全话题。