Server Components vs Client Components 实战
第 11 章讲了 RSC 原理,本章聚焦实战——如何在真实 AI SaaS 项目中划分 Server/Client 边界、组织组件目录、处理第三方库兼容性、以及优化 Bundle 大小。每个模式都配有可直接复用的代码。
1. 组件划分实战
1.1 AI 对话页面的完整拆分
app/(dashboard)/chat/[id]/page.tsx (Server)
│
├── 数据获取层(Server)
│ ├── 查询对话信息
│ ├── 查询消息列表
│ └── 查询用户配额
│
└── 渲染层
├── ChatHeader (Server) — 对话标题、模型名、Token 统计
├── MessageList (Server) — 消息列表渲染
│ ├── MessageItem (Server) — 单条消息(纯展示)
│ ├── CodeBlock (Server) — 代码块(语法高亮在服务端)
│ └── CopyButton (Client) — 复制按钮(需要 onClick)
├── ChatInput (Client) — 输入框、发送按钮
│ ├── ModelSelector (Client) — 模型选择下拉框
│ └── FileUpload (Client) — 文件上传
└── ScrollToBottom (Client) — 滚动到底部按钮
1.2 代码实现
// app/(dashboard)/chat/[id]/page.tsx — Server Component
import { auth } from '@/lib/auth/config'
import { ChatHeader } from './chat-header'
import { MessageList } from './message-list'
import { ChatInput } from './chat-input'
export default async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const session = await auth()
const [chat, messages] = await Promise.all([
getChat(id, session!.user.id),
getMessages(id),
])
if (!chat) notFound()
return (
<div className="flex h-full flex-col">
<ChatHeader chat={chat} />
<MessageList messages={messages} />
<ChatInput chatId={id} model={chat.model} />
</div>
)
}// chat-header.tsx — Server Component(纯展示,零 JS)
export function ChatHeader({ chat }: { chat: Chat }) {
return (
<header className="border-b px-4 py-3">
<h1 className="text-lg font-semibold">{chat.title}</h1>
<p className="text-sm text-muted-foreground">
模型:{chat.model} · {chat.messageCount} 条消息 · {chat.tokenUsage} tokens
</p>
</header>
)
}// message-list.tsx — Server Component
import { CodeBlock } from './code-block'
import { CopyButton } from './copy-button'
export function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg) => (
<div key={msg.id} className={msg.role === 'user' ? 'ml-auto max-w-lg' : 'max-w-2xl'}>
<div className="rounded-lg bg-muted p-3">
{msg.content}
{msg.codeBlocks?.map((block, i) => (
<div key={i} className="relative mt-2">
<CodeBlock code={block.code} language={block.language} />
<CopyButton text={block.code} />
</div>
))}
</div>
</div>
))}
</div>
)
}// code-block.tsx — Server Component(语法高亮在服务端完成,不发送 JS)
import { highlight } from '@/lib/shiki'
export async function CodeBlock({ code, language }: { code: string; language: string }) {
const html = await highlight(code, language)
return <div dangerouslySetInnerHTML={{ __html: html }} className="rounded-md text-sm" />
}// copy-button.tsx — Client Component(需要 onClick + clipboard API)
'use client'
import { useState } from 'react'
import { Check, Copy } from 'lucide-react'
export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button onClick={handleCopy} className="absolute right-2 top-2 p-1 rounded hover:bg-accent">
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</button>
)
}关键点:CodeBlock 使用 Shiki 做语法高亮——这个库很大(数 MB),放在 Server Component 中不会发送到浏览器。CopyButton 只需要一个 onClick,是最小的 Client 组件。
2. 边界下推策略
2.1 核心原则
❌ 错误:整个页面标记 'use client'
'use client'
export default function ChatPage() {
const [input, setInput] = useState('')
const messages = ... // 数据获取也被迫在客户端
❌ 结果:所有组件的 JS 都发送到浏览器,无法直接查数据库
✅ 正确:只在需要交互的叶子组件标记 'use client'
export default async function ChatPage() {
const messages = await getMessages() // Server 直查数据库
return (
<div>
<MessageList messages={messages} /> {/* Server — 零 JS */}
<ChatInput /> {/* Client — 最小 JS */}
</div>
)
}
2.2 重构案例:从整页 Client 到精确边界
// ❌ 重构前:整个设置页是 Client Component
'use client'
export default function SettingsPage() {
const [name, setName] = useState('')
const { data: user } = useQuery(...)
return (
<div>
<h1>设置</h1>
<ProfileSection user={user} /> {/* 被拖入 Client */}
<BillingSection user={user} /> {/* 被拖入 Client */}
<ApiKeysSection user={user} /> {/* 被拖入 Client */}
<NameForm name={name} setName={setName} /> {/* 真正需要 Client 的 */}
</div>
)
}// ✅ 重构后:只有表单是 Client
export default async function SettingsPage() {
const user = await getUser() // Server 直查
return (
<div>
<h1>设置</h1>
<ProfileSection user={user} /> {/* Server — 零 JS */}
<BillingSection user={user} /> {/* Server — 零 JS */}
<ApiKeysSection user={user} /> {/* Server — 零 JS */}
<NameForm defaultName={user.name} /> {/* Client — 只有这个需要 */}
</div>
)
}3. 组件目录组织
3.1 推荐结构
components/
├── ui/ # shadcn/ui 基础组件(大部分 Client)
│ ├── button.tsx # 'use client'
│ ├── dialog.tsx # 'use client'
│ └── skeleton.tsx # Server(纯 CSS 动画)
├── shared/ # 通用业务组件
│ ├── header.tsx # Server
│ ├── sidebar.tsx # Client(交互)
│ ├── breadcrumb.tsx # Client(usePathname)
│ └── theme-toggle.tsx # Client(useState)
├── chat/ # 对话功能组件
│ ├── chat-input.tsx # Client
│ ├── message-item.tsx # Server
│ ├── code-block.tsx # Server
│ └── copy-button.tsx # Client
└── client/ # 第三方库 Client 包装
├── recharts.tsx # 'use client' + re-export
└── framer-motion.tsx # 'use client' + re-export
3.2 第三方库包装
// components/client/recharts.tsx
'use client'
export {
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts'
// components/client/framer-motion.tsx
'use client'
export { motion, AnimatePresence } from 'framer-motion'// 在 Server Component 中使用
import { BarChart, Bar } from '@/components/client/recharts'
export default async function StatsPage() {
const data = await getStats()
return <BarChart data={data}><Bar dataKey="value" /></BarChart>
}4. 常见陷阱与解决
4.1 context Provider 必须是 Client
// ❌ 直接在 layout 中使用 Provider
export default function RootLayout({ children }) {
return (
<ThemeProvider> {/* 报错:ThemeProvider 用了 useContext */}
{children}
</ThemeProvider>
)
}
// ✅ 创建 Client wrapper
// components/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
import { QueryProvider } from '@/lib/query-provider'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system">
<QueryProvider>
{children}
</QueryProvider>
</ThemeProvider>
)
}
// app/layout.tsx (Server)
import { Providers } from '@/components/providers'
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}4.2 环境检测
// ❌ 在 Server Component 中使用 window
export function Header() {
const width = window.innerWidth // 报错!Server 没有 window
}
// ✅ 需要 window 的逻辑放到 Client Component
'use client'
import { useEffect, useState } from 'react'
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
return isMobile
}4.3 server-only 保护
防止 Server-only 代码被意外导入到 Client Component:
pnpm add server-only// lib/db.ts
import 'server-only' // 如果被 Client Component 导入,编译时报错
import { drizzle } from 'drizzle-orm/postgres-js'
export const db = drizzle(...)5. 性能优化清单
| 优化 | 方法 | 效果 |
|---|---|---|
| 减少 Client JS | 边界下推到叶子组件 | Bundle 减少 30-60% |
| 重型库服务端化 | Shiki/Marked 放 Server Component | 不发送到浏览器 |
| 第三方库包装 | components/client/ re-export | 明确 Client 边界 |
| Provider 隔离 | 单独的 providers.tsx | layout 保持 Server |
| server-only 保护 | 敏感模块加 import 'server-only' | 编译时防止泄露 |
| 代码分割 | next/dynamic 懒加载大型 Client 组件 | 首屏 JS 更小 |
本章小结
- 边界下推:
'use client'尽量放在叶子组件,不要整页 Client - 语法高亮、Markdown 渲染等重型库放 Server Component,零 JS 发送
- 组件目录:
ui/(shadcn) +shared/(业务) +client/(第三方包装) - Provider:统一放
providers.tsx(Client),layout 保持 Server - server-only:保护数据库连接等敏感模块不被 Client 导入
下一章讲组件懒加载与 Suspense。