第 6 章:Client Components 与浏览器端交互

深入掌握 Next.js Client Components:理解 "use client" 指令的本质、客户端状态管理、与 Server Components 的协作模式、性能优化策略,以及第三方 UI 库集成。

本章是卷 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

“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} />;
}

原因

  • ButtonInputForm(Client Component)导入
  • 它们自动成为 Client Component 的一部分
  • 无需重复声明 "use client"

误解 3:“use client” 会阻止服务端渲染

错误理解

“use client” = 组件只在浏览器执行,不会 SSR

正确理解

“use client” = 组件会 SSR + Hydration

对比

渲染方式Server ComponentClient 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>
  );
}

问题

  • HeaderProductList 不需要交互,但被迫成为 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>
  );
}

优势

  • HeaderProductList 保持为 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

维度useStateuseReducer
复杂度简单状态复杂状态逻辑
状态数量单个值多个相关值
更新逻辑直接更新通过 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 作为 ReactNode
  • LikeButtonPage(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

需求:访问 windowdocumentlocalStoragenavigator

"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(状态和副作用)

需求useStateuseEffectuseRefuseMemouseCallback

"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 ComponentClient 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/ui50+轻量极高现代 Web 应用
MUI100+中等企业级应用
Ant Design60+中等后台管理系统
Chakra UI80+轻量快速开发
Radix UI30+极轻量极高自定义组件

最佳实践:混合使用 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

  1. “use client” 的本质

    • 标记 Client Component 边界
    • 该文件及其导入都成为客户端代码
    • 仍然会 SSR,但需要 Hydration
  2. 客户端状态管理

    • useState:简单状态
    • useReducer:复杂状态逻辑
    • useContext:跨组件共享
    • Zustand/Jotai:全局状态
  3. 协作模式

    • Server Component 包含 Client Component(最常见)
    • Client Component 包含 Server Component(通过 children)
    • 交替嵌套(Interleaving)
  4. 必须使用 Client Component 的场景

    • 事件监听器
    • 浏览器 API
    • React Hooks
    • 第三方库(依赖 window)
    • 类组件
  5. 性能优化策略

    • 将 “use client” 下沉到叶子节点
    • 使用 next/dynamic 动态导入
    • 按功能拆分组件
    • 使用 Suspense 优化加载体验
  6. 第三方 UI 库

    • shadcn/ui:轻量、可定制、支持 Server Components
    • MUI:丰富、企业级
    • Ant Design:中文友好、后台管理

卷 II 总结

核心概念回顾

  1. 渲染策略(第 4 章):

    • CSR、SSR、SSG、ISR、RSC
    • 混合渲染策略
  2. React Server Components(第 5 章):

    • RSC vs SSR
    • 多层缓存系统
    • 流式渲染
  3. Client Components(第 6 章):

    • “use client” 规则
    • 状态管理
    • 协作模式
    • 性能优化

最佳实践

  • 默认使用 Server Component
  • 将 Client Component 下沉到叶子节点
  • 合理使用缓存策略
  • 使用 Suspense 优化加载体验
  • 选择合适的 UI 库

下一步

恭喜!你已经完成了卷 II 的学习。接下来,我们将进入卷 III:路由与导航,学习 Next.js 的路由系统、动态路由、路由组、中间件等高级特性。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页