🙋 需要用户交互、使用 React Hooks 或访问浏览器 API?这时候就需要 Client Components。
什么是 Client Components#
Client Components 在服务端预渲染后,会在浏览器中”水合”(hydrate),获得交互能力。
声明 Client Component#
使用 "use client" 指令:
// Next.js 15.x'use client'
import { useState } from 'react'
export default function Counter() { const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>点击次数: {count}</button>}🔶 注意:"use client" 必须在文件最顶部,在任何 import 之前。
使用场景#
1. 状态管理#
// Next.js 15.x'use client'
import { useState } from 'react'
export default function Toggle() { const [isOn, setIsOn] = useState(false)
return ( <button onClick={() => setIsOn(!isOn)} className={`px-4 py-2 rounded ${isOn ? 'bg-green-500' : 'bg-gray-300'}`} > {isOn ? '开启' : '关闭'} </button> )}2. 事件处理#
// Next.js 15.x'use client'
import { useState, FormEvent } from 'react'
export default function ContactForm() { const [name, setName] = useState('') const [email, setEmail] = useState('') const [loading, setLoading] = useState(false)
const handleSubmit = async (e: FormEvent) => { e.preventDefault() setLoading(true)
await fetch('/api/contact', { method: 'POST', body: JSON.stringify({ name, email }), })
setLoading(false) alert('提交成功!') }
return ( <form onSubmit={handleSubmit} className="space-y-4"> <input value={name} onChange={(e) => setName(e.target.value)} placeholder="姓名" className="border rounded px-3 py-2 w-full" /> <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="邮箱" type="email" className="border rounded px-3 py-2 w-full" /> <button type="submit" disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50" > {loading ? '提交中...' : '提交'} </button> </form> )}3. 副作用#
// Next.js 15.x'use client'
import { useEffect } from 'react'import { usePathname, useSearchParams } from 'next/navigation'
export default function Analytics() { const pathname = usePathname() const searchParams = useSearchParams()
useEffect(() => { const url = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
// 发送页面访问统计 console.log('Page view:', url) // analytics.pageView(url) }, [pathname, searchParams])
return null}4. 浏览器 API#
// Next.js 15.x'use client'
import { useEffect, useState } from 'react'
export default function ThemeToggle() { const [theme, setTheme] = useState<'light' | 'dark'>('light')
useEffect(() => { // 从 localStorage 读取主题 const saved = localStorage.getItem('theme') as 'light' | 'dark' if (saved) { setTheme(saved) } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { setTheme('dark') } }, [])
useEffect(() => { // 更新 DOM 和 localStorage document.documentElement.classList.toggle('dark', theme === 'dark') localStorage.setItem('theme', theme) }, [theme])
return ( <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}> {theme === 'light' ? '🌙' : '☀️'} </button> )}5. 第三方交互库#
// Next.js 15.x'use client'
import { useEffect, useRef } from 'react'import mapboxgl from 'mapbox-gl'import 'mapbox-gl/dist/mapbox-gl.css'
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN!
interface MapProps { center: [number, number] zoom?: number}
export default function Map({ center, zoom = 10 }: MapProps) { const mapContainer = useRef<HTMLDivElement>(null)
useEffect(() => { if (!mapContainer.current) return
const map = new mapboxgl.Map({ container: mapContainer.current, style: 'mapbox://styles/mapbox/streets-v12', center, zoom, })
return () => map.remove() }, [center, zoom])
return <div ref={mapContainer} className="w-full h-96" />}与 Server Components 协作#
接收服务端数据#
// app/products/page.tsx - Server Component// Next.js 15.ximport { ProductFilter } from '@/components/ProductFilter'
async function getProducts() { const res = await fetch('https://api.example.com/products') return res.json()}
async function getCategories() { const res = await fetch('https://api.example.com/categories') return res.json()}
export default async function ProductsPage() { const [products, categories] = await Promise.all([ getProducts(), getCategories(), ])
return <ProductFilter initialProducts={products} categories={categories} />}// components/ProductFilter.tsx - Client Component// Next.js 15.x'use client'
import { useState, useMemo } from 'react'
interface Product { id: string name: string category: string price: number}
interface Category { id: string name: string}
interface Props { initialProducts: Product[] categories: Category[]}
export function ProductFilter({ initialProducts, categories }: Props) { const [selectedCategory, setSelectedCategory] = useState<string | null>(null) const [sortBy, setSortBy] = useState<'name' | 'price'>('name')
const filteredProducts = useMemo(() => { let result = initialProducts
if (selectedCategory) { result = result.filter((p) => p.category === selectedCategory) }
return result.sort((a, b) => { if (sortBy === 'price') return a.price - b.price return a.name.localeCompare(b.name) }) }, [initialProducts, selectedCategory, sortBy])
return ( <div> <div className="flex gap-4 mb-4"> <select value={selectedCategory || ''} onChange={(e) => setSelectedCategory(e.target.value || null)} > <option value="">全部分类</option> {categories.map((cat) => ( <option key={cat.id} value={cat.id}> {cat.name} </option> ))} </select>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as 'name' | 'price')} > <option value="name">按名称</option> <option value="price">按价格</option> </select> </div>
<ul className="space-y-2"> {filteredProducts.map((product) => ( <li key={product.id}> {product.name} - ¥{product.price} </li> ))} </ul> </div> )}children 模式#
将 Server Components 作为 children 传入 Client Component:
// app/page.tsx - Server Componentimport { Tabs } from '@/components/Tabs'import { ServerContent } from '@/components/ServerContent'
export default function Page() { return ( <Tabs> {/* Server Component 作为 children */} <ServerContent /> </Tabs> )}// components/Tabs.tsx - Client Component'use client'
import { useState, ReactNode } from 'react'
export function Tabs({ children }: { children: ReactNode }) { const [activeTab, setActiveTab] = useState(0)
return ( <div> <div className="flex gap-2"> <button onClick={() => setActiveTab(0)}>Tab 1</button> <button onClick={() => setActiveTab(1)}>Tab 2</button> </div> <div className={activeTab === 0 ? '' : 'hidden'}>{children}</div> </div> )}预渲染行为#
Client Components 也会在服务端预渲染:
'use client'
import { useState, useEffect } from 'react'
export default function ClientComponent() { const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
// 服务端渲染时 mounted = false // 客户端水合后 mounted = true if (!mounted) { return <div>加载中...</div> }
return <div>已挂载</div>}处理水合不匹配#
'use client'
import { useState, useEffect } from 'react'
export default function Time() { const [time, setTime] = useState<string | null>(null)
useEffect(() => { // 只在客户端执行 setTime(new Date().toLocaleTimeString())
const interval = setInterval(() => { setTime(new Date().toLocaleTimeString()) }, 1000)
return () => clearInterval(interval) }, [])
// 避免水合不匹配 if (!time) { return <span>--:--:--</span> }
return <span>{time}</span>}边界和导入规则#
”use client” 边界#
"use client" 声明一个边界,该文件及其导入的所有模块都成为 Client Bundle 的一部分:
ServerComponent.tsx ↓ 导入ClientComponent.tsx ("use client") ↓ 导入ChildComponent.tsx ← 自动成为 Client Component ↓ 导入utilities.ts ← 也被打包到客户端不能在 Client Component 中导入 Server Component#
// ❌ 错误'use client'
import { ServerComponent } from './ServerComponent'
export default function ClientComponent() { return <ServerComponent /> // 不行!}
// ✅ 正确:通过 children 传入;('use client')
export default function ClientComponent({ children,}: { children: React.ReactNode}) { return <div>{children}</div>}常见模式#
Context Provider#
'use client'
import { ThemeProvider } from 'next-themes'import { QueryClient, QueryClientProvider } from '@tanstack/react-query'import { useState, ReactNode } from 'react'
export function Providers({ children }: { children: ReactNode }) { const [queryClient] = useState(() => new QueryClient())
return ( <QueryClientProvider client={queryClient}> <ThemeProvider attribute="class">{children}</ThemeProvider> </QueryClientProvider> )}// app/layout.tsx - Server Componentimport { Providers } from '@/components/Providers'
export default function RootLayout({ children,}: { children: React.ReactNode}) { return ( <html> <body> <Providers>{children}</Providers> </body> </html> )}动态导入#
import dynamic from 'next/dynamic'
// 禁用 SSR 的动态导入const Map = dynamic(() => import('@/components/Map'), { ssr: false, loading: () => <div className="h-96 bg-gray-200 animate-pulse" />,})
export default function Page() { return <Map center={[116.4, 39.9]} />}常见问题#
🤔 Q: 为什么我的组件报错说不能使用 useState?
忘记添加 "use client" 指令了。
🤔 Q: Client Component 会影响 SEO 吗?
不会。Client Components 首先在服务端预渲染为 HTML,对爬虫友好。
🤔 Q: 如何在 Client Component 中获取环境变量?
只能访问 NEXT_PUBLIC_ 前缀的环境变量:
const apiUrl = process.env.NEXT_PUBLIC_API_URL下一篇将介绍混合渲染模式,学习如何合理组合 Server 和 Client Components。
-EOF-