Skip to content

Client Components

🙋 需要用户交互、使用 React Hooks 或访问浏览器 API?这时候就需要 Client Components。

什么是 Client Components#

Client Components 在服务端预渲染后,会在浏览器中”水合”(hydrate),获得交互能力。

声明 Client Component#

使用 "use client" 指令:

components/Counter.tsx
// 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. 状态管理#

components/Toggle.tsx
// 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. 事件处理#

components/Form.tsx
// 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. 副作用#

components/Analytics.tsx
// 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#

components/ThemeToggle.tsx
// 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. 第三方交互库#

components/Map.tsx
// 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.x
import { 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 Component
import { 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 也会在服务端预渲染:

components/ClientComponent.tsx
'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>
}

处理水合不匹配#

components/Time.tsx
'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#

components/Providers.tsx
'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 Component
import { Providers } from '@/components/Providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}

动态导入#

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