Q11: 如何在 Client Component 中获取数据?有哪些最佳实践?(使用 SWR, TanStack Query 或在 useEffect 中调用

70 阅读3分钟

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 数据获取的最佳实践:

  1. 选择合适的库:SWR 或 TanStack Query 提供更好的缓存和状态管理
  2. 错误处理:始终处理加载状态和错误情况
  3. 性能优化:使用防抖、节流、缓存等技术
  4. 用户体验:乐观更新、预加载、无限滚动等
  5. 代码复用:自定义 Hook 封装数据获取逻辑
  6. 内存管理:清理副作用,防止内存泄漏

这些实践能够提供更好的用户体验和代码维护性。