React Server Components 实战:下一代React架构

前端工程

什么是 React Server Components?为什么它很重要?

React Server Components(RSC)是 React 团队提出的全新组件模型,允许组件在服务端渲染并直接将结果流式传输到客户端,从根本上改变了 React 应用的架构方式。

RSC 解决的核心问题

问题 传统 SPA SSR RSC
首屏 JS 体积 大(全量打包) 中(需 hydration) 小(仅客户端组件)
数据获取 useEffect + API getServerSideProps 组件内直接访问数据源
打包体积 所有依赖进 bundle 同 SPA 服务端依赖零打包
请求瀑布流 严重(客户端串行) 中(服务端并行) 服务端并行 + 流式

RSC 的三大核心优势

  1. 零打包体积:服务端组件的依赖不会进入客户端 bundle
  2. 直接数据访问:组件内直接读写数据库、文件系统,无需 API 层
  3. 自动代码分割:客户端组件天然成为代码分割边界

💡 使用 JSON 格式化 工具检查 Next.js 配置文件,确保 App Router 设置正确。


Server 组件 vs Client 组件:完整对比

理解 Server 和 Client 组件的区别是掌握 RSC 的第一步

核心差异

特性 Server Component Client Component
渲染环境 服务端 客户端(浏览器)
文件后缀 .tsx(默认) .tsx + "use client"
数据获取 直接访问 DB/API/文件 useEffect / SWR / React Query
状态管理 无 useState/useReducer useState / useReducer / Context
副作用 无 useEffect / 生命周期 useEffect / useLayoutEffect
依赖打包 不进入客户端 bundle 进入客户端 bundle
DOM 访问
事件处理 无 onClick/onChange
异步支持 async/await 需要第三方库

组件选择决策树

需要交互(onClick / useState / useEffect)?
├── 是 → Client Component
└── 否 → 需要访问服务端资源(DB / 文件系统 / 私有 API)?
    ├── 是 → Server Component
    └── 否 → 需要减少客户端 bundle 体积?
        ├── 是 → Server Component
        └── 否 → Server Component(默认选择)

实际示例:Server 组件

// app/articles/page.tsx(Server Component,默认)
import { db } from '@/lib/db';

interface Article {
  id: number;
  title: string;
  content: string;
  createdAt: Date;
}

export default async function ArticlesPage() {
  const articles: Article[] = await db.query(
    'SELECT id, title, content, created_at FROM articles ORDER BY created_at DESC LIMIT 20'
  );

  return (
    <main className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">最新文章</h1>
      <div className="space-y-4">
        {articles.map((article) => (
          <article key={article.id} className="border rounded-lg p-4">
            <h2 className="text-xl font-semibold">{article.title}</h2>
            <p className="text-gray-600 mt-2">{article.content}</p>
            <time className="text-sm text-gray-400">
              {article.createdAt.toLocaleDateString('zh-CN')}
            </time>
          </article>
        ))}
      </div>
    </main>
  );
}

实际示例:Client 组件

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

import { useState, useTransition } from 'react';

interface LikeButtonProps {
  articleId: number;
  initialLikes: number;
}

export default function LikeButton({ articleId, initialLikes }: LikeButtonProps) {
  const [likes, setLikes] = useState(initialLikes);
  const [isPending, startTransition] = useTransition();

  const handleLike = async () => {
    startTransition(async () => {
      const res = await fetch(`/api/articles/${articleId}/like`, {
        method: 'POST',
      });
      const data = await res.json();
      setLikes(data.likes);
    });
  };

  return (
    <button
      onClick={handleLike}
      disabled={isPending}
      className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
    >
      {isPending ? '处理中...' : `点赞 (${likes})`}
    </button>
  );
}

"use client" 和 "use server" 指令详解

这两个指令是 RSC 架构的边界标记,决定了代码的执行环境。

"use client" 指令

// app/components/SearchBar.tsx
'use client';

import { useState } from 'react';

export default function SearchBar() {
  const [query, setQuery] = useState('');

  return (
    <div className="flex gap-2">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索文章..."
        className="border rounded px-3 py-2 flex-1"
      />
      <button className="bg-blue-500 text-white px-4 py-2 rounded">
        搜索
      </button>
    </div>
  );
}

"use server" 指令

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

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

export async function createArticle(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  if (!title || !content) {
    throw new Error('标题和内容不能为空');
  }

  await db.query(
    'INSERT INTO articles (title, content, created_at) VALUES ($1, $2, NOW())',
    [title, content]
  );

  revalidatePath('/articles');
}

export async function deleteArticle(id: number) {
  await db.query('DELETE FROM articles WHERE id = $1', [id]);
  revalidatePath('/articles');
}

指令使用规则

规则 说明
"use client" 必须在文件顶部 在所有 import 之前声明
"use server" 必须在文件顶部 在所有 import 之前声明,导出的函数必须是 async
Server 组件可导入 Client 组件 但 Client 组件不能导入 Server 组件
Client 组件可通过 props 接收 Server 组件 使用 children prop 传递
"use client" 是边界标记 标记该文件及其所有依赖为客户端代码

组件组合模式:Server 包裹 Client

// app/articles/page.tsx(Server Component)
import { db } from '@/lib/db';
import LikeButton from '@/components/LikeButton';
import SearchBar from '@/components/SearchBar';

export default async function ArticlesPage() {
  const articles = await db.query('SELECT * FROM articles');

  return (
    <div>
      <SearchBar />
      {articles.map((article) => (
        <article key={article.id}>
          <h2>{article.title}</h2>
          <p>{article.content}</p>
          <LikeButton articleId={article.id} initialLikes={article.likes} />
        </article>
      ))}
    </div>
  );
}

数据获取模式:告别 useEffect

RSC 最大的范式转变是数据获取从客户端移到服务端,彻底告别 useEffect 请求瀑布流。

传统模式 vs RSC 模式

// 传统模式:useEffect 瀑布流
'use client';
import { useState, useEffect } from 'react';

export default function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  useEffect(() => {
    if (!user) return;
    fetch(`/api/users/${userId}/posts`)
      .then(res => res.json())
      .then(data => setPosts(data));
  }, [userId, user]);

  if (!user) return <div>加载中...</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      {posts.map(post => <p key={post.id}>{post.title}</p>)}
    </div>
  );
}
// RSC 模式:服务端并行获取
import { db } from '@/lib/db';

export default async function UserProfile({ userId }: { userId: string }) {
  const [user, posts] = await Promise.all([
    db.query('SELECT * FROM users WHERE id = $1', [userId]),
    db.query('SELECT * FROM posts WHERE user_id = $1 ORDER BY created_at DESC', [userId]),
  ]);

  return (
    <div>
      <h1>{user.name}</h1>
      {posts.map(post => <p key={post.id}>{post.title}</p>)}
    </div>
  );
}

直接数据库访问

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

export default async function DashboardPage() {
  const [totalUsers, totalOrders, recentOrders] = await Promise.all([
    db.query('SELECT COUNT(*) as count FROM users'),
    db.query('SELECT COUNT(*) as count FROM orders WHERE created_at > NOW() - INTERVAL 30 DAY'),
    db.query('SELECT * FROM orders ORDER BY created_at DESC LIMIT 10'),
  ]);

  return (
    <div className="grid grid-cols-3 gap-4">
      <div className="bg-white rounded-lg shadow p-6">
        <h3 className="text-gray-500">总用户数</h3>
        <p className="text-3xl font-bold">{totalUsers.count}</p>
      </div>
      <div className="bg-white rounded-lg shadow p-6">
        <h3 className="text-gray-500">近30天订单</h3>
        <p className="text-3xl font-bold">{totalOrders.count}</p>
      </div>
      <div className="bg-white rounded-lg shadow p-6 col-span-3">
        <h3 className="text-gray-500 mb-4">最近订单</h3>
        <table className="w-full">
          <thead>
            <tr>
              <th>订单号</th>
              <th>金额</th>
              <th>状态</th>
            </tr>
          </thead>
          <tbody>
            {recentOrders.map(order => (
              <tr key={order.id}>
                <td>{order.orderNo}</td>
                <td>¥{order.amount}</td>
                <td>{order.status}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

文件系统访问

// app/docs/page.tsx
import { readFile, readdir } from 'fs/promises';
import { join } from 'path';

export default async function DocsPage() {
  const docsDir = join(process.cwd(), 'content/docs');
  const files = await readdir(docsDir);
  const docs = await Promise.all(
    files
      .filter(f => f.endsWith('.md'))
      .map(async (f) => {
        const content = await readFile(join(docsDir, f), 'utf-8');
        return { filename: f, content };
      })
  );

  return (
    <div>
      {docs.map(doc => (
        <article key={doc.filename}>
          <h2>{doc.filename}</h2>
          <pre>{doc.content}</pre>
        </article>
      ))}
    </div>
  );
}

💡 使用 JSON 转 TypeScript 工具从 API 响应生成 TypeScript 类型定义,减少手写类型的工作量。


Streaming 与 Suspense 集成

RSC 与 React Suspense 深度集成,实现流式渲染——页面各部分独立加载,用户无需等待所有数据。

基础 Suspense 模式

// app/page.tsx
import { Suspense } from 'react';

async function SlowSection() {
  const data = await fetch('https://api.example.com/slow', {
    next: { revalidate: 60 },
  }).then(res => res.json());

  return <div>{data.map(item => <p key={item.id}>{item.name}</p>)}</div>;
}

async function FastSection() {
  const data = await fetch('https://api.example.com/fast').then(res => res.json());
  return <div>{data.message}</div>;
}

export default function HomePage() {
  return (
    <div>
      <h1>流式渲染示例</h1>
      <Suspense fallback={<div className="animate-pulse">快速区域加载中...</div>}>
        <FastSection />
      </Suspense>
      <Suspense fallback={<div className="animate-pulse">慢速区域加载中...</div>}>
        <SlowSection />
      </Suspense>
    </div>
  );
}

嵌套 Suspense:渐进式加载

// app/dashboard/page.tsx
import { Suspense } from 'react';

async function RevenueChart() {
  const revenue = await db.query('SELECT * FROM revenue_monthly ORDER BY month');
  return <div className="h-64 bg-gray-100 rounded">图表渲染: {revenue.length} 条数据</div>;
}

async function UserGrowthChart() {
  const growth = await db.query('SELECT * FROM user_growth ORDER BY month');
  return <div className="h-64 bg-gray-100 rounded">增长曲线: {growth.length} 条数据</div>;
}

async function RecentActivity() {
  const activities = await db.query('SELECT * FROM activities ORDER BY created_at DESC LIMIT 20');
  return (
    <ul>
      {activities.map(a => <li key={a.id}>{a.description}</li>)}
    </ul>
  );
}

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">数据看板</h1>
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
          <RevenueChart />
        </Suspense>
        <Suspense fallback={<div className="h-64 animate-pulse bg-gray-200 rounded" />}>
          <UserGrowthChart />
        </Suspense>
      </div>
      <Suspense fallback={<div className="space-y-2">{Array.from({length: 5}).map((_, i) => (
        <div key={i} className="h-8 animate-pulse bg-gray-200 rounded" />
      ))}</div>}>
        <RecentActivity />
      </Suspense>
    </div>
  );
}

loading.tsx:Next.js 内置 Suspense

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="space-y-6 animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-48" />
      <div className="grid grid-cols-2 gap-4">
        <div className="h-64 bg-gray-200 rounded" />
        <div className="h-64 bg-gray-200 rounded" />
      </div>
      <div className="space-y-2">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="h-8 bg-gray-200 rounded" />
        ))}
      </div>
    </div>
  );
}

Next.js App Router 与 RSC

Next.js App Router 是 RSC 的最佳实践框架,提供了完整的 RSC 支持。

App Router 目录结构

app/
├── layout.tsx          # 根布局(Server Component)
├── page.tsx            # 首页(Server Component)
├── loading.tsx         # 首页加载状态
├── error.tsx           # 首页错误处理
├── not-found.tsx       # 404 页面
├── globals.css
├── articles/
│   ├── layout.tsx      # 文章布局
│   ├── page.tsx        # 文章列表
│   ├── loading.tsx
│   ├── [id]/
│   │   ├── page.tsx    # 文章详情
│   │   └── loading.tsx
│   └── new/
│       └── page.tsx    # 新建文章
├── api/
│   └── health/
│       └── route.ts    # API Route
└── components/
    ├── Header.tsx
    ├── LikeButton.tsx  # Client Component
    └── SearchBar.tsx   # Client Component

根布局

// app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: {
    template: '%s | ToolsKu 博客',
    default: 'ToolsKu 博客',
  },
  description: '技术文章与教程',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-CN">
      <body className="min-h-screen bg-gray-50">
        <nav className="bg-white shadow-sm border-b">
          <div className="max-w-6xl mx-auto px-4 py-3 flex justify-between items-center">
            <a href="/" className="text-xl font-bold text-blue-600">ToolsKu</a>
            <div className="flex gap-4">
              <a href="/articles" className="text-gray-600 hover:text-blue-600">文章</a>
              <a href="/about" className="text-gray-600 hover:text-blue-600">关于</a>
            </div>
          </div>
        </nav>
        <main className="max-w-6xl mx-auto px-4 py-8">
          {children}
        </main>
      </body>
    </html>
  );
}

动态路由

// app/articles/[id]/page.tsx
import { db } from '@/lib/db';
import { notFound } from 'next/navigation';
import LikeButton from '@/components/LikeButton';

interface PageProps {
  params: Promise<{ id: string }>;
}

export default async function ArticleDetailPage({ params }: PageProps) {
  const { id } = await params;
  const article = await db.query(
    'SELECT * FROM articles WHERE id = $1',
    [id]
  );

  if (!article) {
    notFound();
  }

  return (
    <article className="max-w-3xl mx-auto">
      <h1 className="text-3xl font-bold mb-4">{article.title}</h1>
      <div className="prose lg:prose-xl" dangerouslySetInnerHTML={{ __html: article.htmlContent }} />
      <div className="mt-8">
        <LikeButton articleId={article.id} initialLikes={article.likes} />
      </div>
    </article>
  );
}

Server Actions:服务端变更操作

Server Actions 是 RSC 生态中处理数据变更的核心机制,替代传统 API Route。

基础 Server Action

// app/articles/new/page.tsx
import { createArticle } from '@/actions/article';

export default function NewArticlePage() {
  return (
    <form action={createArticle} className="max-w-2xl mx-auto space-y-4">
      <div>
        <label className="block text-sm font-medium mb-1">标题</label>
        <input
          name="title"
          type="text"
          required
          className="w-full border rounded px-3 py-2"
        />
      </div>
      <div>
        <label className="block text-sm font-medium mb-1">内容</label>
        <textarea
          name="content"
          required
          rows={10}
          className="w-full border rounded px-3 py-2"
        />
      </div>
      <button
        type="submit"
        className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
      >
        发布文章
      </button>
    </form>
  );
}

useActionState:带状态的 Server Action

// app/components/ArticleForm.tsx
'use client';

import { useActionState } from 'react';
import { createArticleWithState } from '@/actions/article';

const initialState = { message: '', errors: {} };

export default function ArticleForm() {
  const [state, formAction, isPending] = useActionState(
    createArticleWithState,
    initialState
  );

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <input name="title" type="text" placeholder="标题" className="w-full border rounded px-3 py-2" />
        {state.errors.title && <p className="text-red-500 text-sm">{state.errors.title}</p>}
      </div>
      <div>
        <textarea name="content" rows={10} placeholder="内容" className="w-full border rounded px-3 py-2" />
        {state.errors.content && <p className="text-red-500 text-sm">{state.errors.content}</p>}
      </div>
      <button
        type="submit"
        disabled={isPending}
        className="bg-blue-500 text-white px-6 py-2 rounded disabled:opacity-50"
      >
        {isPending ? '发布中...' : '发布文章'}
      </button>
      {state.message && <p className="text-green-600">{state.message}</p>}
    </form>
  );
}
// actions/article.ts
'use server';

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

export async function createArticleWithState(prevState: any, formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  const errors: Record<string, string> = {};

  if (!title || title.trim().length < 2) {
    errors.title = '标题至少2个字符';
  }
  if (!content || content.trim().length < 10) {
    errors.content = '内容至少10个字符';
  }

  if (Object.keys(errors).length > 0) {
    return { message: '', errors };
  }

  await db.query(
    'INSERT INTO articles (title, content, created_at) VALUES ($1, $2, NOW())',
    [title, content]
  );

  revalidatePath('/articles');
  return { message: '文章发布成功!', errors: {} };
}

乐观更新:useOptimistic

// app/components/OptimisticLikeButton.tsx
'use client';

import { useOptimistic } from 'react';
import { toggleLike } from '@/actions/article';

interface LikeButtonProps {
  articleId: number;
  initialLikes: number;
  isLiked: boolean;
}

export default function OptimisticLikeButton({ articleId, initialLikes, isLiked }: LikeButtonProps) {
  const [optimisticState, addOptimistic] = useOptimistic(
    { likes: initialLikes, isLiked },
    (state, newIsLiked: boolean) => ({
      likes: newIsLiked ? state.likes + 1 : state.likes - 1,
      isLiked: newIsLiked,
    })
  );

  const handleToggle = async () => {
    const newIsLiked = !optimisticState.isLiked;
    addOptimistic(newIsLiked);
    await toggleLike(articleId, newIsLiked);
  };

  return (
    <button
      onClick={handleToggle}
      className={`px-4 py-2 rounded ${optimisticState.isLiked ? 'bg-red-500 text-white' : 'bg-gray-200'}`}
    >
      {optimisticState.isLiked ? '❤️' : '🤍'} {optimisticState.likes}
    </button>
  );
}

缓存策略:revalidate 与 ISR

RSC 的缓存策略决定了数据的新鲜度性能之间的平衡。

缓存策略对比

策略 函数 缓存时间 适用场景
静态渲染 默认 构建时生成,永久缓存 博客、文档
ISR revalidate 定时重新验证 新闻、商品列表
动态渲染 no-store 每次请求重新获取 实时数据、用户面板
按需重新验证 revalidatePath/Tag 手动触发 内容更新后

fetch 缓存配置

// 静态渲染:构建时获取,永久缓存
const staticData = await fetch('https://api.example.com/docs', {
  cache: 'force-cache',
});

// ISR:每60秒重新验证
const isrData = await fetch('https://api.example.com/articles', {
  next: { revalidate: 60 },
});

// 动态渲染:每次请求重新获取
const dynamicData = await fetch('https://api.example.com/realtime', {
  cache: 'no-store',
});

// 按标签缓存,可按需清除
const taggedData = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] },
});

按需重新验证

// actions/revalidate.ts
'use server';

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

export async function revalidateArticles() {
  revalidatePath('/articles');
  revalidatePath('/articles/[id]', 'page');
}

export async function revalidateProducts() {
  revalidateTag('products');
}

路由段配置

// app/articles/page.tsx
export const revalidate = 300; // ISR:5分钟重新验证

export default async function ArticlesPage() {
  const articles = await fetch('https://api.example.com/articles', {
    next: { revalidate: 300 },
  }).then(res => res.json());

  return <div>{articles.map(a => <p key={a.id}>{a.title}</p>)}</div>;
}
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // 强制动态渲染

export default async function DashboardPage() {
  const data = await db.query('SELECT * FROM stats');
  return <div>{data.length} 条记录</div>;
}

💡 使用 Base64 编码 工具处理 API Token 和缓存 Key 的编码需求。


性能对比:Bundle Size、TTFB、FCP

RSC 在性能指标上有显著提升,以下是真实项目的对比数据。

Bundle Size 对比

指标 Pages Router (SSR) App Router (RSC) 改善
首页 JS 体积 187 KB 42 KB -77%
文章页 JS 体积 203 KB 38 KB -81%
第三方库体积 94 KB 0 KB(服务端) -100%
Hydration 脚本 156 KB 23 KB -85%

Core Web Vitals 对比

指标 Pages Router App Router (RSC) 改善
TTFB 320ms 180ms -44%
FCP 1.8s 0.9s -50%
LCP 2.5s 1.4s -44%
TTI 3.2s 1.6s -50%
CLS 0.12 0.05 -58%

为什么 RSC 更快?

  1. 零 Hydration:Server Component 不需要 Hydration,减少 JS 执行时间
  2. 流式传输:页面分块到达,FCP 大幅提前
  3. 依赖不进 Bundlemarkedhighlight.js 等仅在服务端运行
  4. 更少的请求瀑布流:服务端并行获取数据

性能测量代码

// app/api/metrics/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const metrics = {
    bundleSizeKB: 42,
    ttfbMs: 180,
    fcpMs: 900,
    lcpMs: 1400,
    cls: 0.05,
    timestamp: new Date().toISOString(),
  };

  return NextResponse.json(metrics);
}

常见陷阱与错误

陷阱1:在 Server Component 中使用客户端 API

// ❌ 错误:Server Component 不能使用 useState
export default async function Page() {
  const [count, setCount] = useState(0); // 报错!
  return <div>{count}</div>;
}

// ✅ 正确:提取为 Client Component
// components/Counter.tsx
'use client';
import { useState } from 'react';
export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

陷阱2:Client 组件导入 Server 组件

// ❌ 错误:Client 组件不能直接导入 Server 组件
'use client';
import ServerDataTable from './ServerDataTable'; // 报错!

export default function ClientWrapper() {
  return <ServerDataTable />;
}

// ✅ 正确:通过 children prop 传递
// components/ClientWrapper.tsx
'use client';
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>切换</button>
      {isOpen && children}
    </div>
  );
}

// page.tsx
import ClientWrapper from '@/components/ClientWrapper';
import ServerDataTable from '@/components/ServerDataTable';

export default function Page() {
  return (
    <ClientWrapper>
      <ServerDataTable />
    </ClientWrapper>
  );
}

陷阱3:Server Action 中忘记 revalidate

// ❌ 错误:数据变更后缓存未更新
'use server';
export async function updateArticle(id: number, data: FormData) {
  await db.query('UPDATE articles SET title = $1 WHERE id = $2', [data.get('title'), id]);
  // 缺少 revalidatePath!用户看到的还是旧数据
}

// ✅ 正确:变更后重新验证
'use server';
import { revalidatePath } from 'next/cache';
export async function updateArticle(id: number, data: FormData) {
  await db.query('UPDATE articles SET title = $1 WHERE id = $2', [data.get('title'), id]);
  revalidatePath('/articles');
  revalidatePath(`/articles/${id}`);
}

陷阱4:序列化不可序列化的数据

// ❌ 错误:传递不可序列化的 props 给 Client 组件
import LikeButton from './LikeButton';
export default async function Page() {
  const result = await db.query('SELECT * FROM articles');
  // Date 对象无法序列化传递给 Client Component
  return <LikeButton createdAt={result.createdAt} />;
}

// ✅ 正确:转换为可序列化的值
export default async function Page() {
  const result = await db.query('SELECT * FROM articles');
  return <LikeButton createdAt={result.createdAt.toISOString()} />;
}

常见错误速查表

错误信息 原因 解决方案
useState is not defined Server Component 中使用了 Hook 添加 "use client" 或提取为 Client 组件
onClick is not defined Server Component 中使用了事件 提取交互部分为 Client 组件
Functions cannot be passed as props 传递函数给 Client 组件 使用 Server Actions
Only plain objects can be passed 传递了不可序列化的数据 转换为 JSON 兼容格式
Hydration mismatch Server/Client 渲染不一致 检查动态内容是否一致

从 Pages Router 迁移指南

迁移步骤概览

步骤 内容 预估时间
1 创建 app/ 目录,保留 pages/ 1 小时
2 迁移根布局 _app.tsxapp/layout.tsx 2-4 小时
3 逐页迁移 pages/app/ 每页 1-2 小时
4 迁移 getServerSideProps → Server Component 每页 1-2 小时
5 迁移 getStaticProps → 静态渲染 + ISR 每页 1-2 小时
6 迁移 API Routes → Server Actions / Route Handlers 每个 0.5-1 小时
7 删除 pages/ 目录 0.5 小时

getServerSideProps 迁移

// Pages Router:getServerSideProps
export async function getServerSideProps({ params }) {
  const article = await db.article.findUnique({ where: { id: params.id } });
  return { props: { article } };
}

export default function ArticlePage({ article }) {
  return <div>{article.title}</div>;
}
// App Router:Server Component
export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });

  if (!article) {
    notFound();
  }

  return <div>{article.title}</div>;
}

getStaticProps 迁移

// Pages Router:getStaticProps + ISR
export async function getStaticPaths() {
  const articles = await db.article.findMany({ select: { id: true } });
  return {
    paths: articles.map(a => ({ params: { id: String(a.id) } })),
    fallback: 'blocking',
  };
}

export async function getStaticProps({ params }) {
  const article = await db.article.findUnique({ where: { id: params.id } });
  return { props: { article }, revalidate: 300 };
}
// App Router:generateStaticParams + ISR
export async function generateStaticParams() {
  const articles = await db.article.findMany({ select: { id: true } });
  return articles.map(a => ({ id: String(a.id) }));
}

export const revalidate = 300;

export default async function ArticlePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });
  return <div>{article.title}</div>;
}

API Routes 迁移

// Pages Router:API Route
// pages/api/articles.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    const articles = await db.article.findMany();
    res.status(200).json(articles);
  } else if (req.method === 'POST') {
    const { title, content } = req.body;
    const article = await db.article.create({ data: { title, content } });
    res.status(201).json(article);
  }
}
// App Router:Route Handler
// app/api/articles/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET() {
  const articles = await db.article.findMany();
  return NextResponse.json(articles);
}

export async function POST(request: NextRequest) {
  const { title, content } = await request.json();
  const article = await db.article.create({ data: { title, content } });
  return NextResponse.json(article, { status: 201 });
}

SEO 优化与 RSC

RSC 天然对 SEO 友好——Server Component 在服务端渲染,搜索引擎可直接抓取完整 HTML。

Metadata API

// app/articles/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';

interface PageProps {
  params: Promise<{ id: string }>;
}

export async function generateMetadata(
  { params }: PageProps,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });

  return {
    title: article.title,
    description: article.excerpt,
    openGraph: {
      title: article.title,
      description: article.excerpt,
      type: 'article',
      publishedTime: article.createdAt.toISOString(),
      authors: [article.author.name],
      images: [{ url: article.coverImage, width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title: article.title,
      description: article.excerpt,
      images: [article.coverImage],
    },
  };
}

Sitemap 与 Robots

// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { db } from '@/lib/db';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const articles = await db.article.findMany({
    select: { id: true, updatedAt: true },
  });

  const articleUrls = articles.map(article => ({
    url: `https://toolsku.com/articles/${article.id}`,
    lastModified: article.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }));

  return [
    { url: 'https://toolsku.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
    { url: 'https://toolsku.com/articles', lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
    ...articleUrls,
  ];
}
// app/robots.ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/api/', '/dashboard/'] },
    ],
    sitemap: 'https://toolsku.com/sitemap.xml',
  };
}

结构化数据(JSON-LD)

// app/articles/[id]/page.tsx
export default async function ArticlePage({ params }: PageProps) {
  const { id } = await params;
  const article = await db.article.findUnique({ where: { id } });

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: article.title,
    description: article.excerpt,
    image: article.coverImage,
    datePublished: article.createdAt.toISOString(),
    dateModified: article.updatedAt.toISOString(),
    author: {
      '@type': 'Person',
      name: article.author.name,
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>
        <h1>{article.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: article.htmlContent }} />
      </article>
    </>
  );
}

FAQ

RSC 和 SSR 有什么区别?

SSR 在服务端渲染 HTML 但仍需在客户端 Hydration(加载完整 JS 重建组件树)。RSC 渲染的 Server Component 不需要 Hydration,其代码完全不会发送到客户端。

所有组件都应该用 Server Component 吗?

默认使用 Server Component,只在需要交互(useState、useEffect、事件处理)时才使用 Client Component。经验法则:80% Server + 20% Client。

RSC 可以不用 Next.js 吗?

理论上可以,React 提供了底层协议,但需要自行实现 RSC 的传输和渲染管道。目前 Next.js App Router 是最成熟的 RSC 实现,其他框架(Remix、Waku)也在跟进。

Server Actions 安全吗?

Server Actions 本质是 POST 请求,Next.js 会自动做 CSRF 保护。但仍需在 Action 内部做输入验证和权限检查,不要信任客户端传来的任何数据。

RSC 对 SEO 有帮助吗?

非常有帮助。Server Component 在服务端渲染完整 HTML,搜索引擎可直接抓取。结合 Metadata API、Sitemap、JSON-LD,SEO 效果远超客户端渲染。

如何调试 Server Component?

  1. 在 Server Component 中使用 console.log,输出在服务端终端
  2. 使用 Next.js Dev Tools 查看组件渲染边界
  3. 使用 React DevTools 查看 Client Component 树
  4. next.config.js 中开启 logging: true

大型项目迁移顺序建议?

  1. 先迁移纯展示页面(博客、文档)——风险最低
  2. 再迁移数据展示页面(列表、详情)——替换 getServerSideProps
  3. 最后迁移交互页面(表单、仪表盘)——需要 Server Actions
  4. 保持 pages/app/ 并存,逐步迁移

本站提供浏览器本地工具,免注册即可试用 →

#React#RSC#Server Components#Next.js#教程