服务器组件与客户端组件
深入理解 Next.js 的服务器组件和客户端组件,掌握组件组合模式和最佳实践。
概述
Next.js 默认使用 React 服务器组件(Server Components),允许在服务器上获取数据和渲染 UI,可选地缓存结果并流式传输到客户端。当需要交互性或浏览器 API 时,可以使用客户端组件(Client Components)添加功能。
何时使用服务器组件和客户端组件
客户端和服务器环境具有不同的能力。服务器组件和客户端组件允许根据用例在每个环境中运行逻辑。
使用客户端组件的场景
当需要以下功能时使用客户端组件:
- 状态和事件处理器:
onClick、onChange等 - 生命周期逻辑:
useEffect、useLayoutEffect等 - 浏览器专用 API:
localStorage、window、Navigator.geolocation等 - 自定义 Hooks:依赖于状态、效果或浏览器 API 的 Hooks
- React 类组件:使用类组件语法
使用服务器组件的场景
当需要以下功能时使用服务器组件:
- 从数据库或 API 获取数据:靠近数据源
- 使用敏感信息:API 密钥、令牌等,不暴露给客户端
- 减少客户端 JavaScript:减少发送到浏览器的 JavaScript 量
- 改善首次内容绘制(FCP):流式传输内容到客户端
- SEO 和社交媒体爬虫:服务器渲染的内容更易于索引
对比表
| 需求 | 服务器组件 | 客户端组件 |
|---|---|---|
| 获取数据 | ✓ | |
| 访问后端资源 | ✓ | |
| 在服务器上保留敏感信息 | ✓ | |
| 减少客户端 JavaScript | ✓ | |
| 添加交互性和事件监听器 | ✓ | |
| 使用状态和生命周期效果 | ✓ | |
| 使用浏览器专用 API | ✓ | |
| 使用依赖状态、效果或浏览器 API 的自定义 Hooks | ✓ | |
| 使用 React 类组件 | ✓ |
服务器组件
服务器组件在服务器上渲染,可以直接访问后端资源。
基础示例
// app/page.tsx
// 默认是服务器组件
export default async function Page() {
// 直接在组件中获取数据
const data = await fetch('https://api.example.com/data')
const posts = await data.json()
return (
<div>
<h1>博客文章</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}数据获取
服务器组件可以使用任何异步 I/O 获取数据:
// app/blog/page.tsx
import { db } from '@/lib/db'
export default async function BlogPage() {
// 直接访问数据库
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
})
return (
<div>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
)
}使用敏感信息
服务器组件可以安全地使用 API 密钥和其他敏感信息:
// app/api/data/route.ts
export async function GET() {
// API 密钥不会暴露给客户端
const response = await fetch('https://api.example.com/data', {
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
},
})
const data = await response.json()
return Response.json(data)
}客户端组件
客户端组件在客户端渲染,可以使用交互性和浏览器 API。
使用 "use client" 指令
通过在文件顶部添加 "use client" 指令来创建客户端组件。
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>计数:{count}</p>
<button onClick={() => setCount(count + 1)}>增加</button>
</div>
)
}重要提示:
"use client"指令必须在文件顶部,在任何导入之前- 不需要在每个使用客户端功能的文件中添加指令
- 只需在直接从服务器组件导入的组件中添加
- 该指令定义了客户端-服务器边界
状态管理
'use client'
import { useState } from 'react'
export default function TodoList() {
const [todos, setTodos] = useState<string[]>([])
const [input, setInput] = useState('')
const addTodo = () => {
if (input.trim()) {
setTodos([...todos, input])
setInput('')
}
}
return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="添加待办事项"
/>
<button onClick={addTodo}>添加</button>
<ul>
{todos.map((todo, index) => (
<li key={index}>{todo}</li>
))}
</ul>
</div>
)
}使用效果
'use client'
import { useEffect, useState } from 'react'
export default function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 })
useEffect(() => {
function updateSize() {
setSize({
width: window.innerWidth,
height: window.innerHeight,
})
}
window.addEventListener('resize', updateSize)
updateSize()
return () => window.removeEventListener('resize', updateSize)
}, [])
return (
<p>
窗口大小:{size.width} x {size.height}
</p>
)
}使用浏览器 API
'use client'
import { useState, useEffect } from 'react'
export default function Geolocation() {
const [location, setLocation] = useState<{
latitude: number
longitude: number
} | null>(null)
useEffect(() => {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition((position) => {
setLocation({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
})
})
}
}, [])
if (!location) return <p>获取位置中...</p>
return (
<p>
位置:{location.latitude}, {location.longitude}
</p>
)
}组合模式
在服务器组件中嵌套客户端组件
这是最常见的模式:服务器组件处理数据获取,客户端组件处理交互。
// app/page.tsx (服务器组件)
import Header from './header' // 服务器组件
import Counter from './counter' // 客户端组件
export default async function Page() {
const data = await fetchData()
return (
<div>
<Header />
<Counter initialCount={data.count} />
</div>
)
}// app/counter.tsx (客户端组件)
'use client'
import { useState } from 'react'
export default function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount)
return (
<div>
<p>计数:{count}</p>
<button onClick={() => setCount(count + 1)}>增加</button>
</div>
)
}将服务器组件作为 Props 传递给客户端组件
可以将服务器组件作为 children 或其他 props 传递给客户端组件。
// app/client-wrapper.tsx
'use client'
export default function ClientWrapper({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="wrapper">
{children}
</div>
)
}// app/page.tsx
import ClientWrapper from './client-wrapper'
import ServerComponent from './server-component'
export default function Page() {
return (
<ClientWrapper>
<ServerComponent />
</ClientWrapper>
)
}从客户端组件导入服务器组件(不支持)
不能直接在客户端组件中导入服务器组件。
// 不能在客户端组件中导入服务器组件
'use client'
import ServerComponent from './server-component'
export default function ClientComponent() {
return (
<div>
<ServerComponent />
</div>
)
}正确做法: 将服务器组件作为 props 传递
// 将服务器组件作为 children 传递
'use client'
export default function ClientComponent({
children,
}: {
children: React.ReactNode
}) {
return <div>{children}</div>
}第三方组件
第三方组件可能没有 "use client" 指令。可以将它们包装在自己的客户端组件中。
// app/components/carousel-wrapper.tsx
'use client'
import { Carousel } from 'acme-carousel'
export default function CarouselWrapper() {
return <Carousel />
}// app/page.tsx
import CarouselWrapper from './components/carousel-wrapper'
export default function Page() {
return (
<div>
<CarouselWrapper />
</div>
)
}库作者建议:
如果正在构建组件库,在依赖客户端功能的入口点添加 "use client" 指令。这允许用户直接将组件导入服务器组件,而无需创建包装器。
Context Providers
Context providers 通常在应用根部渲染以共享全局状态。由于服务器组件不支持 React context,在根部创建 context 会导致错误。
解决方案: 在客户端组件中创建 context 和 provider。
// app/providers.tsx
'use client'
import { createContext, useContext, useState } from 'react'
const ThemeContext = createContext<{
theme: string
setTheme: (theme: string) => void
}>({
theme: 'light',
setTheme: () => {},
})
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export const useTheme = () => useContext(ThemeContext)// app/layout.tsx
import { ThemeProvider } from './providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}防止环境污染
使用 server-only 和 client-only 包防止服务器代码在客户端运行,反之亦然。
server-only
标记仅服务器代码,防止意外在客户端使用。
npm install server-only// lib/data.ts
import 'server-only'
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}现在,如果在客户端组件中导入 getData,会在构建时报错。
client-only
标记仅客户端代码,防止在服务器上运行。
npm install client-only// lib/client-utils.ts
import 'client-only'
export function getLocalStorage(key: string) {
return localStorage.getItem(key)
}注意:
- 在 Next.js 中,安装
server-only或client-only是可选的 - Next.js 内部处理这些导入以提供更清晰的错误消息
- Next.js 为 TypeScript 配置提供了自己的类型声明
实际应用示例
博客应用
// app/blog/page.tsx (服务器组件)
import { db } from '@/lib/db'
import LikeButton from './like-button'
export default async function BlogPage() {
const posts = await db.post.findMany()
return (
<div>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
))}
</div>
)
}// app/blog/like-button.tsx (客户端组件)
'use client'
import { useState } from 'react'
export default function LikeButton({
postId,
initialLikes,
}: {
postId: string
initialLikes: number
}) {
const [likes, setLikes] = useState(initialLikes)
const [liked, setLiked] = useState(false)
const handleLike = async () => {
if (liked) return
setLikes(likes + 1)
setLiked(true)
await fetch(`/api/posts/${postId}/like`, { method: 'POST' })
}
return (
<button onClick={handleLike} disabled={liked}>
{liked ? '已点赞' : '点赞'} ({likes})
</button>
)
}仪表板应用
// app/dashboard/page.tsx (服务器组件)
import { getUser, getStats } from '@/lib/data'
import Chart from './chart'
import RefreshButton from './refresh-button'
export default async function Dashboard() {
const user = await getUser()
const stats = await getStats()
return (
<div>
<h1>欢迎,{user.name}</h1>
<Chart data={stats} />
<RefreshButton />
</div>
)
}// app/dashboard/chart.tsx (客户端组件)
'use client'
import { useEffect, useRef } from 'react'
export default function Chart({ data }: { data: any[] }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!canvasRef.current) return
const ctx = canvasRef.current.getContext('2d')
// 绘制图表逻辑
}, [data])
return <canvas ref={canvasRef} />
}// app/dashboard/refresh-button.tsx (客户端组件)
'use client'
import { useRouter } from 'next/navigation'
export default function RefreshButton() {
const router = useRouter()
return (
<button onClick={() => router.refresh()}>
刷新数据
</button>
)
}表单应用
// app/contact/page.tsx (服务器组件)
import ContactForm from './contact-form'
export default function ContactPage() {
return (
<div>
<h1>联系我们</h1>
<ContactForm />
</div>
)
}// app/contact/contact-form.tsx (客户端组件)
'use client'
import { useState } from 'react'
export default function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: '',
})
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setStatus('loading')
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
})
if (response.ok) {
setStatus('success')
setFormData({ name: '', email: '', message: '' })
} else {
setStatus('error')
}
} catch (error) {
setStatus('error')
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="姓名"
required
/>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="邮箱"
required
/>
<textarea
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
placeholder="消息"
required
/>
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? '发送中...' : '发送'}
</button>
{status === 'success' && <p>消息已发送!</p>}
{status === 'error' && <p>发送失败,请重试。</p>}
</form>
)
}最佳实践
1. 尽可能使用服务器组件
服务器组件是默认选择,提供更好的性能和 SEO。
// ✓ 默认使用服务器组件
export default async function Page() {
const data = await fetchData()
return <div>{data}</div>
}2. 将客户端组件推向叶子节点
将客户端组件尽可能推向组件树的叶子节点。
// ✓ 推荐:仅交互部分是客户端组件
import SearchBar from './search-bar' // 客户端组件
export default function Page() {
return (
<div>
<h1>产品</h1>
<SearchBar />
{/* 其余内容是服务器组件 */}
</div>
)
}// ✗ 避免:整个页面是客户端组件
'use client'
export default function Page() {
return (
<div>
<h1>产品</h1>
<SearchBar />
</div>
)
}3. 通过 Props 传递服务器组件
利用组合模式将服务器组件作为 props 传递。
'use client'
export default function ClientLayout({
children,
}: {
children: React.ReactNode
}) {
return <div className="layout">{children}</div>
}4. 使用 server-only 保护敏感代码
使用 server-only 包防止服务器代码泄露到客户端。
import 'server-only'
export async function getSecretData() {
// 使用 API 密钥
return fetch(url, {
headers: { Authorization: process.env.API_KEY },
})
}5. 序列化 Props
客户端组件的 props 必须可序列化。
// ✓ 可序列化的 props
<ClientComponent data={{ name: 'John', age: 30 }} />
// ✗ 不可序列化的 props
<ClientComponent onClick={() => {}} /> // 函数
<ClientComponent date={new Date()} /> // Date 对象总结
Next.js 的服务器组件和客户端组件系统提供了强大而灵活的架构:
- 服务器组件:默认选择,用于数据获取、SEO 和减少客户端 JavaScript
- 客户端组件:用于交互性、状态管理和浏览器 API
- 组合模式:在服务器组件中嵌套客户端组件,通过 props 传递服务器组件
- 环境隔离:使用
server-only和client-only防止代码在错误环境中运行 - 性能优化:将客户端组件推向叶子节点,最大化服务器渲染的优势
理解何时使用每种组件类型以及如何组合它们,是构建高性能 Next.js 应用的关键。
上次更新于