阅读视图

发现新文章,点击刷新页面。

Next.js从入门到实战保姆级教程(第十四章):性能优化深度实践

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

"过早优化是万恶之源"——Donald Knuth。但这并非否定优化的价值,而是强调先测量,再优化的原则。缺乏数据支撑的优化往往偏离正确方向,甚至适得其反。

一、Core Web Vitals 核心指标

Google 将三个关键用户体验指标定义为 Core Web Vitals,直接影响搜索排名和用户留存率:

graph LR
    root((Core Web Vitals))
    LCP["LCP<br/>最大内容绘制<br/>(加载速度)"] -->|目标| LCP_GOOD[< 2.5s]
    INP["INP<br/>交互响应时间<br/>(响应速度)"] -->|目标| INP_GOOD[< 200ms]
    CLS["CLS<br/>累积布局偏移<br/>(视觉稳定性)"] -->|目标| CLS_GOOD[< 0.1]
    root-->LCP
    root-->INP
    root-->CLS

1. 指标详解

指标 全称 含义 优秀阈值 影响权重
LCP Largest Contentful Paint 最大内容元素渲染完成时间 < 2.5s 25%
INP Interaction to Next Paint 用户交互到页面响应时间 < 200ms 25%
CLS Cumulative Layout Shift 页面加载过程布局跳动总量 < 0.1 25%

(1)LCP(最大内容绘制)
衡量首屏主要内容的加载速度,通常是 Hero 图片、主标题或关键文本块。优化重点在于减少资源加载时间和渲染阻塞。

(2)INP(交互响应时间)
替代了之前的 FID(First Input Delay),更全面地评估页面整个生命周期内的交互响应能力。包括点击、按键、触摸等所有用户交互。

(3)CLS(累积布局偏移)
量化页面加载过程中元素位置意外变化的程度。常见原因包括图片未设置尺寸、动态插入广告、字体切换等。

2. 测量工具链

(1) 开发阶段

Vercel平台提供了两个核心分析工具,专门用于监控和优化部署在Vercel上的Next.js应用的性能与用户行为。但是你可以将它用在开发环境上,虽然不会发送数据,但是可以使用它结合控制台来测量性能数据。

// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next';
import { Analytics } from '@vercel/analytics/react';

export default function RootLayout({ 
  children 
}: { 
  children: React.ReactNode 
}) {
  return (
    <html lang="zh-CN">
      <body>
        {children}
        {/* Vercel 提供的性能监控组件 */}
        <SpeedInsights />
        <Analytics />
      </body>
    </html>
  );
}

(2)生产环境监控

当应用上线后,你的目标变成了**“监控真实用户体验”** 和 “数据驱动业务决策”。你可以需要更细粒度的控制和数据上报。这时候你可以选择使用web-vitals库。

// app/layout.tsx
import type { ReportHandler } from 'web-vitals';

/**
 * 上报 Web Vitals 指标
 * @param metric - 性能指标对象
 */
export function reportWebVitals(metric: ReportHandler) {
  // 发送到自定义分析服务
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    id: metric.id,
    label: metric.label,
    timestamp: Date.now(),
  });

  // 使用 sendBeacon 确保数据可靠发送
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/analytics', body);
  } else {
    fetch('/api/analytics', {
      method: 'POST',
      body,
      keepalive: true,
    });
  }

  // 或发送到 Google Analytics
  if (window.gtag) {
    window.gtag('event', metric.name, {
      value: Math.round(
        metric.name === 'CLS' ? metric.value * 1000 : metric.value
      ),
      event_label: metric.id,
      non_interaction: true,
    });
  }
}

二、代码分割与懒加载

1. 动态导入:按需加载组件

Next.js 默认对每个路由自动进行代码分割,但针对大型第三方库非首屏组件,可进一步优化:

// components/LazyChart.tsx
import dynamic from 'next/dynamic';

/**
 * 重型图表组件 - 动态导入
 * 仅在用户滚动到可视区域时加载
 */
const HeavyChart = dynamic(
  () => import('@/components/HeavyChart'),
  {
    loading: () => (
      <div className="h-64 bg-gray-100 animate-pulse rounded-lg flex items-center justify-center">
        <span className="text-gray-400">图表加载中...</span>
      </div>
    ),
    ssr: false,  // 禁用服务端渲染
  }
);

/**
 * 富文本编辑器 - 仅编辑时加载
 */
const RichTextEditor = dynamic(
  () => import('@/components/RichTextEditor'),
  {
    ssr: false,
    loading: () => (
      <div className="min-h-[200px] border rounded-lg p-4 bg-gray-50">
        <div className="animate-pulse space-y-2">
          <div className="h-4 bg-gray-200 rounded w-3/4"></div>
          <div className="h-4 bg-gray-200 rounded w-full"></div>
          <div className="h-4 bg-gray-200 rounded w-5/6"></div>
        </div>
      </div>
    ),
  }
);

export { HeavyChart, RichTextEditor };

2. ssr: false 的使用场景

某些库(比如地图组件、富文本编辑器组件)依赖浏览器环境 API(windowdocumentCanvas),在服务端渲染时会报错。此时需设置 ssr: false:

// 地图组件
const MapComponent = dynamic(
  () => import('@/components/Map'),
  {
    ssr: false,
    loading: () => (
      <div className="h-96 bg-gray-200 rounded-lg flex items-center justify-center">
        <div className="text-center">
          <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
          <span className="text-gray-600">地图加载中...</span>
        </div>
      </div>
    ),
  }
);

常见需要 ssr: false 的库:

  • 地图库(Leaflet、Mapbox GL JS)
  • 部分图表库(D3.js、Chart.js)
  • 实时协作编辑器(Tiptap、ProseMirror)
  • Canvas 绘图库(Fabric.js、Konva)
  • WebGL 相关库(Three.js)

三、使用缓存策略优化性能

缓存是性能优化的重要组成部分,合理的使用缓存,可以获得不错的效果。

Next.js缓存策略的深度剖析请阅读《从原理到实践深度剖析缓存策略》,本文将简单带过。

Next.js 提供四层缓存机制,理解其工作原理对性能优化至关重要:

graph TB
    Request[用户请求] --> RC[Request Memoization<br/>请求记忆化<br/>单次请求周期内去重]
    RC --> DC[Data Cache<br/>数据缓存<br/>跨请求持久化存储]
    DC --> FC[Full Route Cache<br/>完整路由缓存<br/>HTML + RSC Payload]
     DC[Data Cache<br/>数据缓存]
     FC[Full Route Cache<br/>完整路由缓存]
    FC --> RRC[Router Cache<br/>路由缓存<br/>客户端内存缓存]
    RRC --> User[返回给用户]
    
    style RC fill:#e1f5ff
    style DC fill:#fff4e1
    style FC fill:#e8f5e9
    style RRC fill:#fce4ec

1. 数据缓存配置策略

// 1. 永久缓存(静态内容)
const siteConfig = await fetch('/api/site-config', {
  cache: 'force-cache',  // 类似 SSG,构建时获取,永久缓存
});

// 2. 实时数据(不缓存)
const livePrices = await fetch('/api/crypto-prices', {
  cache: 'no-store',  // 每次请求获取最新数据
});

// 3. 定时重新验证(推荐用于大多数场景)
const blogPosts = await fetch('/api/posts', {
  next: { revalidate: 3600 },  // 每小时重新验证
});

// 4. 按标签失效(最灵活的方案)
const userProfile = await fetch(`/api/users/${userId}`, {
  next: { tags: ['user', `user-${userId}`] },
});

2. 缓存失效管理

使用 revalidateTag, revalidatePath手动让缓存失效。

// app/actions/user.ts
'use server';

import { revalidateTag, revalidatePath } from 'next/cache';

/**
 * 更新用户信息并失效相关缓存
 */
export async function updateUserProfile(
  userId: string,
  data: UserProfileUpdate
) {
  // 更新数据库
  await db.users.update({
    where: { id: userId },
    data,
  });

  // 方式一: 按标签失效(推荐)
  revalidateTag(`user-${userId}`);
  revalidateTag('users');

  // 方式二: 按路径失效
  revalidatePath(`/profile/${userId}`);
  revalidatePath('/users');

  return { success: true };
}

3. 缓存策略选择指南

数据类型 更新频率 推荐策略 示例
网站配置(Logo、导航) 极少 force-cache + 发布时重建 公司信息、联系方式
博客文章列表 每天数次 revalidate: 3600 CMS 内容
产品价格 实时变化 no-storerevalidate: 60 电商商品、股票价格
用户个人数据 操作后立即 no-store + 按标签失效 个人资料、订单状态
新闻/动态信息 频繁更新 revalidate: 300 社交媒体 feed
统计数据 每小时 revalidate: 3600 仪表盘数据

四、Bundle 分析与优化

过大的 JavaScript bundle 是 LCP 的主要杀手。Next.js 内置 Bundle Analyzer 工具:

安装与配置

npm install @next/bundle-analyzer
// next.config.ts
import type { NextConfig } from 'next';
import withBundleAnalyzer from '@next/bundle-analyzer';

const config: NextConfig = {
  // 你的其他配置
};

const withAnalyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
});

export default withAnalyzer(config);
# 运行分析
ANALYZE=true npm run build

这将打开交互式可视化界面,展示每个包的大小和依赖关系。

1. 常见优化策略

(1)替换重型依赖

导入依赖时采用按需导入的方式。

// ❌ 导入整个 lodash (~70KB gzipped)
import _ from 'lodash';
const grouped = _.groupBy(items, 'category');

// ✅ 按需导入 (几 KB)
import groupBy from 'lodash/groupBy';
const grouped = groupBy(items, 'category');

// ✅ 更好: 使用原生 JavaScript
const grouped = items.reduce<Record<string, typeof items>>((acc, item) => {
  const key = item.category;
  if (!acc[key]) acc[key] = [];
  acc[key].push(item);
  return acc;
}, {});

(2) 日期处理库优化

使用支持按需导入(模块化)、更轻量的库代替moment库。

// ❌ moment.js (~300KB gzipped)
import moment from 'moment';
const formatted = moment(date).format('YYYY-MM-DD');

// ✅ date-fns (按需导入,几 KB)
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';

const formatted = format(new Date(date), 'yyyy年MM月dd日', {
  locale: zhCN,
});

(3) 图标库优化

// ❌ 导入整个图标库
import { Icon } from '@iconify/react';

// ✅ 按需导入单个图标
import { HomeIcon } from '@heroicons/react/24/outline';
import { UserIcon } from '@heroicons/react/24/outline';

// 使用
<HomeIcon className="w-6 h-6" />

2. Bundle 优化检查清单

  • 移除未使用的依赖(npm prune)
  • 使用 Tree Shaking 友好的库
  • 避免重复打包相同库
  • 大型库使用动态导入
  • 定期运行 Bundle Analyzer 审查

五、数据库查询优化

Next.js 服务端组件直接查询数据库的特性,使得数据库最佳实践尤为重要。数据库的查询优化也是性能优化的一大组成部分。

1. N+1 查询问题

// ❌ N+1 问题: 21 次数据库查询
const posts = await db.posts.findMany({ take: 20 });
const postsWithAuthors = await Promise.all(
  posts.map(async post => ({
    ...post,
    author: await db.users.findUnique({ 
      where: { id: post.authorId } 
    }),
  }))
);

// ✅ 使用 include 关联查询: 1 次查询
const postsWithAuthors = await db.posts.findMany({
  take: 20,
  include: {
    author: {
      select: { 
        id: true, 
        name: true, 
        avatar: true 
      },
    },
  },
});

2. 分页查询实现

// lib/pagination.ts
interface PaginationResult<T> {
  data: T[];
  nextCursor: string | null;
  hasNextPage: boolean;
}

/**
 * 游标分页查询(适合无限滚动)
 */
export async function getPostsWithCursor(
  cursor?: string,
  pageSize = 10
): Promise<PaginationResult<Post>> {
  const posts = await db.posts.findMany({
    take: pageSize + 1,  // 多取一条判断是否有下一页
    ...(cursor && {
      skip: 1,
      cursor: { id: cursor },
    }),
    orderBy: { createdAt: 'desc' },
    select: {
      id: true,
      title: true,
      summary: true,
      publishedAt: true,
      author: {
        select: { name: true, avatar: true },
      },
    },
  });

  const hasNextPage = posts.length > pageSize;
  const data = hasNextPage ? posts.slice(0, -1) : posts;
  const nextCursor = hasNextPage ? data[data.length - 1].id : null;

  return { data, nextCursor, hasNextPage };
}

六、并行数据获取

串行数据获取会产生"瀑布效应",显著增加页面加载时间:

// ❌ 串行获取: 总时间 = A + B + C = 330ms
const userProfile = await getUserProfile(userId);     // 100ms
const userPosts = await getUserPosts(userId);         // 150ms
const followerCount = await getFollowerCount(userId); // 80ms

// ✅ 并行获取: 总时间 = max(A, B, C) = 150ms
const [userProfile, userPosts, followerCount] = await Promise.all([
  getUserProfile(userId),
  getUserPosts(userId),
  getFollowerCount(userId),
]);

性能提升: 从 330ms 降至 150ms,提升 54%


七、性能优化检查清单

部署前使用此清单进行全面检查:

1. 图片优化

  • 使用 <Image> 组件替代 <img>
  • 首屏图片添加 priority 属性
  • 设置正确的 sizes 属性
  • 使用 placeholder="blur" 改善加载体验
  • 外部图片源已配置白名单

2.JavaScript 优化

  • 大型第三方库使用 dynamic() 懒加载
  • 避免导入整个 lodashmoment 等大型库
  • 运行 Bundle Analyzer 检查包大小
  • 移除未使用的依赖

3. 缓存优化

  • 根据数据更新频率设置合适的缓存策略
  • 使用 revalidateTag 按需失效缓存
  • 静态内容使用 force-cache
  • 实时数据使用 no-store

4. 数据获取优化

  • 多个独立请求使用 Promise.all 并行获取
  • 避免 N+1 查询问题,使用 ORM 的 include
  • 实现分页,避免一次加载全量数据
  • 使用数据库索引优化查询性能

5. 渲染优化

  • 首屏重要内容在服务端渲染(SSR/SSG)
  • 使用 Suspense 局部加载,避免全页等待
  • 客户端组件控制在必要的最小范围
  • 避免不必要的 useEffect 和重渲染

6. Core Web Vitals

  • LCP < 2.5s
  • INP < 200ms
  • CLS < 0.1
  • 已配置 Web Vitals 监控

八、本章小结

通过本章学习,你应该掌握了:

  • Core Web Vitals 三大核心指标及其优化方法
  • 代码分割与动态导入的使用场景
  • Next.js 四层缓存机制的工作原理
  • Bundle 分析工具的使用和优化策略
  • 数据库查询优化技巧(N+1 问题、分页)
  • 并行数据获取的性能优势
  • 完整的性能优化检查清单

下一章将进入部署与运维环节——将精心优化的应用成功推向生产环境。

Next.js从入门到实战保姆级教程(第十七章):综合实战项目(下)——前端页面、性能优化与部署

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

这是全栈博客系统实战的下篇。在上篇《全栈博客系统架构与核心功能》中,我们完成了数据库设计、认证系统、Server Actions 等后端核心功能。本篇将聚焦前端页面开发、用户体验优化和生产部署,带你完成从代码到上线的完整流程。

一、📖 前置准备

在开始之前,请确保你已经:

  • ✅ 完成了上篇的所有内容
  • ✅ 数据库已初始化并运行
  • ✅ Auth.js 配置完成
  • ✅ Server Actions 可以正常调用

如果还没有,建议先回顾上篇内容《博客系统架构与核心功能》


二、🎨 Markdown 渲染与代码高亮

1. 为什么选择 MDX?

传统 Markdown 的局限性:

  • ❌ 无法使用 React 组件
  • ❌ 交互功能受限
  • ❌ 动态内容难以集成

MDX (Markdown + JSX) 的优势:

  • ✅ 在 Markdown 中嵌入 React 组件
  • ✅ 支持自定义渲染逻辑
  • ✅ 完美的 TypeScript 类型支持

例如,你可以在文章中这样写:

这是一段普通文本。

<Callout type="info">
  这是一个提示框组件!
</Callout>

```javascript
console.log('代码块自动高亮');
```

2. 安装依赖

npm install next-mdx-remote shiki rehype-autolink-headings rehype-slug

依赖说明:

  • next-mdx-remote: 在服务端安全地渲染 MDX
  • shiki: VS Code 同款语法高亮引擎(比 Prism.js 更准确)
  • rehype-autolink-headings: 自动为标题添加锚点链接
  • rehype-slug: 为标题生成 ID

3. 创建 MDX 渲染器组件

创建 components/MDXRenderer.tsx:

// components/MDXRenderer.tsx
import { MDXRemote } from 'next-mdx-remote/rsc';
import { serialize } from 'next-mdx-remote/serialize';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
import { CodeBlock } from './CodeBlock';
import { Callout } from './Callout';

interface MDXRendererProps {
  content: string;
}

/**
 * MDX 内容渲染器
 * 
 * 工作流程:
 * 1. serialize: 将 Markdown 字符串编译为 MDX AST
 * 2. MDXRemote: 在服务端渲染为 HTML
 * 3. components: 自定义组件映射表
 * 
 * @param content - Markdown 内容
 */
export async function MDXRenderer({ content }: MDXRendererProps) {
  // 序列化 MDX 内容
  const mdxSource = await serialize(content, {
    mdxOptions: {
      rehypePlugins: [
        rehypeSlug,  // 先生成 slug
        [rehypeAutolinkHeadings, { 
          behavior: 'wrap',  // 将整个标题包装为链接
          properties: {
            className: ['anchor-link'],
          },
        }],
      ],
    },
  });

  return (
    <article className="prose prose-lg max-w-none dark:prose-invert prose-headings:relative">
      <MDXRemote
        {...mdxSource}
        components={{
          // 自定义代码块渲染
          pre: CodeBlock,
          // 自定义提示框
          Callout,
          // 可以添加更多自定义组件
          img: CustomImage,
          a: CustomLink,
        }}
      />
    </article>
  );
}

/**
 * 自定义图片组件(懒加载)
 */
function CustomImage(props: React.ImgHTMLAttributes<HTMLImageElement>) {
  return (
    <img 
      {...props} 
      loading="lazy"  // 懒加载
      className="rounded-lg shadow-md"
    />
  );
}

/**
 * 自定义链接组件(外部链接新窗口打开)
 */
function CustomLink(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
  const href = props.href;
  const isExternal = href?.startsWith('http');

  return (
    <a
      {...props}
      {...(isExternal && {
        target: '_blank',
        rel: 'noopener noreferrer',
      })}
      className="text-blue-600 hover:underline"
    />
  );
}

🔍 代码解析:

(1) 为什么使用 next-mdx-remote/rsc?

import { MDXRemote } from 'next-mdx-remote/rsc';  // RSC 版本
  • RSC 版本: 在服务端渲染,性能更好
  • 客户端版本: next-mdx-remote/client,用于交互式 MDX

(2) Rehype 插件的作用

rehypePlugins: [
  rehypeSlug,  // 为 h1-h6 添加 id 属性
  [rehypeAutolinkHeadings, { behavior: 'wrap' }],  // 将标题变为可点击链接
]

执行顺序很重要:

  1. rehypeSlug 先执行,生成 id="introduction"
  2. rehypeAutolinkHeadings 后执行,包裹为 <a href="#introduction"><h2>...</h2></a>

(3) Components 映射表

components={{
  pre: CodeBlock,  // 替换所有 <pre> 标签
  Callout,         // 支持自定义 <Callout> 组件
}}

当 MDX 中出现 <pre> 时,会自动使用 CodeBlock 组件渲染。

4. 代码高亮组件

创建 components/CodeBlock.tsx:

// components/CodeBlock.tsx
import { codeToHtml } from 'shiki';

interface CodeBlockProps {
  children: React.ReactNode;
  className?: string;
}

/**
 * 代码块组件(带语法高亮)
 * 
 * Shiki 优势:
 * - 使用 TextMate grammar,与 VS Code 一致
 * - 支持主题切换
 * - 输出静态 HTML,无运行时 JS
 */
export async function CodeBlock({ children, className }: CodeBlockProps) {
  // 提取语言信息(如 language-jsx)
  const match = /language-(\w+)/.exec(className || '');
  const lang = match ? match[1] : 'text';
  
  // 获取代码内容
  const code = String(children).replace(/\n$/, '');

  // 使用 Shiki 生成高亮 HTML
  const html = await codeToHtml(code, {
    lang,
    theme: 'github-dark',  // 可切换主题
  });

  return (
    <div className="relative my-6 rounded-lg overflow-hidden">
      {/* 语言标签 */}
      <div className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700 text-gray-300 rounded">
        {lang}
      </div>
      
      {/* 高亮代码 */}
      <div 
        dangerouslySetInnerHTML={{ __html: html }}
        className="overflow-x-auto"
      />
    </div>
  );
}

⚡ 性能优化:

Shiki 是异步的,所以组件必须是 async:

export async function CodeBlock({ ... }) {
  const html = await codeToHtml(code, { ... });
  // ...
}

Next.js 会在服务端等待异步操作完成,然后缓存结果。

5. 提示框组件

创建 components/Callout.tsx:

// components/Callout.tsx
interface CalloutProps {
  type?: 'info' | 'warning' | 'error' | 'success';
  children: React.ReactNode;
}

const icons = {
  info: '💡',
  warning: '⚠️',
  error: '❌',
  success: '✅',
};

const styles = {
  info: 'bg-blue-50 border-blue-200 text-blue-800',
  warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
  error: 'bg-red-50 border-red-200 text-red-800',
  success: 'bg-green-50 border-green-200 text-green-800',
};

/**
 * 提示框组件
 * 
 * 使用示例:
 * <Callout type="warning">
 *   这是一个警告提示
 * </Callout>
 */
export function Callout({ type = 'info', children }: CalloutProps) {
  return (
    <div className={`p-4 my-4 border-l-4 rounded ${styles[type]}`}>
      <div className="flex items-start gap-3">
        <span className="text-xl">{icons[type]}</span>
        <div className="flex-1">{children}</div>
      </div>
    </div>
  );
}

三、🏠 首页文章列表

1. 页面结构

创建 app/page.tsx:

// app/page.tsx
import { getPosts } from '@/lib/posts';
import Link from 'next/link';
import Image from 'next/image';
import { formatDate } from '@/lib/utils';

// ==================== 元数据 ====================

export const metadata = {
  title: '全栈博客 - 分享技术与思考',
  description: '专注于 Next.js、React、TypeScript 等现代 Web 开发技术',
};

// ==================== 缓存策略 ====================

/**
 * 每小时重新验证一次
 * 
 * 为什么不是静态生成?
 * - 文章可能频繁更新
 * - 需要显示最新评论数、点赞数
 * - revalidate 平衡了性能和时效性
 */
export const revalidate = 3600;

// ==================== 页面组件 ====================

export default async function HomePage() {
  // 获取第一页的 10 篇文章
  const { posts, pagination } = await getPosts({ 
    page: 1, 
    pageSize: 10 
  });

  return (
    <div className="container mx-auto px-4 py-8">
      {/* Hero 区域 */}
      <section className="mb-12 text-center">
        <h1 className="text-5xl font-bold mb-4 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
          全栈博客
        </h1>
        <p className="text-xl text-gray-600">
          分享 Next.js、React、TypeScript 等现代 Web 开发技术
        </p>
      </section>

      {/* 文章列表 */}
      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>

      {/* 分页 */}
      {pagination.totalPages > 1 && (
        <Pagination 
          currentPage={pagination.page}
          totalPages={pagination.totalPages}
        />
      )}

      {/* 空状态 */}
      {posts.length === 0 && (
        <EmptyState />
      )}
    </div>
  );
}

/**
 * 文章卡片组件
 */
function PostCard({ post }: { post: any }) {
  return (
    <article className="group border rounded-lg overflow-hidden hover:shadow-lg transition-all duration-300">
      {/* 封面图 */}
      {post.coverImage && (
        <Link href={`/blog/${post.slug}`}>
          <Image
            src={post.coverImage}
            alt={post.title}
            width={400}
            height={200}
            className="w-full h-48 object-cover group-hover:scale-105 transition-transform"
          />
        </Link>
      )}
      
      <div className="p-4">
        {/* 标题 */}
        <h2 className="text-xl font-semibold mb-2 line-clamp-2">
          <Link 
            href={`/blog/${post.slug}`}
            className="hover:text-blue-600 transition-colors"
          >
            {post.title}
          </Link>
        </h2>
        
        {/* 摘要 */}
        <p className="text-gray-600 text-sm mb-4 line-clamp-2">
          {post.excerpt}
        </p>
        
        {/* 元信息 */}
        <div className="flex items-center justify-between text-sm text-gray-500">
          <div className="flex items-center gap-2">
            {post.author.image && (
              <Image
                src={post.author.image}
                alt={post.author.name || ''}
                width={24}
                height={24}
                className="rounded-full"
              />
            )}
            <span>{post.author.name}</span>
          </div>
          
          <div className="flex gap-3">
            <span title="浏览量">👁 {post.viewCount}</span>
            <span title="评论数">💬 {post._count.comments}</span>
            <span title="点赞数">❤️ {post._count.likes}</span>
          </div>
        </div>
        
        {/* 日期和阅读时间 */}
        <div className="mt-3 flex items-center gap-3 text-xs text-gray-400">
          <time dateTime={post.publishedAt?.toISOString()}>
            {formatDate(post.publishedAt || post.createdAt)}
          </time>
          {post.readingTime && (
            <>
              <span></span>
              <span>{post.readingTime} 分钟阅读</span>
            </>
          )}
        </div>
        
        {/* 标签 */}
        {post.tags.length > 0 && (
          <div className="flex flex-wrap gap-2 mt-3">
            {post.tags.slice(0, 3).map(({ tag }) => (
              <Link
                key={tag.id}
                href={`/tags/${tag.slug}`}
                className="px-2 py-1 text-xs rounded-full hover:opacity-80 transition-opacity"
                style={{ 
                  backgroundColor: `${tag.color}20`, 
                  color: tag.color 
                }}
              >
                {tag.name}
              </Link>
            ))}
          </div>
        )}
      </div>
    </article>
  );
}

/**
 * 分页组件
 */
function Pagination({ 
  currentPage, 
  totalPages 
}: { 
  currentPage: number;
  totalPages: number;
}) {
  return (
    <nav className="flex justify-center gap-2 mt-8">
      {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
        <Link
          key={page}
          href={`/?page=${page}`}
          className={`px-4 py-2 rounded ${
            page === currentPage
              ? 'bg-blue-600 text-white'
              : 'bg-gray-100 hover:bg-gray-200'
          }`}
        >
          {page}
        </Link>
      ))}
    </nav>
  );
}

/**
 * 空状态组件
 */
function EmptyState() {
  return (
    <div className="text-center py-12">
      <div className="text-6xl mb-4">📝</div>
      <h3 className="text-xl font-semibold mb-2">暂无文章</h3>
      <p className="text-gray-600">
        博主正在努力创作中,敬请期待...
      </p>
    </div>
  );
}

📖 设计要点解析:

(1) 渐进增强原则

<Link href={`/blog/${post.slug}`}>
  <Image src={post.coverImage} alt={post.title} />
</Link>

即使 JavaScript 未加载,用户仍可点击链接跳转,保证基本可用性。

(2) 图片优化

<Image
  src={post.coverImage}
  width={400}
  height={200}
  className="group-hover:scale-105 transition-transform"
/>

next/image 自动:

  • ✅ 生成多种尺寸的图片
  • ✅ 转换为现代格式(WebP/AVIF)
  • ✅ 懒加载(非首屏图片)
  • ✅ 防止布局偏移(CLS)

(3) 文本截断

className="line-clamp-2"  // 最多显示 2 行

Tailwind CSS 的实用类,优雅地处理长文本。


四、📄 文章详情页

1. 动态路由页面

创建 app/blog/[slug]/page.tsx:

// app/blog/[slug]/page.tsx
import { getPostBySlug } from '@/lib/posts';
import { notFound } from 'next/navigation';
import { MDXRenderer } from '@/components/MDXRenderer';
import { CommentSection } from '@/components/CommentSection';
import { LikeButton } from '@/components/LikeButton';
import { BookmarkButton } from '@/components/BookmarkButton';
import { auth } from '@/auth';
import Image from 'next/image';

interface BlogPostPageProps {
  params: Promise<{ slug: string }>;
}

// ==================== 元数据生成 ====================

export async function generateMetadata({ params }: BlogPostPageProps) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);
  
  if (!post) {
    return {};
  }

  return {
    title: post.title,
    description: post.excerpt || post.content.substring(0, 160),
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: post.coverImage ? [{ url: post.coverImage }] : [],
      type: 'article',
      publishedTime: post.publishedAt?.toISOString(),
      authors: [post.author.name].filter(Boolean),
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: post.coverImage ? [post.coverImage] : [],
    },
  };
}

// ==================== 静态参数生成(可选优化) ====================

/**
 * 预生成热门文章的静态页面
 * 
 * 适用场景:
 * - 访问量高的文章
 * - 不经常更新的内容
 * 
 * 注意:如果文章很多,不要全部预生成,会导致构建缓慢
 */
export async function generateStaticParams() {
  // 只预生成最近 10 篇文章
  const { posts } = await getPosts({ 
    page: 1, 
    pageSize: 10,
    published: true 
  });

  return posts.map(post => ({
    slug: post.slug,
  }));
}

// ==================== 页面组件 ====================

export default async function BlogPostPage({ params }: BlogPostPageProps) {
  const { slug } = await params;
  
  // 并行获取文章和当前用户
  const [post, session] = await Promise.all([
    getPostBySlug(slug),
    auth(),
  ]);

  if (!post) {
    notFound();
  }

  return (
    <article className="container mx-auto px-4 py-8 max-w-4xl">
      {/* 文章头部 */}
      <PostHeader post={post} />

      {/* 互动按钮 */}
      <InteractionBar 
        postId={post.id}
        initialLiked={false}
        initialBookmarked={false}
        user={session?.user || null}
      />

      {/* 文章内容 */}
      <MDXRenderer content={post.content} />

      {/* 标签 */}
      <PostTags tags={post.tags} />

      {/* 作者信息 */}
      <AuthorCard author={post.author} />

      {/* 评论区 */}
      <CommentSection 
        postId={post.id} 
        comments={post.comments}
        currentUser={session?.user || null}
      />
    </article>
  );
}

/**
 * 文章头部组件
 */
function PostHeader({ post }: { post: any }) {
  return (
    <header className="mb-8 pb-8 border-b">
      {/* 标题 */}
      <h1 className="text-4xl md:text-5xl font-bold mb-6">
        {post.title}
      </h1>
      
      {/* 作者和日期 */}
      <div className="flex flex-wrap items-center gap-4 text-gray-600">
        {post.author.image && (
          <Image
            src={post.author.image}
            alt={post.author.name || ''}
            width={40}
            height={40}
            className="rounded-full"
          />
        )}
        <span className="font-medium">{post.author.name}</span>
        <span></span>
        <time dateTime={post.publishedAt?.toISOString()}>
          {new Date(post.publishedAt || post.createdAt).toLocaleDateString('zh-CN', {
            year: 'numeric',
            month: 'long',
            day: 'numeric',
          })}
        </time>
        <span></span>
        <span>{post.readingTime} 分钟阅读</span>
        <span></span>
        <span>👁 {post.viewCount} 次阅读</span>
      </div>

      {/* 封面图 */}
      {post.coverImage && (
        <div className="mt-6 rounded-lg overflow-hidden">
          <Image
            src={post.coverImage}
            alt={post.title}
            width={1200}
            height={600}
            priority  // 首屏图片,优先加载
            className="w-full h-auto"
          />
        </div>
      )}
    </header>
  );
}

/**
 * 互动按钮栏
 */
function InteractionBar({ 
  postId, 
  initialLiked, 
  initialBookmarked,
  user 
}: { 
  postId: string;
  initialLiked: boolean;
  initialBookmarked: boolean;
  user: any;
}) {
  return (
    <div className="flex gap-4 mb-8 pb-8 border-b">
      <LikeButton 
        postId={postId} 
        initialLiked={initialLiked}
        isAuthenticated={!!user}
      />
      <BookmarkButton 
        postId={postId} 
        initialBookmarked={initialBookmarked}
        isAuthenticated={!!user}
      />
    </div>
  );
}

/**
 * 标签组件
 */
function PostTags({ tags }: { tags: any[] }) {
  if (tags.length === 0) return null;

  return (
    <div className="flex flex-wrap gap-2 my-8">
      {tags.map(({ tag }) => (
        <Link
          key={tag.id}
          href={`/tags/${tag.slug}`}
          className="px-3 py-1 text-sm rounded-full transition-opacity hover:opacity-80"
          style={{ 
            backgroundColor: `${tag.color}20`, 
            color: tag.color 
          }}
        >
          #{tag.name}
        </Link>
      ))}
    </div>
  );
}

/**
 * 作者卡片
 */
function AuthorCard({ author }: { author: any }) {
  return (
    <div className="my-12 p-6 bg-gray-50 rounded-lg">
      <div className="flex items-center gap-4">
        {author.image && (
          <Image
            src={author.image}
            alt={author.name || ''}
            width={60}
            height={60}
            className="rounded-full"
          />
        )}
        <div>
          <h3 className="font-semibold text-lg">{author.name}</h3>
          {author.bio && (
            <p className="text-gray-600 text-sm mt-1">{author.bio}</p>
          )}
        </div>
      </div>
    </div>
  );
}

🎯 关键知识点:

(1)Metadata API

export async function generateMetadata({ params }) {
  return {
    title: post.title,
    openGraph: { /* Facebook/Twitter 预览 */ },
    twitter: { /* Twitter Card */ },
  };
}

SEO 最佳实践:

  • title: 控制在 60 字符以内
  • description: 150-160 字符,包含关键词
  • openGraph.images: 至少 1200x630 像素
  • twitter.card: 使用 summary_large_image 获得大卡片

(2) generateStaticParams

export async function generateStaticParams() {
  const { posts } = await getPosts({ page: 1, pageSize: 10 });
  return posts.map(post => ({ slug: post.slug }));
}

何时使用?

  • ✅ 访问量高的页面(首页、热门文章)
  • ✅ 内容不频繁变化
  • ❌ 文章数量巨大(会导致构建缓慢)

效果:

  • 这些页面在构建时生成静态 HTML
  • 访问时无需服务端渲染,速度极快

(3)并行数据获取

const [post, session] = await Promise.all([
  getPostBySlug(slug),
  auth(),
]);

而不是串行:

// ❌ 慢
const post = await getPostBySlug(slug);
const session = await auth();

五、💬 评论组件实现

1. 评论列表

创建 components/CommentSection.tsx:

// components/CommentSection.tsx
'use client';

import { useState } from 'react';
import { createComment } from '@/app/actions/comment';
import Image from 'next/image';
import { formatDate } from '@/lib/utils';

interface CommentSectionProps {
  postId: string;
  comments: any[];
  currentUser: any;
}

/**
 * 评论区组件
 * 
 * 功能:
 * - 显示评论列表(支持嵌套)
 * - 发表评论
 * - 回复评论
 * - Optimistic UI(乐观更新)
 */
export function CommentSection({ 
  postId, 
  comments,
  currentUser 
}: CommentSectionProps) {
  const [commentList, setCommentList] = useState(comments);
  const [replyingTo, setReplyingTo] = useState<string | null>(null);
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);

  /**
   * 提交评论
   */
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    if (!content.trim()) return;
    if (!currentUser) {
      alert('请先登录');
      return;
    }

    setLoading(true);

    try {
      const result = await createComment({
        postId,
        content,
        parentId: replyingTo || undefined,
      });

      if (result.success && result.comment) {
        // Optimistic Update: 立即更新 UI
        if (replyingTo) {
          // 添加到回复列表
          setCommentList(prev =>
            prev.map(comment =>
              comment.id === replyingTo
                ? {
                    ...comment,
                    replies: [...(comment.replies || []), result.comment],
                  }
                : comment
            )
          );
        } else {
          // 添加到顶级评论
          setCommentList(prev => [...prev, result.comment]);
        }

        // 清空表单
        setContent('');
        setReplyingTo(null);
      } else {
        alert(result.error || '评论失败');
      }
    } catch (error) {
      console.error(error);
      alert('评论失败,请稍后重试');
    } finally {
      setLoading(false);
    }
  };

  return (
    <section className="mt-12 pt-8 border-t">
      <h2 className="text-2xl font-bold mb-6">
        评论 ({commentList.length})
      </h2>

      {/* 评论表单 */}
      <CommentForm
        content={content}
        onChange={setContent}
        onSubmit={handleSubmit}
        loading={loading}
        placeholder={
          replyingTo ? '撰写回复...' : '写下你的评论...'
        }
        onCancel={() => setReplyingTo(null)}
        isReply={!!replyingTo}
      />

      {/* 评论列表 */}
      <div className="space-y-6 mt-8">
        {commentList.map(comment => (
          <CommentItem
            key={comment.id}
            comment={comment}
            currentUser={currentUser}
            onReply={(commentId) => setReplyingTo(commentId)}
            replyingTo={replyingTo}
          />
        ))}

        {commentList.length === 0 && (
          <p className="text-center text-gray-500 py-8">
            暂无评论,来发表第一条评论吧!
          </p>
        )}
      </div>
    </section>
  );
}

/**
 * 评论表单组件
 */
function CommentForm({
  content,
  onChange,
  onSubmit,
  loading,
  placeholder,
  onCancel,
  isReply,
}: any) {
  return (
    <form onSubmit={onSubmit} className="mb-8">
      <textarea
        value={content}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
        rows={4}
        className="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
        required
      />
      
      <div className="flex justify-end gap-2 mt-3">
        {isReply && (
          <button
            type="button"
            onClick={onCancel}
            className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
          >
            取消
          </button>
        )}
        <button
          type="submit"
          disabled={loading || !content.trim()}
          className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {loading ? '提交中...' : '发表评论'}
        </button>
      </div>
    </form>
  );
}

/**
 * 单条评论组件
 */
function CommentItem({ 
  comment, 
  currentUser, 
  onReply,
  replyingTo 
}: any) {
  const isReplying = replyingTo === comment.id;

  return (
    <div className="flex gap-4">
      {/* 头像 */}
      {comment.author.image && (
        <Image
          src={comment.author.image}
          alt={comment.author.name || ''}
          width={40}
          height={40}
          className="rounded-full flex-shrink-0"
        />
      )}

      <div className="flex-1">
        {/* 评论头部 */}
        <div className="flex items-center gap-2 mb-2">
          <span className="font-medium">{comment.author.name}</span>
          <time 
            className="text-sm text-gray-500"
            dateTime={comment.createdAt}
          >
            {formatDate(comment.createdAt)}
          </time>
        </div>

        {/* 评论内容 */}
        <p className="text-gray-700 mb-3 whitespace-pre-wrap">
          {comment.content}
        </p>

        {/* 回复按钮 */}
        {currentUser && !isReplying && (
          <button
            onClick={() => onReply(comment.id)}
            className="text-sm text-blue-600 hover:underline"
          >
            回复
          </button>
        )}

        {/* 回复表单 */}
        {isReplying && (
          <div className="mt-4 ml-8">
            <CommentForm
              content=""
              onChange={() => {}}
              onSubmit={async (e: any) => {
                e.preventDefault();
                // 实际应由父组件处理
              }}
              loading={false}
              placeholder="撰写回复..."
              onCancel={() => onReply(null)}
              isReply={true}
            />
          </div>
        )}

        {/* 回复列表 */}
        {comment.replies?.length > 0 && (
          <div className="mt-4 space-y-4 ml-8">
            {comment.replies.map((reply: any) => (
              <CommentItem
                key={reply.id}
                comment={reply}
                currentUser={currentUser}
                onReply={onReply}
                replyingTo={replyingTo}
              />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

💡 Optimistic UI 原理:

提交评论的时候采用了乐观更新的方式:

// 1. 立即更新 UI(假设成功)
setCommentList(prev => [...prev, newComment]);

// 2. 发送请求
const result = await createComment(data);

// 3. 如果失败,回滚
if (!result.success) {
  setCommentList(prev => prev.filter(c => c.id !== newComment.id));
}

优势:

  • ✅ 用户体验极佳,无需等待服务器响应
  • ✅ 减少感知延迟

风险:

  • ⚠️ 需要处理失败情况
  • ⚠️ 不适合关键操作(如支付)

后续的点赞收藏功能也采用乐观更新。


六、❤️ 点赞与收藏按钮

1. 点赞按钮

创建 components/LikeButton.tsx:

// components/LikeButton.tsx
'use client';

import { useState } from 'react';
import { toggleLike } from '@/app/actions/interaction';

interface LikeButtonProps {
  postId: string;
  initialLiked: boolean;
  isAuthenticated: boolean;
}

/**
 * 点赞按钮(Optimistic UI)
 * 
 * 交互流程:
 * 1. 用户点击
 * 2. 立即切换 UI 状态
 * 3. 后台发送请求
 * 4. 如果失败,回滚状态
 */
export function LikeButton({ 
  postId, 
  initialLiked,
  isAuthenticated 
}: LikeButtonProps) {
  const [liked, setLiked] = useState(initialLiked);
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    if (!isAuthenticated) {
      alert('请先登录');
      return;
    }

    // Optimistic Update
    const previousState = liked;
    setLiked(!previousState);
    setLoading(true);

    try {
      const result = await toggleLike(postId);

      if (!result.success) {
        // 回滚
        setLiked(previousState);
        alert(result.error);
      }
    } catch (error) {
      // 回滚
      setLiked(previousState);
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${
        liked
          ? 'bg-red-50 text-red-600 hover:bg-red-100'
          : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
      }`}
    >
      <span className={`text-xl ${liked ? 'animate-pulse' : ''}`}>
        {liked ? '❤️' : '🤍'}
      </span>
      <span>{liked ? '已点赞' : '点赞'}</span>
    </button>
  );
}

2. 收藏按钮

创建 components/BookmarkButton.tsx:

// components/BookmarkButton.tsx
'use client';

import { useState } from 'react';
import { toggleBookmark } from '@/app/actions/interaction';

interface BookmarkButtonProps {
  postId: string;
  initialBookmarked: boolean;
  isAuthenticated: boolean;
}

export function BookmarkButton({ 
  postId, 
  initialBookmarked,
  isAuthenticated 
}: BookmarkButtonProps) {
  const [bookmarked, setBookmarked] = useState(initialBookmarked);
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    if (!isAuthenticated) {
      alert('请先登录');
      return;
    }

    const previousState = bookmarked;
    setBookmarked(!previousState);
    setLoading(true);

    try {
      const result = await toggleBookmark(postId);

      if (!result.success) {
        setBookmarked(previousState);
        alert(result.error);
      }
    } catch (error) {
      setBookmarked(previousState);
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${
        bookmarked
          ? 'bg-yellow-50 text-yellow-600 hover:bg-yellow-100'
          : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
      }`}
    >
      <span className="text-xl">
        {bookmarked ? '⭐' : '☆'}
      </span>
      <span>{bookmarked ? '已收藏' : '收藏'}</span>
    </button>
  );
}

七、🔐 登录页面

1. 自定义登录页

创建 app/auth/signin/page.tsx:

// app/auth/signin/page.tsx
import { signIn } from '@/auth';
import { Github } from 'lucide-react';

export const metadata = {
  title: '登录 - 全栈博客',
  description: '使用 GitHub 账号登录',
};

export default function SignInPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
        <h1 className="text-3xl font-bold text-center mb-8">欢迎回来</h1>
        
        <form
          action={async () => {
            'use server';
            await signIn('github', { 
              redirectTo: '/' 
            });
          }}
          className="space-y-4"
        >
          <button
            type="submit"
            className="w-full flex items-center justify-center gap-3 px-6 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
          >
            <Github className="w-5 h-5" />
            使用 GitHub 登录
          </button>
        </form>

        <p className="mt-6 text-center text-sm text-gray-600">
          登录后即可评论、点赞、收藏文章
        </p>
      </div>
    </div>
  );
}

🔑 Server Actions 表单:

<form action={async () => {
  'use server';
  await signIn('github', { redirectTo: '/' });
}}>
  <button type="submit">登录</button>
</form>

这种写法:

  • ✅ 无需 JavaScript 也可工作
  • ✅ 自动处理 CSRF Token
  • ✅ 简洁优雅

八、⚡ 性能优化深度实践

1. 图片懒加载与优先级

// 首屏图片:优先加载
<Image
  src={heroImage}
  priority  // 关键!
  alt="Hero"
/>

// 非首屏图片:懒加载(默认行为)
<Image
  src={thumbnail}
  alt="Thumbnail"
  loading="lazy"  // 可省略,默认就是 lazy
/>

2. 字体优化

创建 app/layout.tsx:

// app/layout.tsx
import { Inter } from 'next/font/google';

// Next.js 自动优化字体
const inter = Inter({ 
  subsets: ['latin'],
  display: 'swap',  // 避免 FOIT(Flash of Invisible Text)
});

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN">
      <body className={inter.className}>
        {children}
      </body>
    </html>
  );
}

优势:

  • ✅ 自动托管字体文件(CDN)
  • ✅ 消除布局偏移
  • ✅ 预加载关键字体

3. 代码分割

Next.js App Router 自动进行代码分割:

  • 每个路由独立 bundle
  • 客户端组件按需加载
  • 第三方库 Tree Shaking

无需手动配置!

4. 流式渲染(Streaming)

对于慢查询,可以使用 Suspense:

// app/blog/[slug]/page.tsx
import { Suspense } from 'react';

export default function BlogPostPage({ params }) {
  return (
    <article>
      {/* 快速加载的部分 */}
      <PostHeader />
      
      {/* 慢查询部分:流式加载 */}
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </article>
  );
}

async function Comments() {
  // 模拟慢查询
  await new Promise(resolve => setTimeout(resolve, 2000));
  return <div>评论内容...</div>;
}

function CommentsSkeleton() {
  return <div className="animate-pulse">加载中...</div>;
}

效果:

  • 用户先看到文章头部
  • 评论逐步加载,无需等待

九、🚀 部署上线

1. Vercel 部署(推荐)

步骤 1:推送代码到 GitHub

git init
git add .
git commit -m "feat: 完成博客系统"
git remote add origin https://github.com/yourusername/fullstack-blog.git
git push -u origin main

步骤 2:连接 Vercel

  1. 访问 vercel.com
  2. 点击 "New Project"
  3. 导入 GitHub 仓库
  4. 配置环境变量

步骤 3:配置环境变量

在 Vercel Dashboard → Settings → Environment Variables 中添加:

DATABASE_URL=postgresql://...
AUTH_SECRET=your-secret-key
GITHUB_ID=your-github-id
GITHUB_SECRET=your-github-secret
OPENAI_API_KEY=sk-your-key
NEXT_PUBLIC_APP_URL=https://your-domain.vercel.app

步骤 4:自动部署

每次推送到 main 分支,Vercel 会自动:

  1. 安装依赖
  2. 执行 next build
  3. 部署到全球 CDN
  4. 提供预览 URL

2. 数据库托管(Neon)

Neon 提供免费 Serverless PostgreSQL:

  1. 注册 neon.tech
  2. 创建新项目
  3. 获取连接字符串
  4. 更新 DATABASE_URL

优势:

  • ✅ 免费 tier: 0.5 GB 存储
  • ✅ 自动扩缩容
  • ✅ 分支功能(类似 Git)

3. 自定义域名

在 Vercel Dashboard → Settings → Domains 中:

  1. 添加你的域名
  2. 按提示配置 DNS(CNAME/A Record)
  3. 等待 SSL 证书签发(自动)

十一、📊 监控与分析

1. Vercel Analytics

app/layout.tsx 中添加:

import { Analytics } from '@vercel/analytics/react';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

安装依赖:

npm install @vercel/analytics

功能:

  • 页面浏览量
  • 用户地理位置
  • 设备类型
  • 性能指标

2. Core Web Vitals 监控

Vercel 自动收集:

  • LCP(Largest Contentful Paint): 最大内容绘制时间
  • FID(First Input Delay): 首次输入延迟
  • CLS(Cumulative Layout Shift): 累积布局偏移

目标值:

  • LCP < 2.5s
  • FID < 100ms
  • CLS < 0.1

十二、📝 本章小结

通过上下两篇的学习,你已完成了一个生产级全栈博客系统:

✅ 已完成功能

模块 功能 技术栈
用户系统 GitHub OAuth 登录 Auth.js
文章管理 CRUD、Markdown 渲染 Prisma、MDX
AI 增强 自动摘要、标签推荐 OpenAI API
社交互动 评论、点赞、收藏 Server Actions
性能优化 缓存、懒加载、流式渲染 Next.js 内置
部署运维 Vercel 自动化部署 CI/CD

🎯 核心知识点回顾

  1. App Router 架构: 文件系统路由、嵌套布局、并行路由
  2. React Server Components: 服务端渲染、减少客户端 JS
  3. Server Actions: 类型安全的表单处理
  4. 数据缓存策略: revalidateTaggenerateStaticParams
  5. 性能优化: next/image、字体优化、代码分割
  6. SEO 最佳实践: Metadata API、Open Graph、Sitemap

🚀 下一步扩展方向

  1. 全文搜索: 集成 Meilisearch 或 Algolia
  2. RSS 订阅: 生成 RSS/Atom Feed
  3. 邮件通知: 新评论提醒(Resend/SendGrid)
  4. 管理后台: 文章审核、数据统计、用户管理
  5. 暗黑模式: next-themes 实现主题切换
  6. 国际化: next-intl 多语言支持
  7. PWA: 离线访问、推送通知

💪 练习作业

  1. 实现"相关文章推荐"功能(基于标签相似度)
  2. 添加"阅读进度条"(客户端组件)
  3. 实现"代码复制"按钮(CodeBlock 组件)
  4. 添加 Google Analytics 集成
  5. 实现简单的站内搜索(使用 Prisma 全文搜索)

🎉 结语

恭喜你完成了这个完整的 Next.js 全栈项目!

从环境配置到生产部署,你已掌握了:

  • ✅ 现代 Web 开发的最佳实践
  • ✅ 全栈应用的架构设计思路
  • ✅ 性能优化与 SEO 技巧
  • ✅ 自动化部署与监控

记住: 学习编程最好的方式就是不断实践。在此基础上,尝试添加新功能、优化现有代码、重构架构。

祝你成为一名优秀的 Next.js 全栈开发者! 🚀


资源链接:

Next.js从入门到实战保姆级教程(第十六章):实战项目(上)——全栈博客系统架构与核心功能

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

理论学一百遍,不如动手做一遍。 本章将带你从零开始构建一个有实际价值的全栈博客系统,将前面所有章节的知识融会贯通。我们将分上下两篇完成这个项目,上篇聚焦架构设计与核心功能实现,下篇将完善前端页面、性能优化及部署上线。

一、📋 项目规划与设计思路

1. 为什么选择博客系统作为实战项目?

在开始编码之前,我们先思考一个问题:为什么博客系统是学习 Next.js 的最佳实战项目?

mindmap
  root((为什么选博客系统))
    (技术覆盖面广)
      路由系统
      数据获取
      表单处理
      认证鉴权
    (业务逻辑完整)
      CRUD 操作
      权限控制
      缓存策略
      SEO 优化
    (可扩展性强)
      评论系统
      AI 集成
      搜索功能
      管理后台
    (真实应用场景)
      个人品牌
      技术分享
      作品集展示

博客系统看似简单,实则涵盖了现代 Web 开发的几乎所有核心技术点:

  1. 内容管理系统(CMS):文章的创建、编辑、删除
  2. 用户系统:注册、登录、权限管理
  3. 交互功能:评论、点赞、收藏
  4. 性能优化:缓存策略、图片优化、SEO
  5. AI 增强:智能摘要、标签推荐

通过这个项目,你将真正理解如何将理论知识转化为生产力

2. 功能特性全景图

让我们先明确这个博客系统要实现哪些功能:

(1)核心功能模块

模块 功能点 技术要点
用户系统 邮箱/GitHub 登录、个人资料管理 Auth.js、Session 管理
文章系统 Markdown 编写、代码高亮、标签分类 MDX、Shiki、Prisma
AI 功能 自动生成摘要、智能标签推荐 OpenAI API、Vercel AI SDK
社交互动 评论、点赞、收藏、RSS 订阅 Server Actions、Optimistic UI
管理后台 文章审核、数据统计、用户管理 RBAC 权限控制

3. 技术选型决策过程

在实际项目中,技术选型不是越新越好,而是要权衡多个维度:

(1)框架选择:Next.js 15 App Router

  • React Server Components 提升性能
  • 文件系统路由简化开发
  • 内置优化(Image/Font/Metadata)
  • Vercel 生态无缝集成

(2) 数据库方案:PostgreSQL + Prisma ORM

  • 关系型数据库适合博客数据结构
  • Prisma 提供类型安全的查询
  • Neon 提供免费 Serverless PostgreSQL
  • 迁移工具简化数据库版本管理

(3) 认证方案:Auth.js (NextAuth v5)

  • 官方推荐的 Next.js 认证方案
  • 支持 OAuth 和凭证登录
  • 与 Prisma 适配器完美集成
  • Session 管理开箱即用

(4)样式方案:Tailwind CSS

  • 实用优先,开发效率高
  • 与 Next.js 深度集成
  • 响应式设计简单易用
  • 社区组件库丰富

关键决策原则:

  • 稳定性优先: 选择成熟稳定的技术栈,而非最新但未经验证的
  • 生态完整: 优先考虑有良好文档和社区支持的技术
  • 开发体验: 减少样板代码,提高开发效率
  • 可维护性: 类型安全、清晰的代码结构

二、🚀 项目初始化与环境搭建

第一步:创建 Next.js 项目

打开终端,执行以下命令:

npx create-next-app@latest fullstack-blog

在交互式提示中,按以下方式选择:

✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … No

为什么要这样配置?

  • TypeScript: 提供类型安全,减少运行时错误,是生产项目的标配
  • ESLint: 自动检测代码问题,保持代码质量
  • Tailwind CSS: 快速构建 UI,避免手写大量 CSS
  • src/ 目录: 更好的项目结构组织,分离源代码和配置文件
  • App Router: Next.js 13+ 的推荐路由方案,支持 RSC

第二步:安装核心依赖

进入项目目录后,我们需要安装几类依赖:

cd fullstack-blog

# 1️⃣ 数据库相关
npm install prisma @prisma/client

# 2️⃣ 认证相关
npm install next-auth@beta @auth/prisma-adapter bcryptjs

# 3️⃣ Markdown 渲染
npm install next-mdx-remote shiki rehype-autolink-headings rehype-slug

# 4️⃣ 表单验证
npm install zod react-hook-form @hookform/resolvers

# 5️⃣ AI 集成
npm install ai openai

# 6️⃣ 工具库
npm install date-fns slugify clsx tailwind-merge

# 7️⃣ 开发依赖(类型定义)
npm install -D @types/bcryptjs

依赖分类解析:

类别 包名 作用
ORM prisma, @prisma/client 类型安全的数据库访问层
认证 next-auth@beta Next.js 官方认证库 v5 版本
密码加密 bcryptjs 用户密码哈希加密
MDX next-mdx-remote 在服务端渲染 Markdown
代码高亮 shiki VS Code 同款语法高亮引擎
表单 zod, react-hook-form Schema 验证 + 高性能表单管理
AI ai, openai Vercel AI SDK + OpenAI 客户端
工具 date-fns, slugify 日期格式化、URL 友好字符串生成

第三步:环境变量配置

在项目根目录创建 .env.local 文件:

# .env.local

# ==================== 数据库配置 ====================
# 本地开发使用 PostgreSQL
DATABASE_URL="postgresql://user:password@localhost:5432/blog"
# Prisma 直连 URL(用于迁移等操作)
DIRECT_URL="postgresql://user:password@localhost:5432/blog"

# ==================== 认证配置 ====================
# Auth.js 会话加密密钥(至少 32 字符)
AUTH_SECRET="your-secret-key-min-32-characters-long!!!"
# GitHub OAuth 凭据(需在 GitHub Developer Settings 中创建)
GITHUB_ID="your-github-client-id"
GITHUB_SECRET="your-github-client-secret"

# ==================== AI 配置 ====================
# OpenAI API Key(从 https://platform.openai.com 获取)
OPENAI_API_KEY="sk-your-openai-api-key"

# ==================== 应用配置 ====================
# 应用基础 URL(开发环境)
NEXT_PUBLIC_APP_URL="http://localhost:3000"

⚠️ 安全提醒:

  • .env.local 已默认添加到 .gitignore,不会提交到 Git
  • AUTH_SECRET 可使用命令生成: openssl rand -base64 32
  • 生产环境需在部署平台(Vercel/Docker)配置这些变量

第四步:启动开发服务器

npm run dev

访问 http://localhost:3000,如果看到 Next.js 欢迎页面,说明项目初始化成功! 🎉


三、🗄️ 数据库设计与 Prisma 建模

1. 为什么需要精心设计数据库?

数据库设计直接影响应用的性能、可扩展性和维护成本。对于博客系统,我们需要考虑:

  1. 实体关系: 用户、文章、标签、评论之间的关系
  2. 索引优化: 加速常用查询(如按 slug 查找文章)
  3. 数据完整性: 外键约束、级联删除
  4. 扩展预留: 未来可能添加的功能(如点赞、收藏)

2. ER 图(Entity-Relationship Diagram)

erDiagram
    USER ||--o{ POST : writes
    USER ||--o{ COMMENT : comments
    USER ||--o{ LIKE : likes
    USER ||--o{ BOOKMARK : bookmarks
    
    POST ||--o{ POST_TAG : has
    TAG ||--o{ POST_TAG : tagged_in
    POST ||--o{ COMMENT : receives
    POST ||--o{ LIKE : gets
    POST ||--o{ BOOKMARK : saved
    
    COMMENT ||--o{ COMMENT : replies_to
    
    USER {
        String id PK
        String email UK
        String name
        Role role
    }
    
    POST {
        String id PK
        String slug UK
        String title
        Boolean published
    }
    
    TAG {
        String id PK
        String name UK
        String slug UK
    }
    
    COMMENT {
        String id PK
        String postId FK
        String parentId FK
    }

3. Prisma Schema 详解

创建 prisma/schema.prisma 文件:

// prisma/schema.prisma

// 1. 生成器配置:告诉 Prisma 生成什么语言的客户端
generator client {
  provider = "prisma-client-js"
}

// 2. 数据源配置:指定数据库类型和连接字符串
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

// 3. 枚举类型:用户角色
enum Role {
  USER   // 普通用户
  ADMIN  // 管理员
}

// ==================== 核心模型 ====================

// 用户模型
model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  emailVerified DateTime?
  image         String?
  bio           String?   @db.Text
  website       String?
  github        String?
  role          Role      @default(USER)
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  // 关联关系
  accounts  Account[]
  sessions  Session[]
  posts     Post[]
  comments  Comment[]
  likes     Like[]
  bookmarks Bookmark[]

  @@map("users")
}

// 文章模型
model Post {
  id          String     @id @default(cuid())
  title       String
  slug        String     @unique
  content     String     @db.Text
  excerpt     String?    // AI 生成的摘要
  coverImage  String?
  published   Boolean    @default(false)
  featured    Boolean    @default(false)
  viewCount   Int        @default(0)
  readingTime Int?       // 预计阅读时间(分钟)
  authorId    String
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
  publishedAt DateTime?

  // 关联关系
  author    User       @relation(fields: [authorId], references: [id])
  tags      PostTag[]
  comments  Comment[]
  likes     Like[]
  bookmarks Bookmark[]

  // 索引优化查询性能
  @@index([slug])
  @@index([published])
  @@index([createdAt])
  @@map("posts")
}

// 标签模型
model Tag {
  id          String    @id @default(cuid())
  name        String    @unique
  slug        String    @unique
  description String?
  color       String    @default("#6366f1")

  posts PostTag[]

  @@map("tags")
}

// 文章-标签多对多关系表
model PostTag {
  postId String
  tagId  String

  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag  Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@id([postId, tagId])
  @@map("post_tags")
}

// 评论模型(支持嵌套回复)
model Comment {
  id        String   @id @default(cuid())
  content   String   @db.Text
  authorId  String
  postId    String
  parentId  String?  // 父评论 ID,用于嵌套评论
  approved  Boolean  @default(true)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  author   User      @relation(fields: [authorId], references: [id])
  post     Post      @relation(fields: [postId], references: [id], onDelete: Cascade)
  parent   Comment?  @relation("CommentReplies", fields: [parentId], references: [id])
  replies  Comment[] @relation("CommentReplies")

  @@index([postId])
  @@index([approved])
  @@map("comments")
}

// 点赞模型
model Like {
  userId String
  postId String

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@id([userId, postId])
  @@map("likes")
}

// 收藏模型
model Bookmark {
  userId    String
  postId    String
  createdAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

  @@id([userId, postId])
  @@map("bookmarks")
}

// ==================== Auth.js 所需模型 ====================

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@map("accounts")
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("sessions")
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
  @@map("verification_tokens")
}

📝 Schema 设计要点解析:

(1) 主键策略:cuid() vs uuid()

id String @id @default(cuid())
  • cuid: 更短、更易读、按时间排序,适合大多数场景
  • uuid: 标准 UUID v4,更长但全球唯一
  • 自增 ID: 不适合分布式系统,不推荐

(2) 索引优化

@@index([slug])        // 加速按 slug 查询文章
@@index([published])   // 加速筛选已发布文章
@@index([postId])      // 加速查询文章的评论

何时添加索引?

  • ✅ 经常用于 WHERE 条件的字段
  • ✅ 外键字段
  • ❌ 低基数字段(如布尔值)
  • ❌ 频繁更新的字段

(3) 级联删除

post Post @relation(fields: [postId], references: [id], onDelete: Cascade)

当文章被删除时,自动删除相关的评论、点赞、收藏记录,保持数据一致性

(4) 自引用关系(嵌套评论)

parent   Comment?  @relation("CommentReplies", fields: [parentId], references: [id])
replies  Comment[] @relation("CommentReplies")

通过 parentId 实现评论的树形结构,支持无限层级回复。

4. 初始化数据库

执行以下命令创建数据库表:

# 1. 生成 Prisma Client(TypeScript 类型定义)
npx prisma generate

# 2. 创建数据库迁移
npx prisma migrate dev --name init

# 3. (可选)可视化查看数据库
npx prisma studio

迁移文件说明:

执行 migrate dev 后,会在 prisma/migrations/ 目录生成 SQL 文件:

-- prisma/migrations/20260412000000_init/migration.sql

CREATE TABLE "users" (
    "id" TEXT NOT NULL,
    "email" TEXT NOT NULL,
    "name" TEXT,
    "role" "Role" NOT NULL DEFAULT 'USER',
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,
    
    CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "users_email_key" ON "users"("email");

-- ... 其他表的创建语句

💡 最佳实践:

  • 每次修改 Schema 都创建新的迁移
  • 迁移文件应提交到 Git,便于团队协作
  • 生产环境使用 prisma migrate deploy 而非 dev

四、🔐 认证系统集成(Auth.js)

1. 认证流程概览

sequenceDiagram
    participant User as 用户
    participant App as Next.js App
    participant Auth as Auth.js
    participant DB as Database
    participant OAuth as GitHub OAuth
    
    User->>App: 点击"使用 GitHub 登录"
    App->>Auth: 重定向到 /api/auth/signin/github
    Auth->>OAuth: 请求授权
    OAuth->>User: 显示授权页面
    User->>OAuth: 确认授权
    OAuth->>Auth: 返回授权码
    Auth->>OAuth: 交换访问令牌
    Auth->>DB: 创建/更新用户记录
    Auth->>App: 设置 Session Cookie
    App->>User: 重定向到首页(已登录状态)

2. 配置 Auth.js

创建 auth.ts 文件(项目根目录):

// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from '@/lib/db';

export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: PrismaAdapter(db),
  
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
  ],
  
  callbacks: {
    // Session 回调:自定义 Session 数据
    async session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        session.user.role = user.role;
      }
      return session;
    },
    
    // JWT 回调:将用户信息编码到 Token
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      return token;
    },
  },
  
  pages: {
    signIn: '/auth/signin',  // 自定义登录页面
  },
});

🔑 关键配置解析:

(1) Adapter(适配器模式)

adapter: PrismaAdapter(db)

Auth.js 通过适配器与不同数据库交互。PrismaAdapter 会自动:

  • 创建/更新用户记录
  • 管理 OAuth 账户绑定
  • 处理 Session 生命周期

(2) Providers(认证提供者)

providers: [
  GitHub({ /* 配置 */ }),
  // 可以添加更多: Google、Email、Credentials...
]

每个 Provider 对应一种登录方式。GitHub OAuth 需要在 GitHub Developer Settings 中创建应用,获取 Client IDClient Secret

(3) Callbacks(回调函数)

callbacks: {
  async session({ session, user }) {
    // 在这里可以向 session 添加额外数据
    session.user.id = user.id;
    return session;
  }
}

常见用途:

  • 向 Session 添加用户 ID、角色等信息
  • 根据用户角色限制访问
  • 记录登录日志

(3)创建 API 路由

Next.js App Router 中,Auth.js 的路由位于 app/api/auth/[...nextauth]/route.ts:

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';

export const { GET, POST } = handlers;

路由命名规则:

  • [...nextauth] 是动态路由段,匹配所有 /api/auth/* 路径
  • Auth.js 内部会根据子路径分发请求(如 /api/auth/signin)

4. 封装认证辅助函数

创建 lib/auth.ts:

// lib/auth.ts
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

/**
 * 获取当前会话(服务端组件中使用)
 */
export async function getCurrentUser() {
  const session = await auth();
  return session?.user || null;
}

/**
 * 要求用户登录(未登录则重定向)
 */
export async function requireAuth() {
  const session = await auth();
  
  if (!session?.user) {
    redirect('/auth/signin?callbackUrl=' + encodeURIComponent(
      typeof window !== 'undefined' ? window.location.pathname : '/'
    ));
  }
  
  return session;
}

/**
 * 检查是否为管理员
 */
export async function requireAdmin() {
  const session = await requireAuth();
  
  if (session.user.role !== 'ADMIN') {
    throw new Error('权限不足');
  }
  
  return session;
}

使用示例:

// app/dashboard/page.tsx
import { requireAuth } from '@/lib/auth';

export default async function DashboardPage() {
  // 未登录会自动重定向到登录页
  const session = await requireAuth();
  
  return <div>欢迎, {session.user.name}</div>;
}

五、✍️ 文章 CRUD 核心功能

Server Actions 架构设计

在 Next.js 13+ 中,Server Actions 是处理表单提交和数据突变的首选方案,相比传统 API Routes 有以下优势:

对比项 Server Actions API Routes
类型安全 ✅ 端到端类型推断 ❌ 需手动定义接口
渐进增强 ✅ 无 JS 也可工作 ❌ 依赖客户端 JS
代码复用 ✅ 直接导入函数 ❌ 需 HTTP 请求
安全性 ✅ 自动 CSRF 保护 ⚠️ 需手动实现

1. 创建文章 Action

创建 app/actions/post.ts:

// app/actions/post.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
import slugify from 'slugify';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';

// ==================== Schema 定义 ====================

/**
 * 创建文章的验证 Schema
 * 
 * Zod 的优势:
 * 1. 运行时验证 + TypeScript 类型推断
 * 2. 详细的错误信息
 * 3. 可组合、可扩展
 */
const createPostSchema = z.object({
  title: z.string()
    .min(1, '标题不能为空')
    .max(200, '标题不能超过 200 字符'),
  
  content: z.string()
    .min(100, '文章内容至少 100 字符'),
  
  excerpt: z.string()
    .max(500, '摘要不能超过 500 字符')
    .optional(),
  
  coverImage: z.string()
    .url('请输入有效的图片 URL')
    .optional(),
  
  tagIds: z.array(z.string())
    .min(1, '至少选择一个标签'),
  
  published: z.boolean()
    .default(false),
});

// 从 Schema 推断 TypeScript 类型
type CreatePostInput = z.infer<typeof createPostSchema>;

/**
 * 创建新文章
 * 
 * @param data - 文章数据
 * @returns 创建结果
 * 
 * 使用场景:
 * - 管理后台创建文章
 * - 用户投稿功能
 */
export async function createPost(data: CreatePostInput) {
  // 1. 身份验证
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '未授权,请先登录' 
    };
  }

  // 2. 数据验证
  const validated = createPostSchema.safeParse(data);
  
  if (!validated.success) {
    return { 
      success: false, 
      error: '数据验证失败',
      details: validated.error.flatten().fieldErrors
    };
  }

  // 3. 生成 URL 友好的 slug
  const slug = slugify(validated.data.title, { 
    lower: true,      // 转小写
    strict: true,     // 严格模式,移除特殊字符
  });

  // 4. 检查 slug 是否已存在
  const existingPost = await db.post.findUnique({
    where: { slug },
  });

  if (existingPost) {
    // 如果 slug 冲突,添加时间戳后缀
    const uniqueSlug = `${slug}-${Date.now()}`;
    return await savePost({ ...validated.data, slug: uniqueSlug }, session.user.id!);
  }

  return await savePost({ ...validated.data, slug }, session.user.id!);
}

/**
 * 保存文章到数据库(内部函数)
 */
async function savePost(
  data: CreatePostInput & { slug: string }, 
  authorId: string
) {
  try {
    const post = await db.post.create({
      data: {
        title: data.title,
        slug: data.slug,
        content: data.content,
        excerpt: data.excerpt,
        coverImage: data.coverImage,
        published: data.published,
        publishedAt: data.published ? new Date() : null,
        authorId,
        // 关联标签(多对多关系)
        tags: {
          create: data.tagIds.map(tagId => ({
            tag: { connect: { id: tagId } },
          })),
        },
      },
    });

    // 5. 失效相关缓存
    revalidateTag('posts');           // 文章列表缓存
    revalidateTag(`user-${authorId}`); // 用户文章列表缓存

    return { 
      success: true, 
      postId: post.id,
      message: '文章创建成功'
    };
  } catch (error) {
    console.error('Failed to create post:', error);
    return { 
      success: false, 
      error: '创建文章失败,请稍后重试'
    };
  }
}

📖 代码解析:

(1) 为什么使用 'use server' 指令?

'use server';

这个指令告诉 Next.js:

  • 该文件中的所有导出函数都在服务端执行
  • 可以在函数中访问数据库、环境变量等敏感资源
  • 客户端调用时会自动序列化参数和返回值

(2) Zod Schema 验证的重要性

const validated = createPostSchema.safeParse(data);

if (!validated.success) {
  return { error: '数据验证失败', details: validated.error.flatten() };
}

防御性编程原则:

  • 永远不要信任客户端传来的数据
  • ✅ 在服务端进行二次验证
  • ✅ 提供清晰的错误提示

(3)缓存失效策略

revalidateTag('posts');

当我们创建/更新/删除文章后,需要通知 Next.js 清除相关缓存:

  • revalidateTag('posts'): 清除所有文章列表的缓存
  • revalidatePath('/blog'): 清除特定路径的缓存

缓存失效时机:

  • 创建文章 → 清除列表缓存
  • 更新文章 → 清除详情 + 列表缓存
  • 删除文章 → 清除详情 + 列表 + 用户缓存

2. 获取文章列表(带缓存)

创建 lib/posts.ts:

// lib/posts.ts
import { db } from '@/lib/db';
import { cache } from 'react';

interface GetPostsOptions {
  page?: number;
  pageSize?: number;
  tagSlug?: string;
  search?: string;
  published?: boolean;
}

/**
 * 获取文章列表(带 React Cache)
 * 
 * cache() 的作用:
 * - 在同一请求中多次调用时,只执行一次数据库查询
 * - 配合 Next.js 数据缓存,实现多层缓存
 */
export const getPosts = cache(async ({
  page = 1,
  pageSize = 10,
  tagSlug,
  search,
  published = true,
}: GetPostsOptions = {}) => {
  const skip = (page - 1) * pageSize;

  // 构建动态查询条件
  const where = {
    published,
    ...(tagSlug && {
      tags: {
        some: {
          tag: { slug: tagSlug },
        },
      },
    }),
    ...(search && {
      OR: [
        { title: { contains: search, mode: 'insensitive' as const } },
        { content: { contains: search, mode: 'insensitive' as const } },
      ],
    }),
  };

  // 并行查询:文章列表 + 总数
  const [posts, total] = await Promise.all([
    db.post.findMany({
      where,
      skip,
      take: pageSize,
      orderBy: { publishedAt: 'desc' },
      include: {
        author: {
          select: { id: true, name: true, image: true },
        },
        tags: {
          include: {
            tag: { select: { id: true, name: true, slug: true, color: true } },
          },
        },
        _count: {
          select: { comments: true, likes: true },
        },
      },
    }),
    db.post.count({ where }),
  ]);

  return {
    posts,
    pagination: {
      page,
      pageSize,
      total,
      totalPages: Math.ceil(total / pageSize),
    },
  };
});

🎯 性能优化技巧:

(1) 使用 Promise.all 并行查询

const [posts, total] = await Promise.all([
  db.post.findMany({ /* ... */ }),
  db.post.count({ where }),
]);

而不是串行:

// ❌ 慢:两个查询依次执行
const posts = await db.post.findMany({ /* ... */ });
const total = await db.post.count({ where });

(2) 精确选择字段

include: {
  author: {
    select: { id: true, name: true, image: true }, // 只取需要的字段
  },
}

避免 select: true 取出所有字段,减少网络传输和内存占用。

(3) 使用 _count 聚合查询

_count: {
  select: { comments: true, likes: true },
}

直接在数据库层面统计数量,避免在应用层遍历数组。

4. 获取单篇文章详情

继续在 lib/posts.ts 中添加:

/**
 * 根据 slug 获取文章详情
 * 
 * @param slug - 文章 URL 标识
 * @returns 文章详情或 null
 */
export const getPostBySlug = cache(async (slug: string) => {
  const post = await db.post.findUnique({
    where: { slug },
    include: {
      author: {
        select: { id: true, name: true, image: true, bio: true },
      },
      tags: {
        include: {
          tag: { select: { id: true, name: true, slug: true, color: true } },
        },
      },
      // 获取顶级评论(不包括回复)
      comments: {
        where: { approved: true, parentId: null },
        include: {
          author: { select: { id: true, name: true, image: true } },
          // 嵌套获取回复评论
          replies: {
            include: {
              author: { select: { id: true, name: true, image: true } },
            },
          },
        },
        orderBy: { createdAt: 'asc' },
      },
      _count: {
        select: { likes: true, bookmarks: true },
      },
    },
  });

  if (!post) {
    return null;
  }

  // 异步增加浏览量(不阻塞响应)
  incrementViewCount(post.id);

  return post;
});

/**
 * 增加文章浏览量
 */
async function incrementViewCount(postId: string) {
  await db.post.update({
    where: { id: postId },
    data: { viewCount: { increment: 1 } },
  });
}

💡 设计思考:

为什么浏览量更新不等待?

// 不阻塞主流程
incrementViewCount(post.id);
return post;
  • 用户体验优先: 用户无需等待计数器更新
  • ✅ 即使更新失败,也不影响文章展示
  • ⚠️ 注意:在高并发场景可能需要队列或批量更新优化

六、🤖 AI 功能集成

1. 为什么要在博客中集成 AI?

传统博客系统的痛点:

  • ❌ 作者需要手动编写摘要,耗时耗力
  • ❌ 标签选择主观,不利于 SEO
  • ❌ 相关文章推荐算法复杂

AI 可以解决这些问题:

  • 自动生成摘要: 节省作者时间
  • 智能标签推荐: 基于内容语义分析
  • 个性化推荐: 提升用户停留时长

2. 配置 OpenAI

创建 lib/ai.ts:

// lib/ai.ts
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';

/**
 * 使用 AI 生成文章摘要
 * 
 * @param content - 文章正文
 * @returns 生成的摘要文本
 * 
 * 应用场景:
 * - 创建文章时自动生成 excerpt
 * - 批量处理历史文章
 */
export async function generateExcerpt(content: string): Promise<string> {
  // 限制输入长度,避免超出 Token 限制
  const truncatedContent = content.substring(0, 2000);

  const { text } = await generateText({
    model: openai('gpt-4-turbo'),
    prompt: `请为以下文章内容生成一段简洁的摘要(不超过 200 字)。
要求:
1. 突出核心观点
2. 语言精炼流畅
3. 吸引读者继续阅读

文章内容:
${truncatedContent}`,
    temperature: 0.7, // 创造性:0-1,越高越随机
  });

  return text.trim();
}

/**
 * 智能推荐标签
 * 
 * @param title - 文章标题
 * @param content - 文章正文
 * @returns 标签名称数组
 */
export async function suggestTags(
  title: string,
  content: string
): Promise<string[]> {
  const { text } = await generateText({
    model: openai('gpt-4-turbo'),
    prompt: `基于以下文章标题和内容,推荐 3-5 个相关的技术标签。
要求:
1. 标签应为常见的技术术语
2. 用逗号分隔,不要编号
3. 每个标签不超过 10 个字符

标题: ${title}
内容: ${content.substring(0, 1500)}`,
    temperature: 0.5, // 更低温度,更稳定
  });

  // 解析返回结果
  return text
    .split(',')
    .map(tag => tag.trim())
    .filter(Boolean)
    .slice(0, 5); // 最多 5 个标签
}

/**
 * 生成文章预计阅读时间
 * 
 * @param content - 文章正文
 * @returns 阅读时间(分钟)
 */
export function calculateReadingTime(content: string): number {
  const wordsPerMinute = 300; // 中文阅读速度
  const wordCount = content.length / 2; // 粗略估算中文字数
  return Math.ceil(wordCount / wordsPerMinute);
}

⚙️ AI 配置最佳实践:

(1)Temperature 参数调优

temperature: 0.7  // 摘要生成:需要一定创造性
temperature: 0.5  // 标签推荐:需要稳定性
  • 0.0-0.3: 确定性输出,适合事实性问题
  • 0.4-0.7: 平衡创造性和准确性
  • 0.8-1.0: 高创造性,适合创意写作

(2)Prompt Engineering 技巧

prompt: `请为以下文章内容生成一段简洁的摘要(不超过 200 字)。
要求:
1. 突出核心观点
2. 语言精炼流畅
3. 吸引读者继续阅读`

有效 Prompt 的要素:

  • ✅ 明确任务目标
  • ✅ 列出具体要求
  • ✅ 提供示例(Few-shot Learning)
  • ✅ 限制输出格式

(3) 成本控制

const truncatedContent = content.substring(0, 2000);
  • 限制输入长度,减少 Token 消耗
  • 对于长文章,可以分段处理后合并
  • 考虑使用更便宜的模型(如 gpt-3.5-turbo)进行测试

3. 在创建文章时调用 AI

修改 createPost 函数:

// app/actions/post.ts
import { generateExcerpt, calculateReadingTime } from '@/lib/ai';

export async function createPost(data: CreatePostInput) {
  // ... 前面的验证逻辑 ...

  // 如果没有提供摘要,使用 AI 生成
  let excerpt = validated.data.excerpt;
  if (!excerpt) {
    excerpt = await generateExcerpt(validated.data.content);
  }

  // 计算阅读时间
  const readingTime = calculateReadingTime(validated.data.content);

  // 保存到数据库
  const post = await db.post.create({
    data: {
      // ... 其他字段 ...
      excerpt,
      readingTime,
    },
  });

  return { success: true, postId: post.id };
}

🎯 用户体验优化:

可以在前端显示"AI 生成中..."的加载状态:

// components/AIExcerptGenerator.tsx
'use client';

import { useState } from 'react';
import { generateExcerpt } from '@/app/actions/ai';

export function AIExcerptGenerator({ content }: { content: string }) {
  const [loading, setLoading] = useState(false);
  const [excerpt, setExcerpt] = useState('');

  const handleGenerate = async () => {
    setLoading(true);
    try {
      const result = await generateExcerpt(content);
      setExcerpt(result);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button onClick={handleGenerate} disabled={loading}>
        {loading ? 'AI 生成中...' : '✨ 自动生成摘要'}
      </button>
      {excerpt && <textarea value={excerpt} />}
    </div>
  );
}

七、💬 评论系统实现

1. 评论系统设计要点

评论系统是博客的社交核心,需要考虑:

  1. 嵌套回复: 支持楼中楼式讨论
  2. 审核机制: 防止垃圾评论
  3. 实时更新: 新评论即时显示
  4. 权限控制: 仅登录用户可评论

2. 创建评论 Action

创建 app/actions/comment.ts:

// app/actions/comment.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';

const commentSchema = z.object({
  postId: z.string().min(1, '文章 ID 不能为空'),
  content: z.string()
    .min(1, '评论内容不能为空')
    .max(5000, '评论不能超过 5000 字符'),
  parentId: z.string().optional(), // 回复评论时填写
});

type CreateCommentInput = z.infer<typeof commentSchema>;

/**
 * 发表评论
 * 
 * @param data - 评论数据
 * @returns 创建结果
 */
export async function createComment(data: CreateCommentInput) {
  // 1. 身份验证
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '请先登录后再评论' 
    };
  }

  // 2. 数据验证
  const validated = commentSchema.safeParse(data);
  
  if (!validated.success) {
    return { 
      success: false, 
      error: '数据验证失败',
      details: validated.error.flatten().fieldErrors
    };
  }

  // 3. 检查文章是否存在
  const post = await db.post.findUnique({
    where: { id: validated.data.postId },
    select: { id: true, published: true },
  });

  if (!post || !post.published) {
    return { 
      success: false, 
      error: '文章不存在或未发布' 
    };
  }

  // 4. 如果是回复,检查父评论是否存在
  if (validated.data.parentId) {
    const parentComment = await db.comment.findUnique({
      where: { id: validated.data.parentId },
    });

    if (!parentComment) {
      return { 
        success: false, 
        error: '父评论不存在' 
      };
    }
  }

  try {
    // 5. 创建评论
    const comment = await db.comment.create({
      data: {
        content: validated.data.content,
        authorId: session.user.id!,
        postId: validated.data.postId,
        parentId: validated.data.parentId,
        approved: true, // 默认通过审核(可改为 false 启用审核)
      },
      include: {
        author: { 
          select: { id: true, name: true, image: true } 
        },
      },
    });

    // 6. 失效缓存
    revalidateTag(`post-${validated.data.postId}`);

    return { 
      success: true, 
      comment,
      message: '评论成功'
    };
  } catch (error) {
    console.error('Failed to create comment:', error);
    return { 
      success: false, 
      error: '评论失败,请稍后重试'
    };
  }
}

🔒 安全防护措施:

(1) 防 XSS 攻击

虽然我们在数据库中存储原始内容,但在渲染时需要转义:

// 使用 dangerouslySetInnerHTML 时要谨慎
<div dangerouslySetInnerHTML={{ __html: sanitize(comment.content) }} />

可以使用 dompurify 库清理 HTML:

npm install dompurify
npm install -D @types/dompurify

(2) 频率限制

防止用户刷评论:

// 检查用户最近 1 分钟内的评论次数
const recentComments = await db.comment.count({
  where: {
    authorId: session.user.id!,
    createdAt: {
      gte: new Date(Date.now() - 60 * 1000), // 1 分钟内
    },
  },
});

if (recentComments >= 5) {
  return { 
    success: false, 
    error: '评论过于频繁,请稍后再试' 
  };
}

(3) 敏感词过滤

const bannedWords = ['广告', '赌博', '色情'];

if (bannedWords.some(word => validated.data.content.includes(word))) {
  return { 
    success: false, 
    error: '评论包含不当内容' 
  };
}

八、👍 点赞与收藏功能

1. 为什么需要点赞和收藏?

  • 点赞: 量化文章受欢迎程度,激励作者
  • 收藏: 用户个人知识库,方便后续查阅
  • 数据分析: 了解用户偏好,优化内容策略

2. 切换点赞状态

创建 app/actions/interaction.ts:

// app/actions/interaction.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';
import { revalidateTag } from 'next/cache';

/**
 * 切换点赞状态(点赞/取消点赞)
 * 
 * @param postId - 文章 ID
 * @returns 操作结果
 */
export async function toggleLike(postId: string) {
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '请先登录' 
    };
  }

  // 检查是否已点赞
  const existing = await db.like.findUnique({
    where: {
      userId_postId: {
        userId: session.user.id!,
        postId,
      },
    },
  });

  try {
    if (existing) {
      // 取消点赞
      await db.like.delete({
        where: {
          userId_postId: {
            userId: session.user.id!,
            postId,
          },
        },
      });
      
      return { success: true, liked: false };
    } else {
      // 添加点赞
      await db.like.create({
        data: {
          userId: session.user.id!,
          postId,
        },
      });
      
      return { success: true, liked: true };
    }
  } catch (error) {
    console.error('Failed to toggle like:', error);
    return { 
      success: false, 
      error: '操作失败' 
    };
  } finally {
    // 无论成功与否,都失效缓存
    revalidateTag(`post-${postId}`);
  }
}

/**
 * 切换收藏状态
 * 
 * @param postId - 文章 ID
 * @returns 操作结果
 */
export async function toggleBookmark(postId: string) {
  const session = await auth();
  
  if (!session?.user) {
    return { 
      success: false, 
      error: '请先登录' 
    };
  }

  const existing = await db.bookmark.findUnique({
    where: {
      userId_postId: {
        userId: session.user.id!,
        postId,
      },
    },
  });

  try {
    if (existing) {
      await db.bookmark.delete({
        where: {
          userId_postId: {
            userId: session.user.id!,
            postId,
          },
        },
      });
      
      return { success: true, bookmarked: false };
    } else {
      await db.bookmark.create({
        data: {
          userId: session.user.id!,
          postId,
        },
      });
      
      return { success: true, bookmarked: true };
    }
  } catch (error) {
    console.error('Failed to toggle bookmark:', error);
    return { 
      success: false, 
      error: '操作失败' 
    };
  } finally {
    revalidateTag(`user-${session.user.id}`);
  }
}

💡 设计模式:Toggle Pattern

点赞/收藏这类功能是典型的 Toggle 模式:

  1. 检查当前状态
  2. 如果存在则删除,不存在则创建
  3. 返回新状态

这种模式的优点:

  • ✅ 幂等性:多次调用结果一致
  • ✅ 简化前端逻辑:无需分别实现"点赞"和"取消点赞"
  • ✅ 原子操作:避免竞态条件

九、📝 本章小结

通过实战项目上篇的学习,我们已经完成了博客系统的后端核心功能:

项目初始化: Next.js 15 + TypeScript + Tailwind CSS
数据库设计: Prisma Schema 建模,理解关系型数据结构
认证系统: Auth.js 集成 GitHub OAuth
文章 CRUD: Server Actions 实现数据突变
AI 集成: OpenAI 自动生成摘要和标签
评论系统: 嵌套评论 + 安全防护
互动功能: 点赞、收藏的 Toggle 模式

核心知识点回顾:

知识点 应用场景 关键代码
Server Actions 表单提交、数据突变 'use server'
Zod 验证 输入数据校验 z.object().parse()
React Cache 同请求内去重查询 cache(fn)
Revalidate Tag 缓存失效策略 revalidateTag()
Prisma Relations 多对多、自引用关系 @relation
AI Integration 智能摘要生成 generateText()

十、🚀 下篇预告

下篇中,我们将实现:

  1. 前端页面开发:

    • 首页文章列表
    • 文章详情页(MDX 渲染)
    • 登录/注册页面
    • 管理后台
  2. UI 组件实现:

    • Markdown 代码高亮
    • 评论组件(嵌套显示)
    • 点赞/收藏按钮(Optimistic UI)
  3. 性能优化:

    • 图片懒加载
    • 并行数据获取
    • 流式渲染
  4. 部署上线:

    • Vercel 部署
    • 环境变量配置
    • 域名绑定

敬请期待! 🎉


练习作业:

  1. 尝试添加"文章编辑"功能(提示:参考 createPost,使用 db.post.update)
  2. 实现"删除文章"功能,并处理级联删除
  3. 添加"草稿箱"功能(区分 published: true/false)
  4. 实现简单的全文搜索(使用 Prisma 的 contains 查询)

完成这些练习,你将真正掌握 Next.js 全栈开发的核心技能! 💪

Next.js从入门到实战保姆级教程(第十二章):认证鉴权与中间件

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

认证(Authentication) 解决"你是谁"的问题,鉴权(Authorization) 解决"你能做什么"的问题。这两者构成了应用安全的基石,实施不当将导致严重的安全漏洞。

一、认证方案选型

在 Next.js 生态中实现认证,主要有三种技术路线:

graph TD
    A[需要认证功能] --> B{项目复杂度与需求}
    B -->|快速开发<br/>标准需求| C[Auth.js<br/>原NextAuth.js]
    B -->|高度定制<br/>企业内建| D[手动实现 JWT]
    B -->|托管服务<br/>全功能| E[Clerk / Supabase Auth]

    C --> F[支持OAuth社交登录<br/>内置Session管理<br/> App Router深度集成]
    D --> G[完全自定义控制<br/> 需编写更多代码<br/>自行处理安全细节]
    E --> H[ 功能最全面<br/> 零维护成本<br/>产生服务费用<br/>厂商锁定风险]

方案选择建议

场景 推荐方案 理由
初创项目/MVP Auth.js 快速搭建,社区活跃
企业内部系统 手动JWT 已有认证基础设施
SaaS产品 Clerk/Supabase 节省开发时间,专注业务
高安全要求 手动JWT + 审计 完全掌控安全策略

对于大多数应用场景,Auth.js(原 NextAuth.js v5) 是最佳选择:支持数十种 OAuth 提供商(Google、GitHub、微信等),内置 Session 管理机制,与 Next.js App Router 深度集成。

本章重点讲解 Auth.js 方案,同时剖析 JWT 手动实现的底层原理。


二、Auth.js 完整认证实现

1. 安装与基础配置

npm install next-auth@beta

(1)实例化Next-Auth

// auth.ts(项目根目录)
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import type { DefaultSession } from 'next-auth';

// 扩展 Session 类型
declare module 'next-auth' {
  interface Session {
    user: {
      id: string;
      role: string;
    } & DefaultSession['user'];
  }
}

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    // GitHub OAuth 登录
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),

    // Google OAuth 登录
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),

    // 邮箱密码登录(Credentials Provider)
    Credentials({
      name: '邮箱密码',
      credentials: {
        email: { label: '邮箱', type: 'email', placeholder: 'example@email.com' },
        password: { label: '密码', type: 'password' },
      },
      async authorize(credentials) {
        // 参数验证
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        // 查询用户
        const user = await db.users.findUnique({
          where: { email: credentials.email as string },
        });

        // 用户不存在或无密码
        if (!user || !user.hashedPassword) {
          return null;
        }

        // 验证密码
        const isValid = await bcrypt.compare(
          credentials.password as string,
          user.hashedPassword
        );

        if (!isValid) {
          return null;
        }

        // 返回用户信息(敏感信息不返回)
        return {
          id: user.id,
          name: user.name,
          email: user.email,
          image: user.avatar,
          role: user.role,
        };
      },
    }),
  ],

  // Session 配置
  session: {
    strategy: 'jwt',  // 使用 JWT,无需数据库存储 Session
    maxAge: 30 * 24 * 60 * 60,  // 30 天过期
  },

  // 自定义页面路径
  pages: {
    signIn: '/login',      // 自定义登录页
    signOut: '/logout',    // 自定义登出页
    error: '/auth/error',  // 错误页面
  },

  // Callbacks:自定义认证流程
  callbacks: {
    /**
     * JWT Token 回调
     * 在 token 创建/更新时触发
     */
    async jwt({ token, user }) {
      // 首次登录时,将用户信息添加到 token
      if (user) {
        token.id = user.id;
        token.role = user.role;
      }
      return token;
    },

    /**
     * Session 回调
     * 每次读取 session 时触发
     */
    async session({ session, token }) {
      if (token && session.user) {
        session.user.id = token.id as string;
        session.user.role = token.role as string;
      }
      return session;
    },
  },

  // 调试模式(仅开发环境)
  debug: process.env.NODE_ENV === 'development',
});

(2)定义route handler

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';

// 导出 GET 和 POST 处理器
export const { GET, POST } = handlers;

(3)填写配置信息

# .env.local
AUTH_SECRET="your-secret-key-min-32-chars"  # 使用 openssl rand -base64 32 生成
GITHUB_ID="your-github-app-id"
GITHUB_SECRET="your-github-app-secret"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"

安全提示AUTH_SECRET 必须至少 32 个字符,生产环境务必使用强随机字符串。

2. 登录页面实现

// app/login/page.tsx
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
import { redirect } from 'next/navigation';
import Link from 'next/link';

interface LoginPageProps {
  searchParams: Promise<{ callbackUrl?: string; error?: string }>;
}

export default async function LoginPage({ 
  searchParams 
}: LoginPageProps) {
  const { callbackUrl, error } = await searchParams;

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
      <div className="w-full max-w-md p-8 bg-white rounded-2xl shadow-lg border border-gray-100">
        <h1 className="text-2xl font-bold text-center mb-8 text-gray-900">
          欢迎登录
        </h1>

        {/* 错误提示 */}
        {error && (
          <div 
            className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm border border-red-200"
            role="alert"
          >
            {error === 'CredentialsSignin'
              ? '邮箱或密码错误,请重试'
              : '登录失败,请稍后重试'}
          </div>
        )}

        {/* 邮箱密码登录表单 */}
        <form
          action={async (formData) => {
            'use server';
            try {
              await signIn('credentials', {
                email: formData.get('email'),
                password: formData.get('password'),
                redirectTo: callbackUrl || '/dashboard',
              });
            } catch (error) {
              if (error instanceof AuthError) {
                redirect(`/login?error=${error.type}`);
              }
              throw error;
            }
          }}
          className="space-y-4"
        >
          <div>
            <label 
              htmlFor="email" 
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              邮箱地址
            </label>
            <input
              id="email"
              name="email"
              type="email"
              required
              autoComplete="email"
              className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              placeholder="your@email.com"
            />
          </div>

          <div>
            <label 
              htmlFor="password" 
              className="block text-sm font-medium text-gray-700 mb-1"
            >
              密码
            </label>
            <input
              id="password"
              name="password"
              type="password"
              required
              autoComplete="current-password"
              className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
              placeholder="••••••••"
            />
          </div>

          <button
            type="submit"
            className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
          >
            登录
          </button>
        </form>

        {/* 分隔线 */}
        <div className="my-6 flex items-center gap-4">
          <hr className="flex-1 border-gray-300" />
          <span className="text-gray-400 text-sm">或使用以下方式登录</span>
          <hr className="flex-1 border-gray-300" />
        </div>

        {/* OAuth 登录按钮 */}
        <div className="space-y-3">
          {/* GitHub 登录 */}
          <form
            action={async () => {
              'use server';
              await signIn('github', {
                redirectTo: callbackUrl || '/dashboard',
              });
            }}
          >
            <button
              type="submit"
              className="w-full flex items-center justify-center gap-2 border border-gray-300 py-2 rounded-lg hover:bg-gray-50 transition-colors"
            >
              <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
                <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
              </svg>
              使用 GitHub 登录
            </button>
          </form>

          {/* Google 登录 */}
          <form
            action={async () => {
              'use server';
              await signIn('google', {
                redirectTo: callbackUrl || '/dashboard',
              });
            }}
          >
            <button
              type="submit"
              className="w-full flex items-center justify-center gap-2 border border-gray-300 py-2 rounded-lg hover:bg-gray-50 transition-colors"
            >
              <svg className="w-5 h-5" viewBox="0 0 24 24">
                <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
                <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
                <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
                <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
              </svg>
              使用 Google 登录
            </button>
          </form>
        </div>

        {/* 注册链接 */}
        <p className="mt-6 text-center text-sm text-gray-600">
          还没有账号?{' '}
          <Link 
            href="/register" 
            className="text-blue-600 hover:text-blue-700 font-medium"
          >
            立即注册
          </Link>
        </p>
      </div>
    </div>
  );
}

三、Session 获取与管理

Auth.js 提供了多种场景下的 Session 获取方式:

1. 服务端组件中获取

// app/dashboard/page.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  // 获取当前会话
  const session = await auth();

  // 未登录则重定向
  if (!session?.user) {
    redirect('/login?callbackUrl=/dashboard');
  }

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-2xl font-bold mb-4">
        欢迎,{session.user.name}
      </h1>
      <p className="text-gray-600">邮箱:{session.user.email}</p>
      <p className="text-gray-600">角色:{session.user.role}</p>
    </div>
  );
}

2. 客户端组件中获取

// components/UserMenu.tsx
'use client';

import { useSession, signOut } from 'next-auth/react';
import Image from 'next/image';
import Link from 'next/link';

export function UserMenu() {
  const { data: session, status } = useSession();

  // 加载中状态
  if (status === 'loading') {
    return (
      <div className="w-8 h-8 rounded-full bg-gray-200 animate-pulse" />
    );
  }

  // 未登录
  if (!session) {
    return (
      <Link 
        href="/login"
        className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
      >
        登录
      </Link>
    );
  }

  // 已登录
  return (
    <div className="flex items-center gap-3">
      {session.user.image && (
        <Image
          src={session.user.image}
          alt={`${session.user.name} 的头像`}
          width={32}
          height={32}
          className="rounded-full border border-gray-200"
        />
      )}
      <span className="text-sm text-gray-700">{session.user.name}</span>
      <button
        onClick={() => signOut({ callbackUrl: '/' })}
        className="text-sm text-gray-500 hover:text-red-600 transition-colors"
      >
        退出
      </button>
    </div>
  );
}

需在根布局中包裹 SessionProvider

// app/layout.tsx
import { SessionProvider } from 'next-auth/react';
import { auth } from '@/auth';
import type { ReactNode } from 'react';

interface RootLayoutProps {
  children: ReactNode;
}

export default async function RootLayout({ 
  children 
}: RootLayoutProps) {
  const session = await auth();

  return (
    <html lang="zh-CN">
      <body>
        <SessionProvider session={session}>
          {children}
        </SessionProvider>
      </body>
    </html>
  );
}

四、Middleware:路由级认证保护

中间件(Middleware)运行在 Edge Runtime,在请求到达路由处理程序之前执行。这是实现路由级别认证保护的最佳位置——比在每个页面单独检查 Session 高效得多。

1. 基础路由保护

// middleware.ts(项目根目录)
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export default auth((req: NextRequest) => {
  const { nextUrl } = req;
  const session = req.auth;
  const isLoggedIn = !!session?.user;

  // 定义受保护路由
  const protectedRoutes = [
    '/dashboard',
    '/profile',
    '/settings',
    '/admin',
  ];

  const isProtectedRoute = protectedRoutes.some(route => 
    nextUrl.pathname.startsWith(route)
  );

  // 未登录访问受保护页面 → 重定向到登录页
  if (isProtectedRoute && !isLoggedIn) {
    const redirectUrl = new URL('/login', nextUrl);
    redirectUrl.searchParams.set('callbackUrl', nextUrl.pathname);
    return NextResponse.redirect(redirectUrl);
  }

  // 已登录访问登录/注册页 → 重定向到仪表板
  if (isLoggedIn && ['/login', '/register'].includes(nextUrl.pathname)) {
    return NextResponse.redirect(new URL('/dashboard', nextUrl));
  }

  return NextResponse.next();
});

// 配置中间件匹配规则(排除静态资源)
export const config = {
  matcher: [
    /*
     * 匹配所有路径,除了:
     * - api(API 路由)
     * - _next/static(静态文件)
     * - _next/image(图片优化)
     * - favicon.ico(网站图标)
     * - public 文件夹
     */
    '/((?!api|_next/static|_next/image|favicon.ico|public).*)',
  ],
};

2. 基于角色的访问控制(RBAC)

// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export default auth((req: NextRequest) => {
  const { nextUrl } = req;
  const session = req.auth;
  const userRole = session?.user?.role;

  // 管理员路由保护
  if (nextUrl.pathname.startsWith('/admin')) {
    // 未登录
    if (!session?.user) {
      return NextResponse.redirect(new URL('/login', nextUrl));
    }
    
    // 非管理员
    if (userRole !== 'admin') {
      return NextResponse.redirect(new URL('/403', nextUrl));
    }
  }

  // 编辑者路由保护
  if (nextUrl.pathname.startsWith('/editor')) {
    if (!session?.user) {
      return NextResponse.redirect(new URL('/login', nextUrl));
    }
    
    const allowedRoles = ['admin', 'editor'];
    if (!allowedRoles.includes(userRole || '')) {
      return NextResponse.redirect(new URL('/403', nextUrl));
    }
  }

  return NextResponse.next();
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

五、手动实现 JWT 认证(深入理解)

虽然推荐使用 Auth.js,但理解 JWT 认证的底层原理对应对复杂场景至关重要。许多企业内部系统已有后端 API 提供 JWT,前端只需处理 Token 的存储和传递。

1. JWT 工作流程

sequenceDiagram
    participant User as 用户
    participant Client as 客户端
    participant Server as 服务端
    participant DB as 数据库

    User->>Client: 提交邮箱密码
    Client->>Server: POST /api/login
    Server->>DB: 验证用户凭证
    DB-->>Server: 返回用户信息
    Server->>Server: 生成 JWT Token
    Server-->>Client: 返回 JWT
    Client->>Client: 存储于 HttpOnly Cookie
    User->>Client: 访问受保护页面
    Client->>Server: 携带 JWT Cookie
    Server->>Server: 验证 JWT 有效性
    Server-->>Client: 返回数据

2. 安全 Token 管理

一般可以把Token存在HTTPOnly Cookie里。

// lib/session.ts
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';

// JWT 密钥(生产环境从环境变量读取)
const SECRET_KEY = new TextEncoder().encode(
  process.env.JWT_SECRET || 'fallback-secret-change-in-production'
);

/**
 * 创建 Session Token
 * @param userId - 用户ID
 * @param role - 用户角色
 */
export async function createSession(
  userId: string, 
  role: string
): Promise<void> {
  const token = await new SignJWT({ userId, role })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')  // 7 天过期
    .sign(SECRET_KEY);

  const cookieStore = await cookies();

  // 将 JWT 存储在 HttpOnly Cookie 中
  cookieStore.set('session', token, {
    httpOnly: true,           // JavaScript 无法读取,防止 XSS
    secure: process.env.NODE_ENV === 'production',  // 仅 HTTPS
    sameSite: 'lax',          // 防止 CSRF
    maxAge: 7 * 24 * 60 * 60, // 7 天
    path: '/',
  });
}

/**
 * 验证并读取 Session
 * @returns Session 载荷或 null
 */
export async function getSession(): Promise<{ 
  userId: string; 
  role: string 
} | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get('session')?.value;

  if (!token) {
    return null;
  }

  try {
    const { payload } = await jwtVerify(token, SECRET_KEY);
    return payload as { userId: string; role: string };
  } catch (error) {
    // Token 无效或已过期
    console.error('Invalid token:', error);
    return null;
  }
}

/**
 * 删除 Session(退出登录)
 */
export async function deleteSession(): Promise<void> {
  const cookieStore = await cookies();
  cookieStore.delete('session');
}

为什么使用 Cookie 而非 localStorage?
localStorage 可被 JavaScript 访问,易受 XSS 攻击(恶意脚本窃取 Token)。HttpOnly Cookie 无法被 JavaScript 访问,XSS 攻击无法窃取 Token。这是 Web 安全的核心实践之一。


六、安全防护最佳实践

1. 敏感信息绝不暴露于客户端

// ❌ 危险:在客户端存储权限信息
localStorage.setItem('isAdmin', 'true');

// ✅ 正确:仅在服务端验证权限
export async function DELETE(request: Request) {
  const session = await getSession();
  const user = await db.users.findUnique({ 
    where: { id: session?.userId } 
  });

  if (user?.role !== 'admin') {
    return new Response('Forbidden', { status: 403 });
  }

  await deleteData();
}

2. 密码哈希存储

import bcrypt from 'bcryptjs';

// 注册时:哈希密码
const saltRounds = 12;  // 10-12 为合理范围
const hashedPassword = await bcrypt.hash(plainPassword, saltRounds);
await db.users.create({ 
  data: { email, hashedPassword } 
});

// 登录时:验证密码
const isValid = await bcrypt.compare(plainPassword, hashedPassword);
if (!isValid) {
  throw new Error('密码错误');
}

绝对禁止明文存储密码,即使数据库泄露,哈希密码也能保护用户安全。

3. 限制登录尝试次数

// lib/rate-limit.ts
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

const MAX_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60; // 15 分钟

/**
 * 检查登录速率限制
 */
export async function checkRateLimit(email: string): Promise<{
  allowed: boolean;
  remainingAttempts?: number;
  lockoutUntil?: Date;
}> {
  const key = `login_attempts:${email}`;
  const attempts = await redis.get<number>(key) || 0;

  if (attempts >= MAX_ATTEMPTS) {
    const ttl = await redis.ttl(key);
    return {
      allowed: false,
      lockoutUntil: new Date(Date.now() + ttl * 1000),
    };
  }

  return {
    allowed: true,
    remainingAttempts: MAX_ATTEMPTS - attempts,
  };
}

/**
 * 记录登录失败
 */
export async function recordFailedAttempt(email: string): Promise<void> {
  const key = `login_attempts:${email}`;
  await redis.incr(key);
  await redis.expire(key, LOCKOUT_DURATION);
}

/**
 * 重置登录尝试计数
 */
export async function resetLoginAttempts(email: string): Promise<void> {
  const key = `login_attempts:${email}`;
  await redis.del(key);
}

4. CSRF 防护

Auth.js 内置 CSRF 保护。若手动实现 JWT,需添加 CSRF Token:

// 生成 CSRF Token
import { randomBytes } from 'crypto';

export function generateCsrfToken(): string {
  return randomBytes(32).toString('hex');
}

// 验证 CSRF Token
export function verifyCsrfToken(
  tokenFromCookie: string,
  tokenFromBody: string
): boolean {
  return tokenFromCookie === tokenFromBody;
}

七、本章小结

通过本章学习,你应该掌握了:

  • Auth.js 的完整配置与使用方法
  • OAuth 社交登录与邮箱密码登录的实现
  • Session 在服务端和客户端组件中的获取方式
  • Middleware 路由保护与 RBAC 权限控制
  • JWT 手动实现的底层原理与安全存储
  • 常见安全陷阱及防护策略(XSS、CSRF、暴力破解)

下一章《从原理到实践深度剖析缓存策略》将继续更深入地剖析 Next.js 的多层缓存架构,揭示其工作原理、最佳实践以及常见陷阱。

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

本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。

应用的质量不仅体现在正常运行时,更体现在出错和加载场景下的用户体验。因此,做好错误和边界处理是构建健壮应用的核心之一。Next.js 通过特殊文件约定,使这些"边缘情况"的处理变得系统化、规范化。

一、Next.js 的"文件即配置"理念

前面我们已经深入讲解过,在 App Router 中,Next.js的理念是“文件即配置”,路由系统就是在这样一套机制下建立起来的。同样,在Next.js中错误处理和加载状态也是通过特定命名的文件实现,而非全局配置:

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. 基础用法

// 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)骨架屏的优势

  • 展示页面大致结构
  • 降低用户心理负担
  • 提升感知性能
// 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>
  );
}
// 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 组件:

// 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. 基础实现

// 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. 错误边界作用域

理解错误捕获范围对调试至关重要:

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 处理:

// 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. 基础实现

// 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

// 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 中的错误处理

根据错误类型选择合适的处理方式:

方式一:返回错误状态(可预期错误)

适用于表单验证、业务逻辑校验等场景:

// 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');
}

方式二:抛出错误(不可预期错误)

适用于数据库异常、网络故障等场景:

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 集成

npx @sentry/wizard@latest -i nextjs

Sentry 自动捕获未处理错误并发送至 Dashboard,包含完整调用栈和用户上下文。

2. 自定义错误日志

即使不使用第三方服务,也应记录错误:

'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. 隐藏技术细节

// ❌ 危险:暴露内部实现
<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 中的两种错误处理方式
  • 错误监控服务的集成方法
  • 生产环境的错误处理最佳实践

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

❌