Next.js 客户端渲染 (CSR) 🖥️
🎯 概述
客户端渲染(Client-Side Rendering,CSR)是在浏览器中使用 JavaScript 渲染页面内容的方式。Next.js 支持通过客户端组件实现 CSR,适用于需要交互性和实时更新的场景。
📦 什么是 CSR?
CSR 工作原理
- 服务器返回最小的 HTML
- 浏览器下载 JavaScript
- JavaScript 在客户端执行
- 动态渲染页面内容
- 处理用户交互
CSR 适用场景
- 🎮 高度交互的应用
- 📊 实时数据更新
- 🔐 需要用户认证的内容
- 💬 聊天和消息应用
- 🎨 复杂的用户界面
🚀 客户端组件
'use client' 指令
tsx
// components/Counter.tsx
'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>
)
}使用 React Hooks
tsx
'use client'
import { useState, useEffect } from 'react'
export default function UserProfile() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => {
setUser(data)
setLoading(false)
})
}, [])
if (loading) return <div>加载中...</div>
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}📝 数据获取
使用 useEffect
tsx
'use client'
import { useState, useEffect } from 'react'
export default function Posts() {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('https://api.example.com/posts')
.then(res => {
if (!res.ok) throw new Error('获取失败')
return res.json()
})
.then(data => {
setPosts(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [])
if (loading) return <div>加载中...</div>
if (error) return <div>错误: {error}</div>
return (
<div>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
)
}使用 SWR
tsx
'use client'
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then(res => res.json())
export default function Posts() {
const { data, error, isLoading } = useSWR(
'https://api.example.com/posts',
fetcher
)
if (isLoading) return <div>加载中...</div>
if (error) return <div>加载失败</div>
return (
<div>
{data.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
)
}使用 React Query
tsx
'use client'
import { useQuery } from '@tanstack/react-query'
export default function Posts() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
})
if (isLoading) return <div>加载中...</div>
if (error) return <div>错误: {error.message}</div>
return (
<div>
{data.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
)
}🎨 事件处理
基本事件
tsx
'use client'
export default function Form() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log('表单提交')
}
const handleClick = () => {
console.log('按钮点击')
}
return (
<form onSubmit={handleSubmit}>
<input type="text" />
<button type="submit">提交</button>
<button type="button" onClick={handleClick}>
取消
</button>
</form>
)
}表单处理
tsx
'use client'
import { useState } from 'react'
export default function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
})
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
if (res.ok) {
alert('发送成功')
setFormData({ name: '', email: '', message: '' })
}
}
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={formData.name}
onChange={handleChange}
placeholder="姓名"
/>
<input
name="email"
value={formData.email}
onChange={handleChange}
placeholder="邮箱"
/>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
placeholder="消息"
/>
<button type="submit">发送</button>
</form>
)
}🔄 状态管理
本地状态
tsx
'use client'
import { useState } from 'react'
export default function TodoList() {
const [todos, setTodos] = useState([])
const [input, setInput] = useState('')
const addTodo = () => {
if (input.trim()) {
setTodos([...todos, { id: Date.now(), text: input }])
setInput('')
}
}
const removeTodo = (id: number) => {
setTodos(todos.filter(todo => todo.id !== id))
}
return (
<div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
/>
<button onClick={addTodo}>添加</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.text}
<button onClick={() => removeTodo(todo.id)}>删除</button>
</li>
))}
</ul>
</div>
)
}Context API
tsx
'use client'
import { createContext, useContext, useState } from 'react'
const ThemeContext = createContext(null)
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
return useContext(ThemeContext)
}
// 使用
export default function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
切换到 {theme === 'light' ? '暗色' : '亮色'} 模式
</button>
)
}📊 实战示例
实时搜索
tsx
'use client'
import { useState, useEffect } from 'react'
import { useDebounce } from '@/hooks/useDebounce'
export default function Search() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const debouncedQuery = useDebounce(query, 500)
useEffect(() => {
if (debouncedQuery) {
setLoading(true)
fetch(`/api/search?q=${debouncedQuery}`)
.then(res => res.json())
.then(data => {
setResults(data)
setLoading(false)
})
} else {
setResults([])
}
}, [debouncedQuery])
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索..."
/>
{loading && <div>搜索中...</div>}
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</div>
)
}无限滚动
tsx
'use client'
import { useState, useEffect, useRef } from 'react'
export default function InfiniteScroll() {
const [items, setItems] = useState([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const observerRef = useRef(null)
useEffect(() => {
loadMore()
}, [page])
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
setPage(prev => prev + 1)
}
},
{ threshold: 1.0 }
)
if (observerRef.current) {
observer.observe(observerRef.current)
}
return () => observer.disconnect()
}, [hasMore, loading])
const loadMore = async () => {
setLoading(true)
const res = await fetch(`/api/items?page=${page}`)
const data = await res.json()
setItems(prev => [...prev, ...data.items])
setHasMore(data.hasMore)
setLoading(false)
}
return (
<div>
{items.map(item => (
<div key={item.id}>{item.title}</div>
))}
{loading && <div>加载中...</div>}
<div ref={observerRef} />
</div>
)
}实时聊天
tsx
'use client'
import { useState, useEffect, useRef } from 'react'
export default function Chat() {
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const wsRef = useRef(null)
useEffect(() => {
// 连接 WebSocket
wsRef.current = new WebSocket('ws://localhost:3001')
wsRef.current.onmessage = (event) => {
const message = JSON.parse(event.data)
setMessages(prev => [...prev, message])
}
return () => {
wsRef.current?.close()
}
}, [])
const sendMessage = () => {
if (input.trim() && wsRef.current) {
wsRef.current.send(JSON.stringify({
text: input,
timestamp: Date.now()
}))
setInput('')
}
}
return (
<div>
<div className="messages">
{messages.map((msg, i) => (
<div key={i}>{msg.text}</div>
))}
</div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
/>
<button onClick={sendMessage}>发送</button>
</div>
)
}📚 最佳实践
1. 混合使用服务端和客户端组件
tsx
// app/page.tsx (服务端组件)
import ClientComponent from './ClientComponent'
async function getData() {
const res = await fetch('https://api.example.com/data')
return res.json()
}
export default async function Page() {
const data = await getData()
return (
<div>
<h1>{data.title}</h1>
<ClientComponent initialData={data} />
</div>
)
}
// ClientComponent.tsx (客户端组件)
'use client'
export default function ClientComponent({ initialData }) {
const [data, setData] = useState(initialData)
return <div>{/* 交互式内容 */}</div>
}2. 优化性能
tsx
'use client'
import { memo, useMemo, useCallback } from 'react'
const ExpensiveComponent = memo(({ data }) => {
const processedData = useMemo(() => {
return data.map(item => /* 复杂计算 */)
}, [data])
const handleClick = useCallback(() => {
// 处理点击
}, [])
return <div onClick={handleClick}>{/* ... */}</div>
})3. 错误边界
tsx
'use client'
import { Component } from 'react'
class ErrorBoundary extends Component {
state = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return <div>出错了</div>
}
return this.props.children
}
}🔗 相关资源
下一步:学习 Next.js 流式渲染。