mcpField
R
react-data-fetching

react-data-fetching

github.com/patternsdev开发2571.1k 次安装28 周前更新
代码仓库

关于 react-data-fetching

面向 React 开发者的数据获取模式指南,适用于多种应用框架。内容涵盖缓存、请求去重和同步策略,帮助改善加载流程。

主要功能

  1. 01并行执行独立请求
  2. 02使用 TanStack Query 或 SWR
  3. 03通过 Suspense 协调加载
  4. 04导航前预取数据
  5. 05去重服务端渲染请求
  6. 06实现乐观更新

适用场景

  1. 01替换 useEffect 中的 fetch
  2. 02减少 React 视图中的请求瀑布
  3. 03缓存并去重共享数据请求
  4. 04改进加载与数据变更反馈

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/patternsdev/skills --skill react-data-fetching

安装后在 Claude Code 中输入/react-data-fetching确认已加载。

详细介绍

React 数据获取模式

目录

适用于生产环境的 React 应用服务器端数据获取、缓存和同步模式。这些模式与框架无关——无论你使用 Vite + React Router、Next.js、Remix 还是自定义配置,都可以使用。

适用场景

在以下情况下参考这些模式:

  • 为组件添加数据获取功能
  • 将 useEffect + fetch 替换为完善的数据层
  • 实现缓存、请求去重或乐观更新
  • 调试瀑布式加载模式
  • 选择数据获取库

说明

  • 在代码生成、审查和重构时应用这些模式。如果发现没有缓存或请求去重的 effect 内 fetch,请推荐合适的模式。

详情

概述

React 应用中最常见的性能问题是请求瀑布——本可并行运行的请求却按顺序执行。第二常见的问题是冗余请求——多个组件各自获取相同的数据。以下模式可解决这两类问题,并从影响最大的优化开始。


1. 使用 Promise.all 并行执行彼此独立的请求

影响:严重——消除顺序瀑布,可提升 2–10 倍性能。

当多个请求之间没有依赖关系时,应并发执行。

避免——顺序执行(3 次往返):

async function loadDashboard() {
  const user = await fetchUser()
  const posts = await fetchPosts()
  const notifications = await fetchNotifications()
  return { user, posts, notifications }
}

推荐——并行执行(1 次往返):

async function loadDashboard() {
  const [user, posts, notifications] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchNotifications(),
  ])
  return { user, posts, notifications }
}

如果请求之间存在部分依赖关系(B 依赖 A,但 C 不依赖 A),应立即启动独立的操作:

async function loadPage() {
  const userPromise = fetchUser()
  const configPromise = fetchConfig()

  const user = await userPromise
  const [config, posts] = await Promise.all([
    configPromise,
    fetchPosts(user.id), // depends on user
  ])
  return { user, config, posts }
}

2. 等到需要使用值时再执行 Await

影响:高——尽早启动操作,不必阻塞等待暂时用不到的结果。

一个常见错误是立即对每个 promise 执行 await,即使后续代码暂时不需要其结果。应尽早启动 promise,然后在实际读取其值时再执行 await。

避免——造成不必要的阻塞:

async function loadProfile(userId: string) {
  const user = await fetchUser(userId)       // waits here
  const prefs = await fetchPreferences()     // starts only after user resolves
  const avatar = buildAvatarUrl(user.avatar)
  return { user, prefs, avatar }
}

推荐——尽早启动,稍后等待:

async function loadProfile(userId: string) {
  const userPromise = fetchUser(userId)      // starts immediately
  const prefsPromise = fetchPreferences()    // starts immediately

  const user = await userPromise             // await when needed
  const avatar = buildAvatarUrl(user.avatar)
  const prefs = await prefsPromise           // may already be resolved

  return { user, prefs, avatar }
}

这与 Promise.all 互为补充:需要在请求之间获取中间结果时,使用延迟 await;可以一次性等待所有结果时,使用 Promise.all。


3. 使用 TanStack Query 获取客户端数据

影响:严重——自动缓存、请求去重、重新验证和错误处理。

原生的 useEffect + fetch 缺少缓存、请求去重、重试和后台刷新功能。请使用数据获取库。

避免——没有缓存、没有请求去重、没有错误处理:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    setLoading(true)
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUser)
      .finally(() => setLoading(false))
  }, [userId])

  if (loading) return <Skeleton />
  return <div>{user?.name}</div>
}

推荐——TanStack Query(推荐用于 Vite + React 应用):

import { useQuery } from '@tanstack/react-query'

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  })

  if (isLoading) return <Skeleton />
  return <div>{user?.name}</div>
}

TanStack Query 是 Vite 应用的首选——它与框架无关,内置 useSuspenseQuery、开发工具、无限查询、乐观变更和离线支持。SWR 是更轻量的替代方案,涵盖基本功能(请求去重、缓存、重新验证),但处理复杂变更工作流的功能较少。

两者都提供:请求去重、过期后重新验证缓存、自动重试和后台刷新。

Vite 应用的设置:

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000, // 1 minute
      retry: 2,
    },
  },
})

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
)

4. 使用 Suspense 声明式管理加载状态

影响:高——代码更简洁、自动协调加载,并支持流式传输。

Suspense 允许你在组件树中声明加载边界,而不必在每个组件中管理 isLoading 状态。

避免——手动协调加载:

function Dashboard() {
  const { data: user, isLoading: userLoading } = useQuery(userQuery)
  const { data: stats, isLoading: statsLoading } = useQuery(statsQuery)

  if (userLoading || statsLoading) return <FullPageSpinner />
  return (
    <div>
      <UserHeader user={user} />
      <StatsPanel stats={stats} />
    </div>
  )
}

推荐——Suspense 边界:

function Dashboard() {
  return (
    <Suspense fallback={<FullPageSpinner />}>
      <DashboardContent />
    </Suspense>
  )
}

function DashboardContent() {
  const { data: user } = useSuspenseQuery(userQuery)
  const { data: stats } = useSuspenseQuery(statsQuery)
  return (
    <div>
      <UserHeader user={user} />
      <StatsPanel stats={stats} />
    </div>
  )
}

对于彼此独立的部分,应使用单独的 Suspense 边界,以便它们各自加载:

function Dashboard() {
  return (
    <div>
      <Suspense fallback={<HeaderSkeleton />}>
        <UserHeader />
      </Suspense>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />
      </Suspense>
    </div>
  )
}

TanStack Query 提供 useSuspenseQuery,SWR 则提供 { suspense: true } 选项。


5. 在导航前预取数据

影响:高——消除页面切换时的加载状态。

在用户确认导航前开始获取数据——例如在悬停、聚焦或路由预加载时。

使用 TanStack Query:

import { useQueryClient } from '@tanstack/react-query'

function ProjectLink({ projectId }: { projectId: string }) {
  const queryClient = useQueryClient()

  const prefetch = () => {
    queryClient.prefetchQuery({
      queryKey: ['project', projectId],
      queryFn: () => fetchProject(projectId),
      staleTime: 30_000,
    })
  }

  return (
    <Link
      to={`/projects/${projectId}`}
      onMouseEnter={prefetch}
      onFocus={prefetch}
    >
      View Project
    </Link>
  )
}

使用 React Router 加载器(Vite 应用):

// routes.tsx
const routes = [
  {
    path: '/projects/:id',
    loader: ({ params }) => queryClient.ensureQueryData({
      queryKey: ['project', params.id],
      queryFn: () => fetchProject(params.id!),
    }),
    Component: ProjectPage,
  },
]

6. 使用 React.cache() 在服务端去重

影响:中等 — 在单次服务端渲染期间对开销较大的操作去重。

在服务端组件(RSC)中,React.cache() 可确保多个组件发起的相同异步调用在每个请求中只执行一次。

import { cache } from 'react'

export const getSession = cache(async () => {
  const session = await auth()
  if (!session?.user?.id) return null
  return session
})

export const getUser = cache(async (userId: string) => {
  return await db.user.findUnique({ where: { id: userId } })
})

同一次渲染中的多个组件调用 getSession() 时,会共享一次执行。

重要: 缓存键应使用基本类型参数(字符串、数字)。内联对象会创建新的引用,导致缓存未命中:

// Cache miss every time — new object reference
getUser({ id: '123' })
getUser({ id: '123' }) // miss

// Cache hit — same string value
getUser('123')
getUser('123') // hit

7. 实现乐观更新,即时反馈

影响:高 — 无需等待服务端响应,UI 即可立即更新。

对于结果可预测的变更(例如切换点赞状态、更新名称),应立即更新 UI,再根据服务端响应进行协调。

使用 TanStack Query:

import { useMutation, useQueryClient } from '@tanstack/react-query'

function LikeButton({ postId }: { postId: string }) {
  const queryClient = useQueryClient()

  const { mutate: toggleLike } = useMutation({
    mutationFn: () => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
    onMutate: async () => {
      await queryClient.cancelQueries({ queryKey: ['post', postId] })
      const previous = queryClient.getQueryData<Post>(['post', postId])
      queryClient.setQueryData<Post>(['post', postId], old => ({
        ...old!,
        liked: !old!.liked,
        likeCount: old!.liked ? old!.likeCount - 1 : old!.likeCount + 1,
      }))
      return { previous }
    },
    onError: (_err, _vars, context) => {
      queryClient.setQueryData(['post', postId], context?.previous)
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['post', postId] })
    },
  })

  return <button onClick={() => toggleLike()}>Like</button>
}

8. 避免组件树中的请求瀑布

影响:严重 — 先请求父组件数据、再请求子组件数据是首要的性能问题。

如果父组件先请求数据,而子组件又基于父组件的结果请求自己的数据,就会形成请求瀑布。应调整结构,以便并行请求。

避免 — 子组件必须等父组件完成后才能开始请求:

function UserPage({ userId }: { userId: string }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  if (!user) return <Skeleton />
  return <UserPosts userId={user.id} /> // starts fetching only after user loads
}

function UserPosts({ userId }: { userId: string }) {
  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchPosts(userId),
  })
  // ...
}

推荐 — 在同一层级同时请求:

function UserPage({ userId }: { userId: string }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })
  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchPosts(userId),
  })

  if (!user) return <Skeleton />
  return (
    <div>
      <UserHeader user={user} />
      <PostList posts={posts ?? []} />
    </div>
  )
}

也可以使用路由级加载器,在组件渲染前获取所有数据。


9. 对全局事件监听器去重

影响:中等 — 避免 N 个组件实例创建 N 个监听器。

当多个组件实例需要监听相同的全局事件(缩放、滚动、联网状态)时,应共享单个监听器。

// hooks/useOnlineStatus.ts
import { useSyncExternalStore } from 'react'

function subscribe(callback: () => void) {
  window.addEventListener('online', callback)
  window.addEventListener('offline', callback)
  return () => {
    window.removeEventListener('online', callback)
    window.removeEventListener('offline', callback)
  }
}

function getSnapshot() {
  return navigator.onLine
}

export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, getSnapshot, () => true)
}

useSyncExternalStore 会自动对订阅去重,并确保并发渲染期间的状态一致。


10. 为滚动和触摸事件使用被动监听器

影响:低至中等 — 避免监听器阻塞滚动,造成卡顿。

非被动的滚动/触摸监听器会阻塞浏览器的合成线程。如果不调用 preventDefault(),应将监听器标记为被动。

避免 — 会阻塞滚动:

useEffect(() => {
  const handler = () => trackScroll(window.scrollY)
  window.addEventListener('scroll', handler)
  return () => window.removeEventListener('scroll', handler)
}, [])

推荐 — 不会阻塞:

useEffect(() => {
  const handler = () => trackScroll(window.scrollY)
  window.addEventListener('scroll', handler, { passive: true })
  return () => window.removeEventListener('scroll', handler)
}, [])

11. 为客户端存储设置模式版本

影响:低至中等 — 避免过期的 localStorage 数据导致崩溃。

从 localStorage 或 sessionStorage 读取数据时,先前应用版本留下的过期数据可能导致应用崩溃。应添加模式版本并进行验证。

避免 — 模式变更时会崩溃:

const [prefs, setPrefs] = useState(() => {
  return JSON.parse(localStorage.getItem('prefs') || '{}')
})

推荐 — 添加版本并提供回退方案:

const PREFS_VERSION = 2

const [prefs, setPrefs] = useState<Prefs>(() => {
  try {
    const raw = localStorage.getItem('prefs')
    if (!raw) return DEFAULT_PREFS
    const parsed = JSON.parse(raw)
    if (parsed._v !== PREFS_VERSION) return DEFAULT_PREFS
    return parsed
  } catch {
    return DEFAULT_PREFS
  }
})

// On save, include version
useEffect(() => {
  localStorage.setItem('prefs', JSON.stringify({ ...prefs, _v: PREFS_VERSION }))
}, [prefs])

来源

相关模式来自 patterns.dev——面向更广泛的 Web 工程社区、与框架无关的 React 数据获取指南。

评论

支持 Markdown