#Next.js#React

React 数据获取:从 fetch 到 React Query

useEffect+fetch 看着简单,坑极多(竞态/重复请求/无缓存/无重试)。本文讲清 React 数据获取的演进:useEffect(反模式)→ React Query/SWR(服务端状态库)→ Server Components(RSC 服务端获取),各自该用在哪,以及 RSC 和 RQ 不是替代是互补。

本文要点

  • 主题聚焦:Next.js、React
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年3月18日·更新于 2026年7月23日

React 数据获取:从 fetch 到 React Query

很多人写 React 的第一段数据获取代码长这样:useEffect 里 fetch,然后 setState。它能跑,甚至看起来很简洁。但只要你把它推到生产环境,就会在某个深夜被它咬一口——竞态请求、重复请求、无缓存、无重试、loading 状态闪烁、内存泄漏警告……这些问题不是你写得不够仔细,而是 useEffect + fetch 这个组合本身就是个反模式。

这篇文章讲清 React 数据获取的演进路线:从手写的 useEffect,到 React Query/SWR 这类服务端状态库,再到 Server Components 的服务端获取。最后我会给出一个我实战中最常用的组合模式。

一、useEffect + fetch:一个看着简单的反模式

先看教科书式的写法:

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

  useEffect(() => {
    let cancelled = false
    setLoading(true)
    fetch(`/api/users/${userId}`)
      .then((res) => {
        if (!res.ok) throw new Error('Failed')
        return res.json()
      })
      .then((data) => {
        if (!cancelled) {
          setUser(data)
          setError(null)
        }
      })
      .catch((err) => {
        if (!cancelled) setError(err)
      })
      .finally(() => {
        if (!cancelled) setLoading(false)
      })

    return () => {
      cancelled = true
    }
  }, [userId])

  if (loading) return <Spinner />
  if (error) return <ErrorView />
  return <UserCard user={user} />
}

为了"写对"这一段,我已经写了 30 行。但它仍然有不少坑:

  • 竞态条件(race condition):userId 连续从 1 变到 2,如果请求 2 比请求 1 先返回呢?那个 cancelled 标志位就是为了防这个——但很多人根本不写,结果就是 UI 显示了"旧请求"的数据。
  • 重复请求:同组件在多处挂载,或者用户在列表页来回切换,每次都重新发一遍。没有任何缓存。
  • 无重试:网络抖动一次就 error 了,用户只能手动刷新。
  • 无陈旧刷新:数据已经躺在屏幕上一小时了,后台早就变了,UI 毫无感知。
  • 窗口聚焦不更新:用户切到别的 Tab 再切回来,数据是"旧的"。
  • 手动管理 loading/error:三种状态(loading / error / success)在每个组件里都要手写一遍,样板代码爆炸。

问题的根源在于:useEffect 是用来同步副作用到外部世界的逃生舱,不是用来获取数据的。React 官方文档现在也明确不推荐这种写法。你需要的是一个专门处理"服务端状态"的抽象层。

二、React Query / SWR:服务端状态库

React Query(现在叫 TanStack Query)和 SWR 解决的正是上述问题。它们的核心定位是:你的客户端状态(state)和服务端状态(server state)是两种东西,不该用同一套 useState 管理。

服务端状态有几个特点:它不归你拥有(归远端数据库)、它随时可能变(陈旧)、它需要异步获取、它需要缓存和去重。用 useState 管这些,等于在重新发明一个残缺的缓存层。

换成 React Query,上面 30 行变成这样:

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

  if (isLoading) return <Spinner />
  if (error) return <ErrorView />
  return <UserCard user={user} />
}

一行 useQuery,你免费拿到了:

  • 自动缓存 + 请求去重(同一个 query key 并发只发一次)
  • 自动重试(失败默认重试 3 次)
  • 陈旧刷新(staleTime 过后视为陈旧,窗口聚焦/重新挂载时自动重取)
  • loading / error / success 状态全套
  • 内存泄漏防护(组件卸载自动清理)

有人算过,这一行 useQuery 等价于你手写大约 50 行等价逻辑。这叫用对抽象。

三、RQ 的核心概念:query key、staleTime、invalidate

要用好 RQ,先搞懂三个概念。

Query Key(查询键) 是缓存的唯一标识。它是个数组,可以嵌套:

tsx
queryKey: ['users']                // 所有用户
queryKey: ['users', { page: 2 }]   // 第 2 页用户
queryKey: ['user', userId]         // 某个用户

RQ 会对 key 做序列化比较,['user', 1] 和 ['user', '1'] 是不同的缓存。把所有影响请求的参数都塞进 key,这是它"依赖追踪"的方式——不用你手写依赖数组。

staleTime(陈旧时间) 控制数据多久算"陈旧"。默认是 0,意思是一拿到就陈旧——所以默认每次窗口聚焦、重新挂载都会重取(虽然先返回缓存,体验上是即时的)。如果你数据更新不频繁,设个 60_000(1 分钟)能大幅减少无谓请求。

refetchOnWindowFocus(窗口聚焦重取) 默认开启。听起来像 bug,其实是产品经理梦想中的功能——用户切回来,数据自动是最新的。不喜欢就关掉。

invalidateQueries(失效查询) 是写操作后刷新数据的关键。改了用户名,你得告诉 RQ"这个缓存废了,重新取":

tsx
queryClient.invalidateQueries({ queryKey: ['user', userId] })

它会让匹配的 query 标记为陈旧并后台重取。注意它是"模式匹配"的——invalidateQueries({ queryKey: ['users'] }) 会失效所有以 users 开头的查询,非常适合"新建了一个用户,列表和详情都刷新"这种场景。

四、mutation:写操作与乐观更新

读用 useQuery,写用 useMutation。一个 mutation 长这样:

tsx
const mutation = useMutation({
  mutationFn: (newName: string) =>
    fetch(`/api/users/${userId}`, {
      method: 'PATCH',
      body: JSON.stringify({ name: newName }),
    }),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['user', userId] })
  },
})

调用 mutation.mutate('Alice') 触发。成功后失效缓存,RQ 自动重取最新数据。这是最稳妥的模式。

但有时你想让 UI 更"快"——点了保存立刻看到新名字,不等服务器确认。这就是乐观更新(optimistic update):先改 UI,再确认,失败就回滚:

tsx
const mutation = useMutation({
  mutationFn: (newName: string) =>
    fetch(`/api/users/${userId}`, {
      method: 'PATCH',
      body: JSON.stringify({ name: newName }),
    }),
  onMutate: async (newName) => {
    // 取消正在进行的查询,避免覆盖我们的乐观值
    await queryClient.cancelQueries({ queryKey: ['user', userId] })
    // 快照旧值,用于回滚
    const prev = queryClient.getQueryData<User>(['user', userId])
    // 乐观写入新值
    queryClient.setQueryData<User>(['user', userId], (old) =>
      old ? { ...old, name: newName } : old,
    )
    return { prev }
  },
  onError: (_err, _newName, context) => {
    // 失败,回滚到旧值
    queryClient.setQueryData(['user', userId], context?.prev)
  },
  onSettled: () => {
    // 无论成败,后台重取拿服务端真实状态
    queryClient.invalidateQueries({ queryKey: ['user', userId] })
  },
})

乐观更新的核心就是 onMutate(改前快照 + 写乐观值)→ onError(回滚)→ onSettled(重取对账)。这套路写熟了,你的"保存"按钮会快得像本地操作。

五、Server Components:服务端获取

到了 React Server Components(RSC),数据获取又变了。它直接在服务端 await:

tsx
// app/users/[id]/page.tsx
export default async function UserProfile({
  params,
}: {
  params: { id: string }
}) {
  const user = await fetch(`https://api.example.com/users/${params.id}`, {
    next: { revalidate: 60, tags: ['user'] },
  }).then((r) => r.json())

  return <UserCard user={user} />
}

这段代码整个在服务端跑,发到浏览器的只有渲染好的 HTML,fetch 逻辑和它依赖的库零客户端 JS 成本。顺带,因为是服务端渲染,SEO 天然友好,首屏也不用闪 loading 了。

它特别适合"首屏数据":博客正文、商品详情、列表第一页——这些不需要交互触发、又是 SEO 重点的内容。

六、RSC vs RQ:不是替代,是互补

很多人(包括以前的我)有个误区:有了 RSC 是不是就不用 React Query 了?不是。它俩解决的是不同问题。

  • RSC 负责首屏、服务端数据:在服务端取、服务端渲染,零 JS 成本。但它只在服务端渲染那一刻取一次,用户交互之后呢?它不会自己刷新。
  • RQ 负责客户端动态数据:用户 mutate 之后刷新、切回 Tab 自动更新、跨页面缓存共享——这些 RSC 做不了。

举个具体场景:一个商品详情页,首屏用 RSC 取商品信息(快、SEO 好);用户点了"加入收藏"后,收藏按钮的状态、购物车角标需要更新——这部分交给客户端的 RQ mutation + invalidate。一个负责"静态的首屏",一个负责"动态的交互后",各司其职。

七、实战模式:首屏 RSC + 交互 RQ + mutation

这是我在 Next.js 项目里最常用的组合拳,直接抄:

tsx
// app/posts/page.tsx —— Server Component,首屏服务端取
import { getPosts } from '@/lib/api'
import { PostsList } from '@/components/PostsList'

export default async function Page() {
  const initialPosts = await getPosts() // 服务端取,零客户端 JS
  return <PostsList initialPosts={initialPosts} />
}
tsx
// components/PostsList.tsx —— Client Component,交互后用 RQ
'use client'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

export function PostsList({ initialPosts }: { initialPosts: Post[] }) {
  const queryClient = useQueryClient()

  // 用 initialData 把首屏数据"喂"给 RQ,避免二次请求
  const { data: posts } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    initialData: initialPosts,
    staleTime: 60_000,
  })

  // 写用 mutation + invalidate
  const mutation = useMutation({
    mutationFn: (title: string) => createPost(title),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] })
    },
  })

  return (
    <>
      <button onClick={() => mutation.mutate('新文章')}>发文</button>
      {posts?.map((p) => <article key={p.id}>{p.title}</article>)}
    </>
  )
}

这个模式的好处:首屏快(服务端取,无 loading 闪烁)、交互顺(RQ 接管后续,自动缓存和刷新)、写操作清晰(mutation + invalidate 一套打完)。initialData 是连接 RSC 和 RQ 的桥梁——RSC 把首屏数据递给 RQ 当缓存起点,RQ 接着干活。

八、缓存策略:按数据新鲜度调

RQ 的默认行为很"激进":staleTime = 0,意味着数据一到手就陈旧,窗口聚焦就重取。这对很多应用来说请求量太大了。我通常这样调:

  • 高频变化(消息列表、通知):staleTime: 0,保持默认,勤更新。
  • 中频(用户资料、订单列表):staleTime: 30_000 ~ 60_000,30 秒到 1 分钟内信任缓存。
  • 低频(配置、字典数据、静态内容):staleTime: Infinity(或几小时),基本不重取。
  • gcTime(旧缓存保留时间,原 cacheTime):默认 5 分钟。一个 query 的所有 observer 都卸载后,缓存还会留 5 分钟,5 分钟内重新挂载就直接命中。低频数据想长留就调大。

调缓存的核心原则:信任数据的时长,应该和数据本身的真实变化频率匹配。不要让一个几乎不变的字典表每 30 秒重取一次,也不要让一个实时通知 5 分钟都不更新。

小结

  • useEffect + fetch 是反模式:竞态、重复请求、无缓存、无重试、样板代码爆炸,生产环境里它迟早咬你。
  • React Query / SWR 把"服务端状态"和"客户端状态"分开,一行 useQuery 替代 50 行手写逻辑,缓存/去重/重试/陈旧刷新全自动。
  • RQ 三件套:queryKey(缓存键)、staleTime(新鲜期)、invalidateQueries(写后刷新)。写操作用 useMutation,要快就上乐观更新。
  • RSC 负责首屏和服务端数据(零 JS、SEO 友好),RQ 负责交互后的动态数据——不是替代,是互补。
  • 实战黄金组合:首屏 RSC 取数据 → 用 initialData 喂给 RQ → 写操作 mutation + invalidate。缓存按数据真实变化频率调 staleTime。

金句:数据获取不该是客户端的独角戏,也不该是服务端的独角戏——首屏交给服务端,交互留给客户端,用对工具而不是用对框架。

Related

相关推荐