Next.js从入门到实战保姆级教程(第十一章):错误处理与加载状态

本系列文章将围绕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.tsxnotFound() 函数的使用
  • Server Actions 中的两种错误处理方式
  • 错误监控服务的集成方法
  • 生产环境的错误处理最佳实践

下一章将深入探讨认证鉴权与中间件------这是所有实际应用都必须面对的核心安全话题。

相关推荐
深海鱼在掘金2 小时前
Next.js从入门到实战保姆级教程(第十二章):认证鉴权与中间件
前端·typescript·next.js
energy_DT2 小时前
2026年十五五油气田智能增产装备数字孪生,CIMPro孪大师赋能“流动增产工厂”三维可视化管控
前端
龙猫里的小梅啊2 小时前
CSS(四)CSS文本属性
前端·css
MXN_小南学前端2 小时前
watch详解:与computed 对比以及 Vue2 / Vue3 区别
前端·javascript·vue.js
饭小猿人2 小时前
Flutter实现底部动画弹窗有两种方式
开发语言·前端·flutter
让学习成为一种生活方式3 小时前
pbtk v 3.5.0安装与使用--生信工具084
前端·chrome
heimeiyingwang3 小时前
【架构实战】FinOps云成本优化实践
前端·chrome·架构
Mr Xu_3 小时前
从后端数据到前端图表:深入解析 reduce 与 flatMap 的数据整形实战
前端·javascript
玖玖passion4 小时前
Windows 上部署 Hermes Agent 完整指南 - 让你的 AI 助手在 WSL2 中跑起来
前端·后端·github