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 的三大核心优势
- 零打包体积:服务端组件的依赖不会进入客户端 bundle
- 直接数据访问:组件内直接读写数据库、文件系统,无需 API 层
- 自动代码分割:客户端组件天然成为代码分割边界
💡 使用 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 更快?
- 零 Hydration:Server Component 不需要 Hydration,减少 JS 执行时间
- 流式传输:页面分块到达,FCP 大幅提前
- 依赖不进 Bundle:
marked、highlight.js等仅在服务端运行 - 更少的请求瀑布流:服务端并行获取数据
性能测量代码
// 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.tsx → app/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?
- 在 Server Component 中使用
console.log,输出在服务端终端 - 使用 Next.js Dev Tools 查看组件渲染边界
- 使用 React DevTools 查看 Client Component 树
- 在
next.config.js中开启logging: true
大型项目迁移顺序建议?
- 先迁移纯展示页面(博客、文档)——风险最低
- 再迁移数据展示页面(列表、详情)——替换 getServerSideProps
- 最后迁移交互页面(表单、仪表盘)——需要 Server Actions
- 保持
pages/和app/并存,逐步迁移
本站提供浏览器本地工具,免注册即可试用 →