本章是卷 II 的收尾章节。读完本章,你将掌握 Client Components 的正确使用方式,理解如何在 Server Components 和 Client Components 之间做出最佳选择。
6.1 “use client” 声明规则与边界划分
什么是 “use client”
"use client" 是一个指令声明,用于标记一个文件及其所有导入为 Client Component。
核心概念:
"use client"标记的是边界,而非单个组件- 一旦文件标记为 Client Component,该文件导入的所有模块都成为客户端代码
"use client"必须放在文件顶部(在 import 之前)
“use client” 的工作原理
边界划分
// app/page.tsx(Server Component,默认)
import Header from './Header';
import Counter from './Counter';
export default function Page() {
return (
<div>
<Header />
<Counter />
</div>
);
}
// app/Counter.tsx(Client Component)
"use client";
import { useState } from 'react';
import Button from './Button'; // ✅ Button 也成为 Client Component
export default function Counter() {
const [count, setCount] = useState(0);
return <Button onClick={() => setCount(c => c + 1)}>Count: {count}</Button>;
}
// app/Button.tsx(自动成为 Client Component)
// 不需要 "use client",因为被 Counter 导入
export default function Button({ children, onClick }) {
return <button onClick={onClick}>{children}</button>;
}
边界规则:
Server Component(page.tsx)
↓
├─ Header(Server Component)
└─ Counter(Client Component,"use client")
└─ Button(Client Component,被 Counter 导入)
导入树的影响
场景:一个文件被多个组件导入。
// app/utils/format.ts
export function formatDate(date: Date) {
return date.toLocaleDateString();
}
// app/PostList.tsx(Server Component)
import { formatDate } from './utils/format';
export default function PostList({ posts }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>{formatDate(post.date)}</li>
))}
</ul>
);
}
// app/DatePicker.tsx(Client Component)
"use client";
import { formatDate } from './utils/format';
export default function DatePicker() {
const [date, setDate] = useState(new Date());
return <div>{formatDate(date)}</div>;
}
结果:
format.ts被 Server Component 和 Client Component 同时导入- Next.js 会自动处理:
- 在 Server Component 中,
format.ts在服务端执行 - 在 Client Component 中,
format.ts被打包到客户端 Bundle
- 在 Server Component 中,
“use client” 的常见误解
误解 1:“use client” 意味着组件在客户端渲染
错误理解:
“use client” = 组件只在浏览器执行
正确理解:
“use client” = 组件可以在浏览器执行,但仍然会参与服务端渲染(SSR)
实际行为:
1. 服务端渲染(SSR):
- Client Component 在服务端执行,生成 HTML
- 用户立即看到内容
2. 客户端激活(Hydration):
- 下载 Client Component 的 JS
- React 接管 HTML,添加事件监听器
- 组件变为可交互
示例:
"use client";
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
渲染流程:
服务端:
1. 执行 Counter(),初始状态 count = 0
2. 生成 HTML:<p>Count: 0</p>
3. 返回 HTML 给浏览器
客户端:
1. 浏览器显示 HTML(用户立即看到 "Count: 0")
2. 下载 Counter 的 JS 代码
3. React Hydration:接管 HTML,添加 onClick 事件
4. 用户点击按钮,count 更新为 1
误解 2:每个 Client Component 都需要 “use client”
错误做法:
// ❌ 错误:Button 不需要 "use client"
"use client";
export default function Button({ children, onClick }) {
return <button onClick={onClick}>{children}</button>;
}
// ❌ 错误:Input 不需要 "use client"
"use client";
export default function Input({ value, onChange }) {
return <input value={value} onChange={onChange} />;
}
正确做法:
// ✅ 正确:只在顶层组件添加 "use client"
"use client";
import Button from './Button';
import Input from './Input';
export default function Form() {
const [value, setValue] = useState('');
return (
<form>
<Input value={value} onChange={(e) => setValue(e.target.value)} />
<Button onClick={() => alert(value)}>Submit</Button>
</form>
);
}
// ✅ 正确:Button 不需要 "use client"
export default function Button({ children, onClick }) {
return <button onClick={onClick}>{children}</button>;
}
// ✅ 正确:Input 不需要 "use client"
export default function Input({ value, onChange }) {
return <input value={value} onChange={onChange} />;
}
原因:
Button和Input被Form(Client Component)导入- 它们自动成为 Client Component 的一部分
- 无需重复声明
"use client"
误解 3:“use client” 会阻止服务端渲染
错误理解:
“use client” = 组件只在浏览器执行,不会 SSR
正确理解:
“use client” = 组件会 SSR + Hydration
对比:
| 渲染方式 | Server Component | Client Component |
|---|---|---|
| 服务端执行 | ✅ 是 | ✅ 是(SSR) |
| 生成 HTML | ✅ 是 | ✅ 是(SSR) |
| 发送到客户端 | ❌ 只发送 RSC Payload | ✅ 发送 JS Bundle |
| Hydration | ❌ 不需要 | ✅ 需要 |
| 可交互 | ❌ 不可交互 | ✅ 可交互 |
“use client” 的最佳位置
原则:将 “use client” 下沉到叶子节点
目标:尽量减少 Client Component 的范围,让更多组件保持为 Server Component。
❌ 不好的做法:
// app/page.tsx
"use client"; // ❌ 整个页面都是 Client Component
import Header from './Header';
import ProductList from './ProductList';
import LikeButton from './LikeButton';
export default function Page() {
return (
<div>
<Header />
<ProductList />
<LikeButton />
</div>
);
}
问题:
Header和ProductList不需要交互,但被迫成为 Client Component- 增加了客户端 JS 体积
- 失去了 Server Component 的优势(直接访问数据库、零客户端 JS)
✅ 好的做法:
// app/page.tsx(Server Component)
import Header from './Header';
import ProductList from './ProductList';
import LikeButton from './LikeButton';
export default async function Page() {
const products = await db.query('SELECT * FROM products');
return (
<div>
<Header />
<ProductList products={products} />
<LikeButton /> {/* ✅ 只有这个需要交互 */}
</div>
);
}
// app/LikeButton.tsx(Client Component)
"use client";
import { useState } from 'react';
export default function LikeButton() {
const [likes, setLikes] = useState(0);
return (
<button onClick={() => setLikes(l => l + 1)}>
Likes: {likes}
</button>
);
}
优势:
Header和ProductList保持为 Server Component- 减少客户端 JS 体积
Page可以直接访问数据库
实战:电商产品详情页
页面结构:
ProductPage
├── Header(静态,Server Component)
├── ProductInfo(展示型,Server Component)
├── ProductPrice(实时数据,Server Component)
├── AddToCartButton(交互,Client Component)
├── ProductReviews(交互,Client Component)
└── RelatedProducts(展示型,Server Component)
实现:
// app/products/[id]/page.tsx(Server Component)
import Header from './Header';
import ProductInfo from './ProductInfo';
import ProductPrice from './ProductPrice';
import AddToCartButton from './AddToCartButton';
import ProductReviews from './ProductReviews';
import RelatedProducts from './RelatedProducts';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.query('SELECT * FROM products WHERE id = ?', [params.id]);
return (
<div>
<Header />
<ProductInfo product={product} />
<ProductPrice productId={params.id} />
<AddToCartButton productId={params.id} />
<ProductReviews productId={params.id} />
<RelatedProducts category={product.category} />
</div>
);
}
// app/products/[id]/AddToCartButton.tsx(Client Component)
"use client";
import { useState } from 'react';
export default function AddToCartButton({ productId }: { productId: string }) {
const [isAdding, setIsAdding] = useState(false);
const handleAdd = async () => {
setIsAdding(true);
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId }),
});
setIsAdding(false);
};
return (
<button onClick={handleAdd} disabled={isAdding}>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
);
}
// app/products/[id]/ProductReviews.tsx(Client Component)
"use client";
import { useState, useEffect } from 'react';
export default function ProductReviews({ productId }: { productId: string }) {
const [reviews, setReviews] = useState([]);
const [newReview, setNewReview] = useState('');
useEffect(() => {
fetch(`/api/reviews?productId=${productId}`)
.then(r => r.json())
.then(setReviews);
}, [productId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const res = await fetch('/api/reviews', {
method: 'POST',
body: JSON.stringify({ productId, content: newReview }),
});
const review = await res.json();
setReviews([...reviews, review]);
setNewReview('');
};
return (
<div>
<h2>Reviews</h2>
<ul>
{reviews.map(review => (
<li key={review.id}>{review.content}</li>
))}
</ul>
<form onSubmit={handleSubmit}>
<textarea
value={newReview}
onChange={(e) => setNewReview(e.target.value)}
/>
<button type="submit">Submit Review</button>
</form>
</div>
);
}
结果:
- 6 个组件中,只有 2 个是 Client Component
- 4 个 Server Component 可以直接访问数据库
- 客户端 JS 体积最小化
6.2 客户端状态管理
useState:基础状态管理
适用场景:简单的本地状态(计数器、表单输入、开关)。
"use client";
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
表单输入示例:
"use client";
import { useState } from 'react';
export default function SearchForm() {
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
alert(`Searching for: ${query}`);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}
useReducer:复杂状态逻辑
适用场景:状态逻辑复杂、多个子值、下一个状态依赖上一个状态。
"use client";
import { useReducer } from 'react';
type State = {
count: number;
step: number;
};
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'setStep'; payload: number }
| { type: 'reset' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'decrement':
return { ...state, count: state.count - state.step };
case 'setStep':
return { ...state, step: action.payload };
case 'reset':
return { count: 0, step: 1 };
default:
return state;
}
}
export default function CounterWithReducer() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
return (
<div>
<p>Count: {state.count} (Step: {state.step})</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'setStep', payload: 5 })}>
Set Step to 5
</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
对比 useState:
| 维度 | useState | useReducer |
|---|---|---|
| 复杂度 | 简单状态 | 复杂状态逻辑 |
| 状态数量 | 单个值 | 多个相关值 |
| 更新逻辑 | 直接更新 | 通过 action 更新 |
| 可测试性 | 一般 | 好(reducer 是纯函数) |
| 适用场景 | 计数器、开关 | 表单、购物车 |
useContext:跨组件共享状态
适用场景:主题、语言、用户信息等全局状态。
"use client";
import { createContext, useContext, useState, ReactNode } from 'react';
// 1. 创建 Context
type ThemeContextType = {
theme: 'light' | 'dark';
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// 2. 创建 Provider
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggleTheme = () => {
setTheme(t => t === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
<div className={theme}>
{children}
</div>
</ThemeContext.Provider>
);
}
// 3. 创建自定义 Hook
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// app/layout.tsx
import { ThemeProvider } from './ThemeProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
// app/ThemeToggle.tsx
"use client";
import { useTheme } from './ThemeProvider';
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Current: {theme}
</button>
);
}
状态管理库对比
| 库 | 特点 | 适用场景 |
|---|---|---|
| useState | 内置、简单 | 本地状态 |
| useReducer | 内置、复杂逻辑 | 复杂本地状态 |
| useContext | 内置、跨组件 | 小型全局状态 |
| Zustand | 轻量、简洁 | 中型全局状态 |
| Jotai | 原子化、细粒度 | 细粒度状态 |
| Redux Toolkit | 强大、复杂 | 大型应用、复杂状态 |
Zustand 示例:
"use client";
import { create } from 'zustand';
type CartStore = {
items: { id: string; quantity: number }[];
addItem: (id: string) => void;
removeItem: (id: string) => void;
clear: () => void;
};
export const useCartStore = create<CartStore>((set) => ({
items: [],
addItem: (id) =>
set((state) => {
const existing = state.items.find(item => item.id === id);
if (existing) {
return {
items: state.items.map(item =>
item.id === id ? { ...item, quantity: item.quantity + 1 } : item
),
};
}
return { items: [...state.items, { id, quantity: 1 }] };
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter(item => item.id !== id),
})),
clear: () => set({ items: [] }),
}));
"use client";
import { useCartStore } from './cartStore';
export default function CartButton({ productId }: { productId: string }) {
const addItem = useCartStore(state => state.addItem);
const items = useCartStore(state => state.items);
const quantity = items.find(item => item.id === productId)?.quantity || 0;
return (
<button onClick={() => addItem(productId)}>
Add to Cart {quantity > 0 && `(${quantity})`}
</button>
);
}
6.3 Server Components 与 Client Components 的协作模式
模式 1:Server Component 包含 Client Component
最常见模式:父组件是 Server Component,子组件是 Client Component。
// app/page.tsx(Server Component)
import LikeButton from './LikeButton';
export default async function Page() {
const posts = await db.query('SELECT * FROM posts');
return (
<div>
<h1>Blog Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
{post.title}
<LikeButton postId={post.id} />
</li>
))}
</ul>
</div>
);
}
// app/LikeButton.tsx(Client Component)
"use client";
import { useState } from 'react';
export default function LikeButton({ postId }: { postId: string }) {
const [likes, setLikes] = useState(0);
return (
<button onClick={() => setLikes(l => l + 1)}>
Likes: {likes}
</button>
);
}
数据流:
Server Component(Page)
↓ 获取数据(数据库)
↓ 传递 props(可序列化)
Client Component(LikeButton)
↓ 接收 props
↓ 管理本地状态
↓ 处理用户交互
模式 2:Client Component 包含 Server Component(通过 children)
场景:交互式容器包含展示型内容。
// app/page.tsx(Server Component)
import Modal from './Modal';
import PostContent from './PostContent';
export default function Page() {
return (
<Modal>
<PostContent /> {/* ✅ 通过 children 传递 */}
</Modal>
);
}
// app/Modal.tsx(Client Component)
"use client";
import { useState, ReactNode } from 'react';
export default function Modal({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
{isOpen && (
<div className="modal-overlay">
<div className="modal-content">
{children}
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
</div>
)}
</div>
);
}
// app/PostContent.tsx(Server Component)
export default async function PostContent() {
const post = await db.query('SELECT * FROM posts LIMIT 1');
return (
<article>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
);
}
关键点:
Modal是 Client Component(需要交互)PostContent是 Server Component(通过children传入)PostContent在服务端执行,不发送 JS 到客户端
模式 3:交替嵌套(Interleaving)
场景:Server Component → Client Component → Server Component。
// app/page.tsx(Server Component)
import Layout from './Layout';
import UserProfile from './UserProfile';
export default async function Page() {
const user = await db.query('SELECT * FROM users LIMIT 1');
return (
<Layout>
<UserProfile user={user} />
</Layout>
);
}
// app/Layout.tsx(Client Component)
"use client";
import { useState, ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="flex">
{sidebarOpen && (
<aside className="w-64 bg-gray-100">
<nav>Sidebar</nav>
</aside>
)}
<main className="flex-1">
<button onClick={() => setSidebarOpen(o => !o)}>
Toggle Sidebar
</button>
{children}
</main>
</div>
);
}
// app/UserProfile.tsx(Server Component)
export default async function UserProfile({ user }: { user: any }) {
const posts = await db.query(
'SELECT * FROM posts WHERE author_id = ?',
[user.id]
);
return (
<div>
<h1>{user.name}</h1>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
渲染流程:
服务端:
1. 执行 Page(Server Component)
2. 执行 UserProfile(Server Component)
3. 生成 RSC Payload(包含 Layout 的占位符)
客户端:
1. 渲染 DOM
2. 下载 Layout 的 JS(Client Component)
3. Hydration Layout
模式 4:通过 Props 传递 Client Component
场景:Server Component 传递 Client Component 给另一个 Client Component。
// app/page.tsx(Server Component)
import Card from './Card';
import LikeButton from './LikeButton';
export default async function Page() {
const post = await db.query('SELECT * FROM posts LIMIT 1');
return (
<Card action={<LikeButton postId={post.id} />}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</Card>
);
}
// app/Card.tsx(Client Component)
"use client";
import { ReactNode } from 'react';
export default function Card({
children,
action,
}: {
children: ReactNode;
action: ReactNode;
}) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="card">
{isExpanded ? children : <p>Click to expand</p>}
<div className="card-footer">
{action}
<button onClick={() => setIsExpanded(e => !e)}>
{isExpanded ? 'Collapse' : 'Expand'}
</button>
</div>
</div>
);
}
关键点:
Card接收action作为 ReactNodeLikeButton在Page(Server Component)中创建LikeButton仍然是 Client Component
6.4 必须使用客户端组件的场景
场景 1:事件监听器
需求:按钮点击、表单提交、鼠标移动。
"use client";
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
Server Component 的限制:
// ❌ 错误:Server Component 不能使用 onClick
function Button() {
return <button onClick={() => alert('Clicked!')}>Click me</button>;
}
// 报错:Event handlers are not allowed in Server Components
场景 2:浏览器 API
需求:访问 window、document、localStorage、navigator。
"use client";
import { useEffect, useState } from 'react';
export default function ThemeToggle() {
const [theme, setTheme] = useState('light');
useEffect(() => {
// 访问 localStorage
const stored = localStorage.getItem('theme');
if (stored) setTheme(stored);
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<button onClick={toggleTheme}>
Current: {theme}
</button>
);
}
Server Component 的限制:
// ❌ 错误:Server Component 不能访问 localStorage
function ThemeProvider() {
const theme = localStorage.getItem('theme'); // ❌ 报错
return <div className={theme}>...</div>;
}
// 报错:localStorage is not defined
场景 3:React Hooks(状态和副作用)
需求:useState、useEffect、useRef、useMemo、useCallback。
"use client";
import { useState, useEffect } from 'react';
export default function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Server Component 的限制:
// ❌ 错误:Server Component 不能使用 useState
function Counter() {
const [count, setCount] = useState(0); // ❌ 报错
return <div>Count: {count}</div>;
}
// 报错:useState is not allowed in Server Components
场景 4:第三方库(依赖客户端)
需求:图表库、地图库、动画库。
"use client";
import { useEffect, useRef } from 'react';
import Chart from 'chart.js';
export default function SalesChart({ data }: { data: any }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (canvasRef.current) {
new Chart(canvasRef.current, {
type: 'line',
data: data,
});
}
}, [data]);
return <canvas ref={canvasRef} />;
}
Server Component 的限制:
// ❌ 错误:Server Component 不能使用 Chart.js
function SalesChart({ data }) {
const chart = new Chart(...); // ❌ 报错
return <canvas />;
}
// 报错:window is not defined
场景 5:类组件
需求:使用 React 类组件。
"use client";
import { Component } from 'react';
export default class ErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
Server Component 的限制:
// ❌ 错误:Server Component 不能使用类组件
class MyComponent extends Component { // ❌ 报错
render() {
return <div>Hello</div>;
}
}
// 报错:Class components are not allowed in Server Components
决策表:何时使用 Client Component
| 场景 | Server Component | Client Component |
|---|---|---|
| 获取数据 | ✅ 推荐(直接访问数据库) | ✅ 可以(fetch API) |
| 访问后端资源 | ✅ 推荐 | ❌ 不可以 |
| 事件监听器 | ❌ 不可以 | ✅ 必须 |
| 浏览器 API | ❌ 不可以 | ✅ 必须 |
| useState / useEffect | ❌ 不可以 | ✅ 必须 |
| 第三方库(依赖 window) | ❌ 不可以 | ✅ 必须 |
| 类组件 | ❌ 不可以 | ✅ 必须 |
6.5 重量组件拆分策略
问题:客户端 JS 体积过大
场景:一个页面包含多个大型库。
"use client";
import ReactMarkdown from 'react-markdown'; // 100KB
import { format } from 'date-fns'; // 50KB
import Chart from 'chart.js'; // 200KB
import { useState } from 'react';
export default function BlogPost({ post }) {
const [showChart, setShowChart] = useState(false);
return (
<article>
<h1>{post.title}</h1>
<p>{format(post.date, 'yyyy-MM-dd')}</p>
<ReactMarkdown>{post.content}</ReactMarkdown>
{showChart && <Chart data={post.analytics} />}
<button onClick={() => setShowChart(true)}>Show Analytics</button>
</article>
);
}
问题:
- 客户端 JS 体积:350KB(gzip 后约 100KB)
- 即使用户不点击 “Show Analytics”,也要下载 Chart.js
- ReactMarkdown 和 date-fns 只在服务端需要,但也发送到客户端
策略 1:将展示型部分提取为 Server Component
// app/BlogPost.tsx(Server Component)
import ReactMarkdown from 'react-markdown';
import { format } from 'date-fns';
import AnalyticsChart from './AnalyticsChart';
export default function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<p>{format(post.date, 'yyyy-MM-dd')}</p>
<ReactMarkdown>{post.content}</ReactMarkdown>
<AnalyticsChart data={post.analytics} />
</article>
);
}
// app/AnalyticsChart.tsx(Client Component)
"use client";
import { useState } from 'react';
import dynamic from 'next/dynamic';
// 动态导入 Chart.js
const Chart = dynamic(() => import('chart.js'), {
ssr: false,
loading: () => <p>Loading chart...</p>,
});
export default function AnalyticsChart({ data }) {
const [showChart, setShowChart] = useState(false);
return (
<div>
{showChart ? (
<Chart data={data} />
) : (
<button onClick={() => setShowChart(true)}>
Show Analytics
</button>
)}
</div>
);
}
结果:
- ReactMarkdown(100KB):服务端执行,不发送到客户端
- date-fns(50KB):服务端执行,不发送到客户端
- Chart.js(200KB):动态导入,只在点击时下载
- 初始 JS 体积:从 350KB 减少到 0KB
策略 2:使用 next/dynamic 动态导入
基础用法:
"use client";
import dynamic from 'next/dynamic';
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
loading: () => <p>Loading...</p>,
});
export default function Page() {
return (
<div>
<h1>Page</h1>
<HeavyComponent />
</div>
);
}
禁用 SSR(适用于依赖 window 的组件):
"use client";
import dynamic from 'next/dynamic';
const Map = dynamic(() => import('./Map'), {
ssr: false, // 不在服务端渲染
loading: () => <p>Loading map...</p>,
});
export default function Page() {
return <Map />;
}
按需加载(只在需要时导入):
"use client";
import { useState } from 'react';
import dynamic from 'next/dynamic';
const Modal = dynamic(() => import('./Modal'));
export default function Page() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => setShowModal(true)}>Open Modal</button>
{showModal && <Modal onClose={() => setShowModal(false)} />}
</div>
);
}
策略 3:按功能拆分组件
❌ 不好的做法:一个大型 Client Component
"use client";
import ReactMarkdown from 'react-markdown';
import Chart from 'chart.js';
import { useState, useEffect } from 'react';
export default function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/analytics').then(r => r.json()).then(setData);
}, []);
return (
<div>
<header>Dashboard</header>
<ReactMarkdown>{data?.report}</ReactMarkdown>
<Chart data={data?.chart} />
<button onClick={() => alert('Export')}>Export</button>
</div>
);
}
✅ 好的做法:拆分为多个小组件
// app/Dashboard.tsx(Server Component)
import DashboardHeader from './DashboardHeader';
import DashboardReport from './DashboardReport';
import DashboardChart from './DashboardChart';
import ExportButton from './ExportButton';
export default async function Dashboard() {
const data = await fetch('https://api.example.com/analytics').then(r => r.json());
return (
<div>
<DashboardHeader />
<DashboardReport report={data.report} />
<DashboardChart chartData={data.chart} />
<ExportButton />
</div>
);
}
// app/DashboardHeader.tsx(Server Component)
export default function DashboardHeader() {
return <header>Dashboard</header>;
}
// app/DashboardReport.tsx(Server Component)
import ReactMarkdown from 'react-markdown';
export default function DashboardReport({ report }: { report: string }) {
return <ReactMarkdown>{report}</ReactMarkdown>;
}
// app/DashboardChart.tsx(Client Component)
"use client";
import { useState } from 'react';
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('chart.js'), {
ssr: false,
loading: () => <p>Loading chart...</p>,
});
export default function DashboardChart({ chartData }: { chartData: any }) {
const [showChart, setShowChart] = useState(false);
return (
<div>
{showChart ? (
<Chart data={chartData} />
) : (
<button onClick={() => setShowChart(true)}>Show Chart</button>
)}
</div>
);
}
// app/ExportButton.tsx(Client Component)
"use client";
export default function ExportButton() {
return <button onClick={() => alert('Export')}>Export</button>;
}
结果:
- DashboardHeader:Server Component(0KB)
- DashboardReport:Server Component(ReactMarkdown 不发送到客户端)
- DashboardChart:Client Component(动态导入 Chart.js)
- ExportButton:Client Component(轻量)
策略 4:使用 Suspense 优化加载体验
"use client";
import { Suspense } from 'react';
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
});
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
</div>
);
}
function ChartSkeleton() {
return (
<div className="animate-pulse">
<div className="h-64 bg-gray-200 rounded"></div>
</div>
);
}
6.6 与第三方 UI 库集成
shadcn/ui:现代化的组件库
特点:
- 不是传统 npm 包,而是复制组件代码到项目
- 完全可定制,无黑盒
- 基于 Radix UI 和 Tailwind CSS
- 支持 Server Components
安装:
npx shadcn-ui@latest init
使用示例:
npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add dialog
// app/page.tsx(Server Component)
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
export default function Page() {
return (
<Card>
<CardHeader>Welcome</CardHeader>
<CardContent>
<Button>Click me</Button>
</CardContent>
</Card>
);
}
交互组件需要 Client Component:
// app/DialogDemo.tsx(Client Component)
"use client";
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
export default function DialogDemo() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>
This is a dialog description.
</DialogDescription>
</DialogHeader>
<p>Dialog content goes here.</p>
</DialogContent>
</Dialog>
);
}
优势:
- 组件代码在项目内,完全可控
- 支持 Server Components(展示型组件)
- 轻量(只导入使用的组件)
Material-UI (MUI):企业级组件库
特点:
- 丰富的组件(100+)
- 完善的文档和示例
- 主题系统强大
- 需要 Client Component
安装:
npm install @mui/material @emotion/react @emotion/styled
配置 Next.js:
// app/theme.tsx
"use client";
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
primary: {
main: '#1976d2',
},
},
});
export default theme;
// app/ThemeRegistry.tsx
"use client";
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import theme from './theme';
export default function ThemeRegistry({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);
}
// app/layout.tsx
import ThemeRegistry from './ThemeRegistry';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ThemeRegistry>
{children}
</ThemeRegistry>
</body>
</html>
);
}
使用示例:
// app/page.tsx(Client Component)
"use client";
import { useState } from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
export default function Page() {
const [value, setValue] = useState('');
return (
<Card>
<CardContent>
<TextField
label="Name"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<Button variant="contained">Submit</Button>
</CardContent>
</Card>
);
}
注意事项:
- MUI 组件都是 Client Components
- 需要 ThemeRegistry 包裹
- 客户端 JS 体积较大(约 100KB+)
Ant Design:企业级 UI 设计语言
特点:
- 丰富的组件(60+)
- 中文文档完善
- 适合后台管理系统
- 需要 Client Component
安装:
npm install antd
使用示例:
// app/page.tsx(Client Component)
"use client";
import { useState } from 'react';
import { Button, Input, Card, message } from 'antd';
export default function Page() {
const [value, setValue] = useState('');
const handleSubmit = () => {
message.success(`Submitted: ${value}`);
};
return (
<Card title="Form">
<Input
placeholder="Enter your name"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<Button type="primary" onClick={handleSubmit}>
Submit
</Button>
</Card>
);
}
按需加载(减少 JS 体积):
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ['antd', '@ant-design/icons'],
},
};
UI 库对比
| 库 | 组件数量 | JS 体积 | 可定制性 | 适用场景 |
|---|---|---|---|---|
| shadcn/ui | 50+ | 轻量 | 极高 | 现代 Web 应用 |
| MUI | 100+ | 中等 | 高 | 企业级应用 |
| Ant Design | 60+ | 中等 | 高 | 后台管理系统 |
| Chakra UI | 80+ | 轻量 | 高 | 快速开发 |
| Radix UI | 30+ | 极轻量 | 极高 | 自定义组件 |
最佳实践:混合使用 Server/Client Components
策略:展示型组件使用 Server Component,交互型组件使用 Client Component。
// app/page.tsx(Server Component)
import { Card, CardHeader, CardContent } from '@/components/ui/card';
import LikeButton from './LikeButton';
export default async function Page() {
const posts = await db.query('SELECT * FROM posts');
return (
<div className="grid grid-cols-3 gap-4">
{posts.map(post => (
<Card key={post.id}>
<CardHeader>{post.title}</CardHeader>
<CardContent>
<p>{post.content}</p>
<LikeButton postId={post.id} />
</CardContent>
</Card>
))}
</div>
);
}
// app/LikeButton.tsx(Client Component)
"use client";
import { useState } from 'react';
import { Button } from '@/components/ui/button';
export default function LikeButton({ postId }: { postId: string }) {
const [likes, setLikes] = useState(0);
return (
<Button onClick={() => setLikes(l => l + 1)}>
Likes: {likes}
</Button>
);
}
结果:
- Card、CardHeader、CardContent:Server Component(展示型)
- LikeButton:Client Component(交互型)
- 最小化客户端 JS 体积
本章小结
Key Takeaways
“use client” 的本质:
- 标记 Client Component 边界
- 该文件及其导入都成为客户端代码
- 仍然会 SSR,但需要 Hydration
客户端状态管理:
- useState:简单状态
- useReducer:复杂状态逻辑
- useContext:跨组件共享
- Zustand/Jotai:全局状态
协作模式:
- Server Component 包含 Client Component(最常见)
- Client Component 包含 Server Component(通过 children)
- 交替嵌套(Interleaving)
必须使用 Client Component 的场景:
- 事件监听器
- 浏览器 API
- React Hooks
- 第三方库(依赖 window)
- 类组件
性能优化策略:
- 将 “use client” 下沉到叶子节点
- 使用 next/dynamic 动态导入
- 按功能拆分组件
- 使用 Suspense 优化加载体验
第三方 UI 库:
- shadcn/ui:轻量、可定制、支持 Server Components
- MUI:丰富、企业级
- Ant Design:中文友好、后台管理
卷 II 总结
核心概念回顾:
渲染策略(第 4 章):
- CSR、SSR、SSG、ISR、RSC
- 混合渲染策略
React Server Components(第 5 章):
- RSC vs SSR
- 多层缓存系统
- 流式渲染
Client Components(第 6 章):
- “use client” 规则
- 状态管理
- 协作模式
- 性能优化
最佳实践:
- 默认使用 Server Component
- 将 Client Component 下沉到叶子节点
- 合理使用缓存策略
- 使用 Suspense 优化加载体验
- 选择合适的 UI 库
下一步
恭喜!你已经完成了卷 II 的学习。接下来,我们将进入卷 III:路由与导航,学习 Next.js 的路由系统、动态路由、路由组、中间件等高级特性。
参考资料
- Next.js 官方文档:Client Components
- React 官方文档:useState
- React 官方文档:useReducer
- React 官方文档:useContext
- shadcn/ui 官方文档
- MUI 官方文档
- Ant Design 官方文档
- Zustand GitHub
- Jotai GitHub
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。