🙋 一个页面既需要快速加载,又需要丰富交互。如何合理划分 Server 和 Client 组件的边界?
组件类型决策#
决策流程图#
需要 useState/useEffect?──是──→ Client Component │ 否 ↓需要事件处理(onClick 等)?──是──→ Client Component │ 否 ↓需要浏览器 API?──是──→ Client Component │ 否 ↓使用仅客户端库?──是──→ Client Component │ 否 ↓Server Component ✅快速参考表#
| 场景 | Server | Client |
|---|---|---|
| 获取数据 | ✅ | 可以 |
| 访问后端资源 | ✅ | ❌ |
| 敏感信息 | ✅ | ❌ |
| 使用 Hooks | ❌ | ✅ |
| 事件监听 | ❌ | ✅ |
| 浏览器 API | ❌ | ✅ |
| 减少 JS 体积 | ✅ | ❌ |
组件边界设计#
原则:将 Client 边界推到叶子节点#
// ❌ 不好:整个列表都是 Client'use client'
export default function ProductList({ products }) { const [favorites, setFavorites] = useState([])
return ( <ul> {products.map((product) => ( <li key={product.id}> <h3>{product.name}</h3> {/* 静态内容 */} <p>{product.description}</p> {/* 静态内容 */} <button onClick={() => toggleFavorite(product.id)}>收藏</button> </li> ))} </ul> )}// ✅ 好:只有交互部分是 Client
// app/products/page.tsx - Server Componentimport { FavoriteButton } from '@/components/FavoriteButton'
async function getProducts() { return fetch('...').then((r) => r.json())}
export default async function ProductList() { const products = await getProducts()
return ( <ul> {products.map((product) => ( <li key={product.id}> <h3>{product.name}</h3> {/* Server */} <p>{product.description}</p> {/* Server */} <FavoriteButton productId={product.id} /> {/* Client */} </li> ))} </ul> )}// components/FavoriteButton.tsx - Client Component'use client'
import { useState } from 'react'
export function FavoriteButton({ productId }: { productId: string }) { const [isFavorite, setIsFavorite] = useState(false)
return ( <button onClick={() => setIsFavorite(!isFavorite)}> {isFavorite ? '❤️' : '🤍'} </button> )}组合模式#
1. Wrapper 模式#
Client 包裹 Server(通过 children):
// components/Accordion.tsx - Client Component'use client'
import { useState, ReactNode } from 'react'
interface AccordionProps { title: string children: ReactNode}
export function Accordion({ title, children }: AccordionProps) { const [isOpen, setIsOpen] = useState(false)
return ( <div className="border rounded"> <button onClick={() => setIsOpen(!isOpen)} className="w-full p-4 text-left font-bold" > {title} {isOpen ? '▼' : '▶'} </button> {isOpen && <div className="p-4 border-t">{children}</div>} </div> )}// app/faq/page.tsx - Server Componentimport { Accordion } from '@/components/Accordion'
async function getFAQs() { return fetch('...').then((r) => r.json())}
export default async function FAQPage() { const faqs = await getFAQs()
return ( <div className="space-y-4"> {faqs.map((faq) => ( <Accordion key={faq.id} title={faq.question}> {/* Server Component 作为 children */} <FAQContent content={faq.answer} /> </Accordion> ))} </div> )}
// Server Componentfunction FAQContent({ content }: { content: string }) { return <div dangerouslySetInnerHTML={{ __html: content }} />}2. Slots 模式#
多个 Server 组件作为不同 props:
// components/Modal.tsx - Client Component'use client'
import { useState, ReactNode } from 'react'
interface ModalProps { trigger: ReactNode header: ReactNode content: ReactNode footer: ReactNode}
export function Modal({ trigger, header, content, footer }: ModalProps) { const [isOpen, setIsOpen] = useState(false)
return ( <> <div onClick={() => setIsOpen(true)}>{trigger}</div> {isOpen && ( <div className="fixed inset-0 bg-black/50 flex items-center justify-center"> <div className="bg-white rounded-lg max-w-md w-full"> <div className="p-4 border-b">{header}</div> <div className="p-4">{content}</div> <div className="p-4 border-t flex justify-end gap-2"> {footer} <button onClick={() => setIsOpen(false)}>关闭</button> </div> </div> </div> )} </> )}// app/page.tsx - Server Componentimport { Modal } from '@/components/Modal'
export default function Page() { return ( <Modal trigger={<button>打开详情</button>} header={<ProductHeader />} {/* Server */} content={<ProductDetails />} {/* Server */} footer={<ProductActions />} {/* Server */} /> )}实战案例#
电商产品页#
app/products/[id]/page.tsx (Server)├── ProductImages (Server) - 图片展示├── ProductInfo (Server) - 基本信息├── PriceDisplay (Server) - 价格展示├── AddToCartButton (Client) - 加购按钮├── QuantitySelector (Client) - 数量选择├── ProductTabs (Client) - 标签切换容器│ ├── Description (Server) - 商品描述│ ├── Specifications (Server) - 规格参数│ └── Reviews (Server) - 评价列表└── SimilarProducts (Server) - 相似推荐// Next.js 15.ximport { AddToCartButton } from '@/components/AddToCartButton'import { QuantitySelector } from '@/components/QuantitySelector'import { ProductTabs } from '@/components/ProductTabs'
async function getProduct(id: string) { return fetch(`https://api.example.com/products/${id}`).then((r) => r.json())}
export default async function ProductPage({ params,}: { params: Promise<{ id: string }>}) { const { id } = await params const product = await getProduct(id)
return ( <div className="grid grid-cols-2 gap-8"> {/* Server: 图片 */} <div className="space-y-4"> <img src={product.image} alt={product.name} className="w-full" /> </div>
{/* 产品信息 */} <div className="space-y-4"> {/* Server: 基本信息 */} <h1 className="text-2xl font-bold">{product.name}</h1> <p className="text-3xl text-red-600">¥{product.price}</p> <p className="text-gray-600">{product.summary}</p>
{/* Client: 交互组件 */} <div className="flex gap-4 items-center"> <QuantitySelector /> <AddToCartButton productId={id} /> </div> </div>
{/* Client + Server: 标签页 */} <div className="col-span-2"> <ProductTabs description={<Description content={product.description} />} specs={<Specifications data={product.specs} />} reviews={<Reviews productId={id} />} /> </div> </div> )}
// Server Componentsfunction Description({ content }: { content: string }) { return <div dangerouslySetInnerHTML={{ __html: content }} />}
async function Specifications({ data }: { data: Record<string, string> }) { return ( <table className="w-full"> <tbody> {Object.entries(data).map(([key, value]) => ( <tr key={key} className="border-b"> <td className="py-2 font-medium">{key}</td> <td className="py-2">{value}</td> </tr> ))} </tbody> </table> )}
async function Reviews({ productId }: { productId: string }) { const reviews = await fetch( `https://api.example.com/products/${productId}/reviews` ).then((r) => r.json())
return ( <ul className="space-y-4"> {reviews.map((review: any) => ( <li key={review.id} className="border-b pb-4"> <div className="flex gap-2 items-center"> <span className="font-medium">{review.author}</span> <span className="text-yellow-500">{'★'.repeat(review.rating)}</span> </div> <p className="mt-2">{review.content}</p> </li> ))} </ul> )}// components/ProductTabs.tsx - Client Component'use client'
import { useState, ReactNode } from 'react'
interface Props { description: ReactNode specs: ReactNode reviews: ReactNode}
export function ProductTabs({ description, specs, reviews }: Props) { const [activeTab, setActiveTab] = useState<'desc' | 'specs' | 'reviews'>( 'desc' )
const tabs = [ { key: 'desc', label: '商品详情', content: description }, { key: 'specs', label: '规格参数', content: specs }, { key: 'reviews', label: '用户评价', content: reviews }, ] as const
return ( <div> <div className="flex border-b"> {tabs.map((tab) => ( <button key={tab.key} onClick={() => setActiveTab(tab.key)} className={`px-4 py-2 ${ activeTab === tab.key ? 'border-b-2 border-blue-600' : '' }`} > {tab.label} </button> ))} </div> <div className="py-4"> {tabs.find((t) => t.key === activeTab)?.content} </div> </div> )}数据仪表盘#
// app/dashboard/page.tsx - Server Componentimport { RefreshButton } from '@/components/RefreshButton'import { DateRangePicker } from '@/components/DateRangePicker'
async function getStats() { return fetch('https://api.example.com/stats', { next: { revalidate: 60 }, }).then((r) => r.json())}
export default async function DashboardPage() { const stats = await getStats()
return ( <div> {/* Client: 日期筛选和刷新 */} <div className="flex justify-between mb-6"> <DateRangePicker /> <RefreshButton /> </div>
{/* Server: 统计卡片 */} <div className="grid grid-cols-4 gap-4 mb-6"> <StatCard title="总用户" value={stats.totalUsers} /> <StatCard title="今日活跃" value={stats.activeToday} /> <StatCard title="总收入" value={`¥${stats.revenue}`} /> <StatCard title="订单数" value={stats.orders} /> </div>
{/* Server: 图表数据 */} <ChartSection data={stats.chartData} /> </div> )}
function StatCard({ title, value }: { title: string; value: string | number }) { return ( <div className="bg-white p-4 rounded-lg shadow"> <p className="text-gray-500 text-sm">{title}</p> <p className="text-2xl font-bold">{value}</p> </div> )}性能优化技巧#
1. 延迟加载 Client Components#
import dynamic from 'next/dynamic'
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), { loading: () => <div className="h-64 bg-gray-100 animate-pulse" />,})
export default function Page() { return <HeavyChart />}2. 条件渲染 Client#
import { Suspense } from 'react'import { Comments } from '@/components/Comments'
export default function Page() { return ( <article> <h1>文章标题</h1> <p>文章内容...</p>
{/* 延迟加载评论 */} <Suspense fallback={<div>加载评论...</div>}> <Comments /> </Suspense> </article> )}常见问题#
🤔 Q: 如何共享 Server 和 Client 之间的代码?
将共享代码放在单独文件,不添加 "use client":
export function formatPrice(price: number) { return `¥${price.toFixed(2)}`}🤔 Q: 什么时候使用 Context vs Server 数据传递?
- 静态数据:Server 获取后通过 props 传递
- 需要实时更新的状态:Context
🤔 Q: 如何处理需要交互的长列表?
将列表项的交互部分提取为 Client Component,列表容器保持 Server。
下一篇将介绍 Streaming 与 Suspense,学习如何实现渐进式页面加载。
-EOF-