Skip to content

混合渲染模式

🙋 一个页面既需要快速加载,又需要丰富交互。如何合理划分 Server 和 Client 组件的边界?

组件类型决策#

决策流程图#

需要 useState/useEffect?──是──→ Client Component
需要事件处理(onClick 等)?──是──→ Client Component
需要浏览器 API?──是──→ Client Component
使用仅客户端库?──是──→ Client Component
Server Component ✅

快速参考表#

场景ServerClient
获取数据可以
访问后端资源
敏感信息
使用 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 Component
import { 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 Component
import { 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 Component
function 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 Component
import { 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) - 相似推荐
app/products/[id]/page.tsx
// Next.js 15.x
import { 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 Components
function 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 Component
import { 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#

app/page.tsx
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"

lib/utils.ts
export function formatPrice(price: number) {
return `¥${price.toFixed(2)}`
}

🤔 Q: 什么时候使用 Context vs Server 数据传递?

🤔 Q: 如何处理需要交互的长列表?

将列表项的交互部分提取为 Client Component,列表容器保持 Server。


下一篇将介绍 Streaming 与 Suspense,学习如何实现渐进式页面加载。

-EOF-