Next.js 面试题详细答案 - Q11
Q11: 如何在 Client Component 中获取数据?有哪些最佳实践?(使用 SWR, TanStack Query 或在 useEffect 中调用 API Route)
Client Component 数据获取方法
1. 使用 useEffect 获取数据
'use client'
import { useState, useEffect } from 'react'
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function fetchUser() {
try {
setLoading(true)
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
throw new Error('Failed to fetch user')
}
const userData = await response.json()
setUser(userData)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchUser()
}, [userId])
if (loading) return <div>加载中...</div>
if (error) return <div>错误: {error}</div>
if (!user) return <div>用户未找到</div>
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
export default UserProfile
2. 使用 SWR 获取数据
'use client'
import useSWR from 'swr'
// 数据获取函数
const fetcher = (url) => fetch(url).then((res) => res.json())
function BlogPost({ slug }) {
const { data, error, isLoading } = useSWR(`/api/posts/${slug}`, fetcher)
if (isLoading) return <div>加载中...</div>
if (error) return <div>加载失败</div>
if (!data) return <div>文章未找到</div>
return (
<article>
<h1>{data.title}</h1>
<div>{data.content}</div>
</article>
)
}
// 带重新验证的 SWR
function PostList() {
const { data, error, isLoading, mutate } = useSWR('/api/posts', fetcher, {
revalidateOnFocus: true,
revalidateOnReconnect: true,
refreshInterval: 30000, // 30秒自动刷新
})
const handleRefresh = () => {
mutate() // 手动重新验证
}
if (isLoading) return <div>加载中...</div>
if (error) return <div>加载失败</div>
return (
<div>
<button onClick={handleRefresh}>刷新</button>
<ul>
{data?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}
3. 使用 TanStack Query 获取数据
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
function UserDashboard({ userId }) {
const queryClient = useQueryClient()
// 获取用户数据
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((res) => res.json()),
staleTime: 5 * 60 * 1000, // 5分钟
})
// 获取用户文章
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetch(`/api/users/${userId}/posts`).then((res) => res.json()),
enabled: !!userId, // 只有 userId 存在时才执行
})
// 更新用户信息
const updateUserMutation = useMutation({
mutationFn: (userData) =>
fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
}).then((res) => res.json()),
onSuccess: () => {
// 更新成功后重新获取数据
queryClient.invalidateQueries({ queryKey: ['user', userId] })
},
})
if (isLoading) return <div>加载中...</div>
if (error) return <div>加载失败</div>
return (
<div>
<h1>{user?.name}</h1>
<p>{user?.email}</p>
<button
onClick={() => updateUserMutation.mutate({ name: 'New Name' })}
disabled={updateUserMutation.isPending}
>
{updateUserMutation.isPending ? '更新中...' : '更新姓名'}
</button>
<h2>文章列表</h2>
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}
最佳实践
1. 错误处理和加载状态
'use client'
import { useState, useEffect } from 'react'
function DataComponent() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
async function fetchData() {
try {
setLoading(true)
setError(null)
const response = await fetch('/api/data')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
if (!cancelled) {
setData(result)
}
} catch (err) {
if (!cancelled) {
setError(err.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
fetchData()
// 清理函数,防止内存泄漏
return () => {
cancelled = true
}
}, [])
if (loading) return <div className="loading">加载中...</div>
if (error) return <div className="error">错误: {error}</div>
if (!data) return <div>暂无数据</div>
return <div>{data}</div>
}
2. 自定义 Hook 封装
// hooks/useApi.js
import { useState, useEffect } from 'react'
function useApi(url, options = {}) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
async function fetchData() {
try {
setLoading(true)
setError(null)
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
if (!cancelled) {
setData(result)
}
} catch (err) {
if (!cancelled) {
setError(err.message)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
fetchData()
return () => {
cancelled = true
}
}, [url, JSON.stringify(options)])
return { data, loading, error }
}
// 使用自定义 Hook
function UserList() {
const { data: users, loading, error } = useApi('/api/users')
if (loading) return <div>加载中...</div>
if (error) return <div>错误: {error}</div>
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
3. 数据缓存和优化
'use client'
import { useState, useEffect, useCallback } from 'react'
// 简单的内存缓存
const cache = new Map()
function useCachedApi(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const fetchData = useCallback(async () => {
// 检查缓存
if (cache.has(url)) {
const cachedData = cache.get(url)
setData(cachedData.data)
setLoading(false)
// 检查缓存是否过期
if (Date.now() - cachedData.timestamp < 300000) { // 5分钟
return
}
}
try {
setLoading(true)
setError(null)
const response = await fetch(url)
const result = await response.json()
// 更新缓存
cache.set(url, {
data: result,
timestamp: Date.now(),
})
setData(result)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [url])
useEffect(() => {
fetchData()
}, [fetchData])
return { data, loading, error, refetch: fetchData }
}
function CachedComponent() {
const { data, loading, error, refetch } = useCachedApi('/api/data')
if (loading) return <div>加载中...</div>
if (error) return <div>错误: {error}</div>
return (
<div>
<button onClick={refetch}>刷新</button>
<div>{data}</div>
</div>
)
}
4. 分页和无限滚动
'use client'
import { useState, useEffect, useCallback } from 'react'
function InfiniteScrollList() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [page, setPage] = useState(1)
const loadMore = useCallback(async () => {
if (loading || !hasMore) return
setLoading(true)
try {
const response = await fetch(`/api/items?page=${page}`)
const newItems = await response.json()
if (newItems.length === 0) {
setHasMore(false)
} else {
setItems(prev => [...prev, ...newItems])
setPage(prev => prev + 1)
}
} catch (error) {
console.error('Failed to load more items:', error)
} finally {
setLoading(false)
}
}, [page, loading, hasMore])
useEffect(() => {
loadMore()
}, [])
// 滚动到底部时加载更多
useEffect(() => {
const handleScroll = () => {
if (
window.innerHeight + document.documentElement.scrollTop >=
document.documentElement.offsetHeight - 1000
) {
loadMore()
}
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [loadMore])
return (
<div>
{items.map((item) => (
<div key={item.id}>{item.title}</div>
))}
{loading && <div>加载中...</div>}
{!hasMore && <div>没有更多内容了</div>}
</div>
)
}
5. 乐观更新
'use client'
import { useState, useEffect } from 'react'
function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes)
const [isLiked, setIsLiked] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
const handleLike = async () => {
// 乐观更新
const newLiked = !isLiked
const newLikes = newLiked ? likes + 1 : likes - 1
setIsLiked(newLiked)
setLikes(newLikes)
setIsUpdating(true)
try {
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ liked: newLiked }),
})
if (!response.ok) {
throw new Error('Failed to update like')
}
} catch (error) {
// 回滚乐观更新
setIsLiked(!newLiked)
setLikes(likes)
console.error('Failed to update like:', error)
} finally {
setIsUpdating(false)
}
}
return (
<button
onClick={handleLike}
disabled={isUpdating}
className={isLiked ? 'liked' : ''}
>
{isLiked ? '❤️' : '🤍'} {likes}
</button>
)
}
性能优化
1. 防抖和节流
'use client'
import { useState, useEffect, useCallback } from 'react'
function SearchInput() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
// 防抖搜索
const debouncedSearch = useCallback(
debounce(async (searchQuery) => {
if (!searchQuery.trim()) {
setResults([])
return
}
setLoading(true)
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
const data = await response.json()
setResults(data)
} catch (error) {
console.error('Search failed:', error)
} finally {
setLoading(false)
}
}, 300),
[]
)
useEffect(() => {
debouncedSearch(query)
}, [query, debouncedSearch])
return (
<div>
<input
type="text"
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>
)
}
// 防抖函数
function debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
2. 数据预加载
'use client'
import { useState, useEffect } from 'react'
function ProductList() {
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchProducts() {
const response = await fetch('/api/products')
const data = await response.json()
setProducts(data)
setLoading(false)
}
fetchProducts()
}, [])
// 预加载产品详情
const preloadProduct = (productId) => {
fetch(`/api/products/${productId}`)
}
if (loading) return <div>加载中...</div>
return (
<div>
{products.map((product) => (
<div
key={product.id}
onMouseEnter={() => preloadProduct(product.id)}
>
<h3>{product.name}</h3>
<p>{product.description}</p>
</div>
))}
</div>
)
}
总结
Client Component 数据获取的最佳实践:
- 选择合适的库:SWR 或 TanStack Query 提供更好的缓存和状态管理
- 错误处理:始终处理加载状态和错误情况
- 性能优化:使用防抖、节流、缓存等技术
- 用户体验:乐观更新、预加载、无限滚动等
- 代码复用:自定义 Hook 封装数据获取逻辑
- 内存管理:清理副作用,防止内存泄漏
这些实践能够提供更好的用户体验和代码维护性。