首页 / 文章

useEffect 数据获取会带来哪些代价,以及 TanStack Query 如何改变这一状况

为什么基于效果的数据获取会在 React 中导致重复请求、样板代码以及过时的闭包,以及 TanStack Query 的 keys、staleTime 和 invalidation 特性是如何解决这些问题的。

发布者:REACTAPP.TOP阅读约 6 分钟1360 词

来源: Stop Using useEffect for API Calls, Use React Query Instead

useEffect 中获取数据是大多数 React 开发者最先学习的模式,对于单个页面上的单次请求来说效果不错。但随着应用规模的扩大,这种模式会悄悄增加请求数量,在每个组件中重复生成样板代码,并引发隐蔽的依赖问题。本文将解释这些问题的根源,以及 TanStack Query(原名 React Query)是如何解决每一个问题的,这样你就能决定何时进行切换以及切换后需要配置哪些内容。

所有人都会用到的模式

这种常见的模式是一个依赖数组为空的效应函数,它在组件挂载时会调用 fetch 函数:

useEffect(() => {
  fetchData();
}, []);

它看起来很简单,但这恰恰是问题所在。这段代码完全没有提及缓存、去重、重试、取消机制,以及组件再次挂载时应该采取什么措施。当需要跟踪的端点数量众多、加载状态和错误状态愈发复杂时,这些缺失之处都会变成漏洞或额外的请求。

为何请求会发送两次,以及其背后的真正问题

最常见的意外情况是,在页面加载时会在网络标签页中看到相同的请求出现两次。在开发模式下,React的StrictMode会刻意地挂载、卸载并重新挂载组件,以便暴露那些未能正确清理自身资源的副作用。这种重复请求是开发模式下的有意设计,在生产环境构建中则不会发生。

双重请求是生产环境中一个重要问题的表现:基于效果的获取操作与组件的生命周期绑定在一起。每次组件挂载都会触发新的请求,即便数据已经存在。那些会反复挂载的组件,比如标签页、模态框和列表行,会不断向后端发起请求,增加服务器负载,从而使用户界面显得比实际更慢。

TanStack Query将数据与组件分离。查询结果会存储在以查询键为标识的共享缓存中。当另一个组件,或是同一组件在重新挂载后,请求那些仍被视为最新的数据时,该库会直接返回缓存中的值而无需访问网络。多个组件同时请求同一个键时,也会共享同一个正在处理中的请求。

手动管理的加载与错误状态

使用 useEffect 时,每个请求都需要三组状态变量来分别存储数据、加载标志和错误信息:

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

如果在每次 API 调用中都重复这样的代码结构,就会产生大量几乎相同的代码,而每份代码都有出错的可能。常见的错误包括在处理错误时忘记重置 loading 状态,导致加载指示器一直不消失。其他问题还包括没有忽略组件卸载后仍传来的响应,以及当输入快速变化时,较慢的旧响应会覆盖新的响应。

useQuery会直接返回data、error、isLoading和isError(以及更多详细的状态字段),同时还会自动处理响应顺序混乱的问题。组件中的冗余代码不再出现,各种边缘情况也由经过充分测试的库统一处理,而无需在应用代码中反复解决。

依赖数组与查询键

效应函数的正确运行依赖于依赖数组的准确设置。如果遗漏了效应函数所需的某个值,就会导致闭包使用过时的参数进行请求,这种错误可能需要数小时才能找到根源。而如果加入会在每次渲染时都被重新创建的对象或函数,效应函数就会在每次渲染时都执行并发送请求。

TanStack Query 用更清晰的契约取代了这种猜测方式:你为每个查询指定一个键和一个获取函数。该键应包含请求所依赖的所有变量,例如资源名称加上用户 ID 或筛选条件。当键发生变化时,就会执行新的查询;如果键保持不变,则会重用缓存数据。键的比较是基于其内容而非引用,因此每次渲染时创建新数组不会引发额外的数据获取操作。重新获取数据的时机则由明确的策略决定,比如组件挂载、窗口重新获得焦点或网络重新连接,同时还会考虑你配置的 staleTime 值。

你仍然需要考虑依赖关系,但只需在键中一次性表达它们,而无需维护一个必须与效果体保持同步的数组。

缓存、staleTime 与失效机制

日常工作中的大部分差异由三个特性决定。

缓存

获取到某个键对应的数据后,它会存储在缓存中。当用户离开后又返回时,缓存的结果会立即显示;如果数据已过期,该库会在后台重新获取数据,并在收到新响应时更新屏幕。

staleTime

staleTime用于控制数据被视为有效的时长。在数据有效期内,重新加载页面或新增订阅者都不会触发请求。默认值为零,这意味着数据会立即被视为过期;缓存的数据仍会立即显示,但会在下一次页面加载或窗口获得焦点时在后台重新获取数据。为那些不会每秒变化的数据设置合理的staleTime,往往是减少后端流量最有效的方法。

invalidateQueries

在发生变更后,比如更新个人资料或创建帖子时,您可以使用相关键调用 queryClient.invalidateQueries。匹配的查询会被标记为过期,而当前正在使用的查询则会自动重新获取数据。无需在每次变更后手动重新获取数据,也无需将服务器响应手动复制到本地状态中。

这三者结合起来就能解决基于效应的获取方式所带来的大部分问题。如需更深入地了解如何大规模组织键、查询选项和变更操作,请参阅我们的指南 构建 TanStack Query 数据层。

典型的迁移场景

想象一个具有多个标签页的 Next.js SaaS 控制面板,每个标签页都在 useEffect 中加载自己的数据。由于切换标签页会重新渲染对应面板,因此每次切换都会触发新一轮请求。后端接收到的流量远超必要水平,用户也会感受到加载缓慢的问题。

迁移通常遵循三个步骤:

  1. 用 useQuery 及能包含所有请求参数的键来替换原有的效果函数。
  2. 为每个查询设置反映其数据实际更新频率的 staleTime 值。
  3. 每次执行数据变更操作后,将受影响数据的键标记为无效。

这样做的预期效果是让控制面板运行更流畅,减少后端调用次数,提升用户体验,同时还能降低每个组件的代码量。

何时仍应使用 useEffect

这并非完全移除 useEffect 的理由。它依然是与 React 之外的非请求响应型数据同步的理想工具,比如订阅、DOM 事件监听器、定时器以及第三方组件。不过,在一个不断发展的应用中,对于 API 调用而言,专门的服务器状态管理库更为合适,因为它能减轻后端负担、减少样板代码,并且还能直接提供缓存和失效处理功能。

也有些情况下,库的功能超出了实际需求,比如那些只有一两个请求的小型应用。对于这类场景,一个带有适当取消功能的简单自定义钩子可能就足够了;我们在关于最小化useFetch钩子及其局限性的文章中探讨了这种取舍。如果不确定,可以先迁移一个页面,然后查看网络标签页中的请求数量进行比较。

关键要点

  • 开发环境中的重复请求源于StrictMode模式;而在生产环境中,基于effect的fetch操作会在每次组件挂载时重复执行。
  • 查询键可以替代依赖数组:将请求所依赖的所有输入都包含进去,缓存和重新获取功能便会据此运作。
  • 默认的staleTime值为零,因此如果希望避免组件重新挂载时自动进行后台重新获取操作,就需要手动设置该值。
  • 在数据发生变更后直接使相关键失效,而非手动重新获取或同步状态。
  • 将useEffect用于与外部系统同步,而非加载服务器数据。