React Query的实战指南

介绍React Query这个非常实用的数据获取和管理库,以及它在应用中的实用技巧

原文地址 Practical React Query

在2018年,当GraphQL尤其是它的实现Apollo变得非常流行时,很多人在忙着完全使用它代替Redux,并且Redux是不是完蛋了的这个问题也经常会被问到。

我清楚地记得我当时完全搞不明白这是怎么回事。为什么某些数据获取库会取代我们的全局状态管理器?它们之间有什么关系?

我的印象是像Apollo这样的GraphQL客户端只会为我们获取数据,类似于axios用于REST请求一样,而且我们仍然需要某种方式使我们的应用程序可以访问该数据。

但是我大错特错了。

客户端状态 VS 服务端状态

Apollo为我们提供的不仅仅是描述我们想要的数据和获取该数据的能力,它还为服务器数据提供了缓存。 这意味着我们可以在多个组件中使用相同的useQuery hook,它只会获取一次数据,然后从缓存中返回它。 这听起来非常熟悉我们,可能还有许多其他团队,主要使用redux的目的是:从服务器获取数据并使其随处可用。

因此,我们似乎一直像对待任何其它客户端状态一样对待此服务器状态。除了我们的应用程序不拥服务器状态(假设:我们获取的文章列表,我饿吗想要显示的用户的详细信息。。。)。 我们只是用它来为用户在屏幕上显示它的最新版本。拥有这些数据的是服务器。

对我来说,这让我对数据的模式思维模式发生了转变。如果我们可以利用缓存来显示我们不拥有的数据,那么整个应用中真正需要使用的的客户端状态就不会剩下多少了。 这让我明白为什么很多人认为Apollo可以在很多情况下取代redux。

React Query

我从来没有机会使用GraphQL。我们现有的REST API,并没有真正遇到过度获取的问题,并且它工作的很好。显然,我们没有足够的痛点来让我们去使用GraphQL,特别是考虑到我们还必须适配后端,这问题就不那么简单了。

然而,我仍然羡慕前端数据获取的简单性,包括加载和错误状态的处理。 如果React访问REST API的工具中中有类似的东西就好了。。。

使用React Query

它是由Tanner Linsley在2019年开发并开源的,它将Apollo中的优点带入了REST世界。它适用于任何返回Promise并采用stale-while-revalidate缓存策略的函数。该库在默认设置下,尝试使我们的数据尽可能新,同时尽可能早地向用户显示数据,使其有时感觉近乎即时,从而提供出色的用户体验。 最重要的是,它也非常灵活,当默认值无法满足我们的需求时,我们可以自定义各种设置。

不过,本文不会介绍React Query。

我认为React Query的文档非常适合解释概念和作为指南,你也可以观看来自各种演讲的视频,如果你想熟悉该库,可以学习Tanner的React Query Essentials 课程

我想更多地关注一些超出文档范围的实用技巧,当你已经在使用该库时,这些技巧可能会很有用。这些是我在过去几个月中学到的东西,当时我不仅在工作中积极使用该库,而且还参与了 React Query社区,在Discord和GitHub讨论中回答问题。

对默认值的解读

我相信React Query的默认值是非常好的选择,但它们有时会让你不明所以,尤其是在刚开始使用React Query的时候。 首先:默认情况下,即便老化时间已经到0了,React Query也不会在每次重渲染的时候调用queryFn。我们的应用可能会因为任何原因,随时进行重渲染,因此每次都获取数据是不合理的。

总是为重新渲染编写代码,而且这会很多。 我喜欢称之为按需渲染。 — Tanner Linsley

如果我们看到不期望的重新获取,很可能是因为我们只是聚焦了窗口,而React Query正在执行 refetchOnWindowFocus,这对正式版本来说是一个很棒的功能:如果用户转到不同的浏览器选项卡,然后又回来了到我们的应用程序,这将自动触发后台重新获取,如果在此期间服务器上的某些内容发生更改,则屏幕上的数据将更新。 所有这些都发生在没有显示加载进度条的情况下,如果数据与我们当前在缓存中的数据相同,我们的组件将不会重新渲染。

在开发过程中,这可能会更频繁地触发,特别是因为浏览器的DevTools和我们的应用程序之间的焦点切换也会导致获取,因此请注意这一点。

其次,缓存时间(cacheTime)和老化时间(staleTime)似乎非常容易混淆,所以让我试着澄清一下:

  • 老化时间:查询从全新变为陈旧所需要的时间。只要查询是全新的,数据将始终只从缓存中读取—这不会发出网络请求!如果查询过时(默认情况下是:立即),我们仍然会从缓存中获取数据,但在某些情况下可能会发生后台重新获取数据的情况。
  • 缓存时间:从缓存中删除非活动查询之前所需要的时间。 这默认为5分钟。一旦没有观察者了,查询就会转换到非活动状态,因为使用该查询的所有组件都已卸载了。

大多数情况下,如果我们想改变这些设置中的一个,那必须是老化时间(staleTime)。我们很少需要去更改缓存时间(cacheTime)。文档中有一个非常好的例子给为什么这么做做出了解释。

使用React Query的DevTools

这将极大地帮助我们了解查询所处的状态。DevTools还会告诉我们当前缓存中的数据,因此我们可以更轻松地进行调试。 除此之外,我发现如果我们想更好地识别后台重新获取,就需要在浏览器DevTools中限制我们的网络连接,因为开发服务器通常非常快。

将查询键key视为依赖数组

我在这里指的是我们所熟悉的useEffect hook的依赖数组。

为什么它们两个非常相似呢?

因为每当查询key发生变更时,React Query都会触发重新获取。 因此,当我们将可变参数传递给queryFn时,我们几乎总是希望在该值发生改变时获取数据。 我们可以使用查询key,而不是编写复杂的副作用来手动触发重新获取:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type State = 'all' | 'open' | 'done'
type Todo = {
  id: number
  state: State
}

type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
  const response = await axios.get(`todos/${state}`)
  return response.data
}


export const useTodosQuery = (state: State) =>
  useQuery(['todos', state], () => fetchTodos(state))

在这里,假设我们的UI显示了一个待办事项列表以及一个过滤器选项。 我们会有一些本地状态来存储过滤条件,一旦用户改变了他们的选择,我们就会更新那个本地状态,React Query会自动为我们触发重新获取,因为查询key发生了变化。 因此,我们将用户的过滤器条件与查询函数保持同步,这与useEffect依赖数组表示的内容非常相似。我不认为我会将不属于queryKey的变量传递给queryFn。

一个新的缓存项

因为查询key用作缓存的key,当我们从“all”切换到“done”时,我们将获得一个新的缓存条目,当我们第一进行这个切换时,这将会有一个漫长的加载状态(可能显示加载进度条)。这很不理想,因此我们可以在这些情况下使用keepPreviousData选项,或者,如果可能,使用初始数据预填充新创建的缓存条目。 上面的例子非常适合这个,因为我们可以对我们的待办事项进行一些客户端预过滤:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type State = 'all' | 'open' | 'done'
type Todo = {
  id: number
  state: State
}

type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
  const response = await axios.get(`todos/${state}`)
  return response.data
}


export const useTodosQuery = (state: State) =>
  useQuery(['todos', state], () => fetchTodos(state), {
    initialData: () => {
      const allTodos = queryCache.getQuery<Todos>(['todos', 'all'])
      const filteredData =
        allTodos?.filter((todo) => todo.state === state) ?? []
      return filteredData.length > 0 ? filteredData : undefined
    },
  })

现在,每次用户在状态之间切换时,如果我们还没有数据,我们会尝试用“所有待办事项”缓存中的数据预先填充它。我们可以立即向用户显示“已完成”的待办事项,一旦后台获取完成,他们仍将看到更新的列表。 请注意,在v3之前,我们还需要设置initialStale属性以实际触发后台获取。

我认为这对于几行代码来说是一个很好的用户体验改进。

保持服务器和客户端状态分开

这于我在上个月所写的文章Putting props to useState有一定的关联:如果我们从useQuery中获取了数据,请尽量不要将该数据写入本地状态中。主要原因是,我们这样做会隐式的无法从React Query的后台更新中获得新的数据,因为状态中的副本是不会随之更新。

如果这是我们所想要的,那么这很好,例如,获取表单的默认值,并将获得数据呈现到我们的表单上。后台更新不太可能产生新的东西,并且我们的表单已经完成了初始化。因此,我们确实是有意为之时,请确保不要通过设置老化时间来触发不必要的后台获取:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const App = () => {
  const { data } = useQuery('key', queryFn, { staleTime: Infinity })
  return data ? <MyForm initialData={data} /> : null

}

const MyForm = ({ initialData} ) => {
  const [data, setData] = React.useState(initialData)
  ...
}

当我们显示一些允许用户编辑的数据时,这个概念会有点难以遵循,但它很有很多有点。因此我准备了一个codesandbox:

这个演示最重要部分是我们从不将从React Query获得的值放入本地状态。 这确保我们总是看到最新的数据,因为它没有本地“副本”。

非常强大的enabled选项

useQuery hook有许多选项,我们可以传入这些选项来自定义其行为,而enabled选项是一个非常强大的选项,可以让我们做许多很酷的事情(双关语)。以下是通过此选项我们能够完成的事情的简短列表:

  • 依赖查询 在一个查询中获取数据,只有在我们成功从第一个查询中成功获取数据后才运行第二个查询。
  • 打开或者关闭查询,由于refetchInterval,我们可以定期轮询数据的查询,但是如果Modal 处于打开状态,我们可以暂时暂停它以避免屏幕背面的更新。
  • 等待用户输入,在查询key中有一些过滤条件,但只要用户没有输入到他们的过滤器就中时,禁用查询。
  • 在某些用户输入后禁用查询,例如 如果我们有一个暂时不需要存储到服务器的草稿值。 请参阅上面的示例。

不要将queryCache用作本地状态管理器

如果我们篡改了queryCache(queryCache.setData),它应该只用于乐观更新或写入我们收到后端变更过的数据。 请记住,每个后台重新获取都可能覆盖该数据,因此请使用其它工具保存本地状态,例如useStateZustandRedux

创建自定义Hooks

即使它只是为了包装一个useQuery调用,创建一个自定义钩子通常也是有价值的的,因为:

  • 我们可以将数据获取和UI进行分离,可以将useQuery调用聚集在一起。
  • 我们可以将一个查询key(以及可能的类型定义)的所有引用保存在一个文件中。
  • 如果我们需要调整一些设置或添加一些数据转换,我们可以在一个地方完成。

我们已经在上面的todos查询中看到了这种例子

译注

随着React自身函数化的推进,React生态也逐步的完善,出现了很多非常好的工具,尤其是数据获取和状态管理相关的组件。React Query将Apollo中的需要优点带入的RESTful的世界中,这让很多既有应用在不用改写为GraphQL的同时享受到了Apollo的数据缓存和服务器状态一致等优点。并且React Query并没有限定我们使用fetch或者Axios,而是在这之上提供了一个高效的封装和适配方案,不得不说这非常便于现有应用使用。