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.tsxlayout 保持 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。