当2018年GraphQL特别是Apolllo Client开始流行之后,很多人开始认为它将替代Redux,关于Redux是否已经落伍的问题经常被问到。
我很清晰地记得我当时对这些观点的不理解。为什么一些数据请求的库会替代全局状态管理库呢?这两者有什么关联呢?
曾经我认为像Apollo这样的Graphql客户端只能用来请求数据,就像axios一样,你仍然需要一些方式来让请求的数据可以被应用程序访问到。
我发现我大错特错。
客户端状态 vs 服务端状态
Apollo提供的不仅仅是描述所需数据同时获取数据的能力,它同时提供了针对这些服务端数据的缓存能力。这意味着你可以在多个组件中使用相同的useQuery
hook,它只会触发一次数据请求并且按照请求的先后顺序返回缓存中的数据。
这看起来跟我们(包括很多除了我们以外的团队)在一些场景使用redux
的目的很相似:从服务器获取数据,然后让这部分数据可以在所有地方可以被访问到。
所以似乎我们经常将这些服务端数据当成客户端状态来看待,除了这些服务端数据(比如:一个文章列表,你需要显示的某个用户的详细信息,...),你的应用并不真正拥有它。我们只是借用了最新版本的一份数据然后展示给用户。服务端才真正拥有这部分数据。
对于我来说,这给了我一个如何看待数据的新的思路。如果我们能利用缓存来显示我们不拥有的那部分数据,那么剩下的应用需要处理的真正的客户端状态将大大减少。这使我理解了为什么很多人认为Apollo可以在很多场景替代redux。
React Query
我一直没有机会使用GraphQL。我们有现成的REST API,并没有遇到冗余请求的问题,目前完全沟通。并没有足够的理由让我们转换到GraphQL,特别是你还需要让后端服务配合进行改动。
但是我还是羡慕GraphQL带来的前端数据请求(包括loading和错误态的处理)的简洁。如果React生态中有相似的针对REST API的方案就好了。
让我们来看看React Query吧。
由Tanner Linsley在2019年开发的React Query使得在REST API中也可以使用到Apollo所带来的好处。他支持任何返回Promise的函数并且使用了stale-while-revalidate
缓存策略。库本身有一些默认行为可以尽可能保证数据的实时性,同时尽可能快的将数据返回给用户,让人们感觉近乎实时的体验以提供优秀的用户体验。在这之上,他同时提供了灵活的自定义能力来满足各种场景。
这篇文章并不会对React Query进行详细的介绍。
我认为官方文档已经对使用和概念进行了很好的介绍,同时也有很多关于这方面的视频,并且Tanner开了一门课程可以让你熟悉这个库。
我将会更多的关注在官方文档之外的一些实践上的介绍,当你已经使用这个库一段时间之后,也许这些介绍对你会有所帮助。这其中有一些我过去几个月在深度使用React Query以及从React Query社区中总结出的经验。
关于默认行为的解释
我相信React Query的默认行为是经过深思熟虑的,但是他们有时会让你措手不及,特别是刚开始使用的时候。
首先,React Query并不会在每次render的时候都执行queryFn
,即使默认的staleTime
是0。你的应用在任何时候可能会因为各种原因重新render,所以如果每次都fetch是疯狂的!
如果你看到了一个你不希望的refetch,这很可能是因为你刚聚焦了当前窗口同时React Query执行了refetchOnWindowFocus
,这在生产环境是一个很棒的特性:如果用户在不同的浏览器tab之间切换,然后回到了你的应用,一个后台的refetch会被自动触发,如果在同一个时间服务端数据发生了变更,那屏幕上的数据会被更新。所有这些会在看不到loading态的情况下发生,如果数据和缓存中的数据对比没有变化的话,你的组件不会进行重新render。
在开发阶段,这个现象会出现得更加频繁,特别是当你在浏览器DevTools和你的应用之间切换的时候。
其次,cacheTime
和staleTime
的区别似乎经常让人感到困惑,所以让我来说明一下:
- StaleTime:一个查询变成失效之前的时长。如果查询是有效的,那么查询就会一直使用缓存中的数据,不会进行网络请求。如果查询是处于失效状态(默认情况下查询会立即失效),首先仍然会从缓存中获取数据,但是同时后台在满足一定条件的情况下会发起一次查询请求。
- CacheTime:查询从变成非激活态到从缓存中移除持续的时长。默认是五分钟,当没有注册的观察者的时候,查询会变成非激活态,所以如果所有使用了某个查询的组件都销毁的时候,这个查询就变成了非激活态。
大多数情况下,如果你要改变这两个设置其中的某一个的话,大部分情况下应该修改staleTime
。我很少会需要修改cacheTime
。在文档里面也有一个关于这个的解释。
使用React Query DevTools
DevTools会帮助你更好的理解查询中的状态。它会告诉你当前缓存中的数据是什么,所以你可以更方便的进行调试。除了这些,我发现在DevTools中可以模拟你的网络环境来更直观的看到后台refetch,因为本地服务一般都很快。
把query key理解成一个依赖列表
我这里所说的依赖列表是类比useEffect
中说到的依赖列表,我假设你已经对useEffect
已经比较熟悉了。
为什么这两者会是相似的呢?
因为React Query会触发refetch当query key发生变化。所以当我们给queryFn
传了一个变量的时候,大部分情况下我们都是希望当这个变量发生变化的时候可以请求数据。相比于通过复杂的代码逻辑来手动触发一个refetch,我们可以利用query key:
type State = 'all' | 'open' | 'done'
type Todo = {
id: number
state: State
}
type Todos = ReadonlyArray
const fetchTodos = async (state: State): Promise => {
const response = await axios.get(`todos/${state}`)
return response.data
}
export const useTodosQuery = (state: State) =>
useQuery(['todos', state], () => fetchTodos(state))
这里,想象我们的UI显示了一个带有过滤器的todo列表。我们会有一些本地状态来存储过滤器的数据,当用户改变了过滤条件之后,我们会更新本地的状态,然后React Query会自动触发一个refetch,因为query key发生了变化。我们最终实现了过滤状态和查询函数的同步,这与useEffect中的依赖列表很相似。我从来没有没有出现过给queryFn
传了一个变量,但是这个变量不是queryKey
的一部分的情况。
一个新的缓存入口
因为query key被用作缓存的key,所以当你把状态从all改成done的时候,你会得到一个新的缓存入口,当你第一次切换过滤状态的时候,会导致一个强制的loading状态(很可能会限制一个loading动画)。这当然不是最理想的,所以你可以使用keepPreviousData
来处理这种情况,或者你可以使用initialData来为新的缓存入口预填充数据。上面那个例子可以很完美的解释这个情况,因为我们可以做一些客户端的数据预过滤:
type State = 'all' | 'open' | 'done'
type Todo = {
id: number
state: State
}
type Todos = ReadonlyArray
const fetchTodos = async (state: State): Promise => {
const response = await axios.get(`todos/${state}`)
return response.data
}
export const useTodosQuery = (state: State) =>
useQuery(['todos', state], () => fetchTodos(state), {
initialData: () => {
const allTodos = queryClient.getQueryData(['todos', 'all'])
const filteredData =
allTodos?.filter((todo) => todo.state === state) ?? []
return filteredData.length > 0 ? filteredData : undefined
},
})
现在,每次用户切换过滤条件的时候,如果我们没有数据,我们会尝试用'all todos'缓存中的数据来预填充。我们可以马上就显示'done'的todo给用户,他们可以在后台fetch结束之后看到更新之后的列表。注意v3版本中,你需要设置initialStale
属性来触发一个后台fetch。
我认为这简单的几行代码可以给你带来很好的用户体验的提升。
把服务端状态和客户端状态分开
这个观点和我上个月写的文档一样:如果你从useQuery
中拿到了数据,不要把这部分数据放到本地状态中。主要的原因是这样会使得React Query所有后台更新失效,因为复制出来的本地状态不会自动更新。
如果你希望获取一些默认数据来设置一个表单的默认值,然后使用数据来渲染表单,那是可以的。后台更新并不会因为表单已经初始化就忽略之后更新的数据。所以如果你想打到这个目的,确保通过设置staleTime
来避免触发不必要的后台refetch:
const App = () => {
const { data } = useQuery('key', queryFn, { staleTime: Infinity })
return data ? : null
}
const MyForm = ({ initialData} ) => {
const [data, setData] = React.useState(initialData)
...
}
enabled属性是很强大的
useQuery
hook有很多属性可以用来自定义他的行为,enabled
属性是很强大的一个,它可以让你做很多有意思的事情。下面是一些我们可以利用它来实现的功能:
在一个查询中获取数据,然后第二个查询只有当我们成功的从上一个查询中获取数据的时候才会触发
- 开启/关闭查询
假设我们有一个定时查询,通过refetchInterval
来实现,但是当一个弹窗打开的时候我们可以暂停这个查询,避免弹窗后面的内容发生变更。
- 等待用户输入
比如我们有一些过滤条件作为query key,但是当用户还没进行过滤操作的时候可以不进行查询。
不要把queryCache当成本地状态管理器
如果你要修改queryCache,它应该只发生在乐观更新或者在变更之后拿到后台返回的新数据的时候。记住任何一个后台refetch都会覆盖这些数据,所以可以使用其他本地状态管理库
创建自定义hook
即使你只是封装一个useQuery
调用,创建一个自定义hook通常情况下也是值得的,因为:
- 你可以把真实的数据获取逻辑和UI分离,当时把它和useQuery调用封装在一起
- 你可以把对于某个query key的使用都放在同一个文件里面
- 如果你需要修改一些设置或者增加一些数据转换逻辑,你可以在一个地方进行
在上面的todo例子里面已经有一些使用场景。
我希望这些实践经验可以帮助你熟悉React Query,去试试吧。