blogs's People
blogs's Issues
ReactQuery系列文章- 1. React Query 实践
当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<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显示了一个带有过滤器的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<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 = queryClient.getQueryData<Todos>(['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 ? <MyForm initialData={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,去试试吧。
ReactQuery系列文章- 3. 渲染优化
免责声明:渲染优化是所有应用的进阶话题。React Query已经进行了许多性能优化并且开箱即用,大多数时候不需要做更多优化。"不必要的重新渲染"是一个很多人投入大量关注的话题,也是我要写这篇文章的原因。但是我要再一次指出,大部分情况下对于大多数应用来说,渲染优化很可能并没有想得那么重要。重新渲染是一个好事情。它保证了你的应用展示了最新的状态。相比于重复渲染,我更关注由于缺少渲染而导致的渲染错误。对于更多关于这个话题的讨论,可以看下面的内容:
- Fix the slow render before you fix the re-render
- this article by @ryanflorence about premature optimizations
我在第二篇文章介绍select的内容中已经讲了一些关于渲染优化的事情。然而,"为什么在没有任何数据变化的情况下,React Query会渲染两次组件呢"是我平时被问到最多的一个问题。我们让我来尝试深入解释一下。
isFetching
在之前的例子中我说过,下面这个组件只会在todos的length变化时才会重新渲染,其实我只说了一部分事实:
export const useTodosQuery = (select) =>
useQuery(['todos'], fetchTodos, { select })
export const useTodosCount = () => useTodosQuery((data) => data.length)
function TodosCount() {
const todosCount = useTodosCount()
return <div>{todosCount.data}</div>
}
每次发生后台refetch的时候,这个组件都会下面的数据分别进行一次渲染:
{ status: 'success', data: 2, isFetching: true }
{ status: 'success', data: 2, isFetching: false }
这是因为React Query在每个查询中返回了很多基本信息,isFetching
就是其中一个。这个属性在请求正在发生的时候会被设置为true。这个在你想要展示一个后台请求的loading标志的时候特别有用。但是如果你不需要,那确实会造成一些不必要的渲染。
notifiOnChange
对于上面说到的这个场景,React Query提供了notifyOnChangeProps
参数。他可以在每个场景单独设置来告诉React Query:只在这些属性发生变化的时候再通知我。通过将这个参数设置为['data']
,我们可以实现一个新的版本:
export const useTodosQuery = (select, notifyOnChangeProps) =>
useQuery(['todos'], fetchTodos, { select, notifyOnChangeProps })
export const useTodosCount = () =>
useTodosQuery((data) => data.length, ['data'])
保持同步
尽管上面的代码可以正常工作,但是它很容易就会造成不同步。如果我们希望针对error
进行特殊处理呢?又或者我们需要使用isLoading
属性呢?我们不得不确保notifyOnChangeProps
属性和我们实际用到的数据保持同步。如果我们忘记将某个数据添加到属性里面,而只监听data属性的变化,当查询返回错误,同时我们也要展示这些错误的时候,我们的组件并不会重新渲染。这个问题当我们把这些属性写死在自定义hook的时候格外明显,因为我们并不知道使用自定义hook的组件实际上会用到哪些数据:
export const useTodosCount = () =>
useTodosQuery((data) => data.length, ['data'])
function TodosCount() {
// 🚨 we are using error, but we are not getting notified if error changes!
const { error, data } = useTodosCount()
return (
<div>
{error ? error : null}
{data ? data : null}
</div>
)
}
就像我在文章开头免责声明中说的,我认为这是比偶尔发生的不必要的重新渲染更坏的事情。当然,我们可以传参数给自定义hook,但是这还是需要手动处理,是否有什么方式可以自动处理这个情况呢?请看:
被追踪的查询
这是我感受特别自豪的一个特性,这也是我对这个库第一个重大的贡献。如果你将notifyOnChangeProps
设置为'tracked'
,React Query会跟踪你在渲染过程中用到的数据,会自动计算依赖列表。最终的效果就跟你手动维护这个列表一样,除了你不用再去关注这个问题以外。你也可以全局开启这个特性:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
notifyOnChangeProps: 'tracked',
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
)
}
利用这个特性,你再也不用考虑重新渲染。当然这个特性也有一些限制,这就是为什么这个特性是一个可选项:
- 如果你使用对象剩余属性结构的语法的话,最终所有属性都会被追踪。正常的解构语法是没问题的,不要这么做:
// 🚨 will track all fields
const { isLoading, ...queryInfo } = useQuery(...)
// ✅ this is totally fine
const { isLoading, data } = useQuery(...)
被追踪的查询只会追踪render过程中用到的数据。如果你只在effects中用到了这些数据,他们并不会被追踪。
const queryInfo = useQuery(...)
// 🚨 will not corectly track data
React.useEffect(() => {
console.log(queryInfo.data)
})
// ✅ fine because the dependency array is accessed during render
React.useEffect(() => {
console.log(queryInfo.data)
}, [queryInfo.data])
- 被追踪的查询不会在每次render的时候被重置,所以只要你使用了一次某个数据,你就会在整个组件的生命周期内追踪这个数据:
const queryInfo = useQuery(...)
if (someCondition()) {
// 🟡 we will track the data field if someCondition was true in any previous render cycle
return <div>{queryInfo.data}</div>
}
结构化共享
一个不同的但是并没那么重要的React Query默认开启的渲染优化是结构化共享。这个特性确保数据在所有地方是引用唯一的。举个例子,假设我们有下面这个数据结构:
[
{ "id": 1, "name": "Learn React", "status": "active" },
{ "id": 2, "name": "Learn React Query", "status": "todo" }
]
现在假设我们将第一个todo转为done,然后进行了一次后台refetch。我们会从后端拿到一个全新的json:
[
- { "id": 1, "name": "Learn React", "status": "active" },
+ { "id": 1, "name": "Learn React", "status": "done" },
{ "id": 2, "name": "Learn React Query", "status": "todo" }
]
现在React Query会尝试对比新老状态,尽可能多的复用老的状态。在上面的例子中,todo数据会是一个新的对象,因为我们更新了一个todo。第一个id为1的对象也会是新的对象,但是对于id为2的对象我们会保持跟对应的旧数据一样的引用-React Query会将他复制一份同样的引用到新的数据,因为这部分数据并没有发生变化。
这使得使用selector进行部分订阅变得特别友好:
// ✅ will only re-render if _something_ within todo with id:2 changes
// thanks to structural sharing
const { data } = useTodo(2)
就像我之前提到的,对于selector来说结构化共享会用到两次:一次是在queryFn返回的结果上,另一次是在selector返回的结果上。在一些场景,特别是数据量比较大的场景,结构化共享会成为一个瓶颈。同时它只能使用在JSON可序列化的数据上。如果你不需要这个优化,你可以通过将structuralSharing
设为false来关闭这个特性。
如果想了解更多底层的实现,可以看一看replaceEqualDeep的测试用例
原文地址:https://tkdodo.eu/blog/react-query-render-optimizations
ReactQuery系列文章- 2. 数据转换
欢迎来到“关于react-query我不得不说的一些事情”的第二章节。随着我越来越深入这个库以及他的社区,我发现一些人们经常会问到的问题。最开始,我计划在一篇超长的文章里面把这些都讲清楚,最终我还是决定将他们拆分成一些有意义的主题。今天第一个主题是一个很普遍但是很重要的事情:数据转换。
数据转换
我们不得不面对这个问题-大部分的人并没有使用GraphQL。如果你使用了,那么恭喜你,因为你可以请求到你期望的数据格式。
如果你在使用REST风格的API,你就必须受限于后端返回的数据格式。所以在使用react-query的时候我们应该在什么地方通过什么方式来进行数据转换呢?答案只有一个:看情况。
下面列举出四种进行数据转换的方式,以及他们的优缺点:
0. 在后端
这是我最喜欢的方式,如果你有决定权的话。如果后端返回的数据结构是你所期望的话,那么你就什么都不用做了。但是在很多场景这并不太现实,比如一些公共的REST API,特别是在企业级应用中。如果你可以让后端针对每一个具体的场景都有一个对应的接口,那么可以返回你期望的数据结构。
- 优点:
前端什么都不用做 - 缺点:
并不是所有情况下都能做到
1. 在查询函数中
查询函数是你传给useQuery
的函数。他会返回一个Promise,最终返回的数据会被存在缓存中。但是这并不意味着你只能按照后端给你的数据结构来返回数据。你可以在返回之前进行数据转换:
const fetchTodos = async (): Promise<Todos> => {
const response = await axios.get('todos')
const data: Todos = response.data
return data.map((todo) => todo.name.toUpperCase())
}
export const useTodosQuery = () => useQuery(['todos'], fetchTodos)
之后你就可以在其他地方使用转换之后的数据,仿佛后端返回的数据就是这样的。你在其他地方都不会拿到不是大写的todo名字了。同时你也拿不到数据的原始结构了。如果你查看react-query-devtools,你会看到转换之后的结构。如果你查看网络请求,你可以看到原始的数据结构。这个可能会有点让人感到困惑,所以不要忘了你在代码里面处理了数据结构。
同时,在这里react-query并不会做什么优化。也就是说每一次fetch被执行的时候,你的转换逻辑都会被执行。如果转换逻辑很复杂,需要考虑一下其他转换方式。一些公司在前端会有一个公共的API层来抽象数据获取,所以你可能没办法在这个抽象层里面做你的数据转换。
- 优点:
和API调用绑定在一起,对上层无感知 - 缺点:
在每次数据请求的时候都会运行
如果你有一个你无法修改的公共的API层,这个方式不太可行 - 其他:
存储在缓存中的是转换之后的数据结构,所以你没办法拿到原始的数据结构
2. 在render函数中
正如第一章节中介绍的,你可以自定义一个hook,那么你可以很方便的在这个hook里做数据转换:
const fetchTodos = async (): Promise<Todos> => {
const response = await axios.get('todos')
return response.data
}
export const useTodosQuery = () => {
const queryInfo = useQuery(['todos'], fetchTodos)
return {
...queryInfo,
data: queryInfo.data?.map((todo) => todo.name.toUpperCase()),
}
}
正如代码逻辑所示,数据转换不会在每次数据查询的时候运行,但是会在每次render的时候运行(即使这次render并没有触发数据请求)。这看起来这不是什么大问题,如果你在意的话,你可以通过useMemo
来进行优化,同时尽可能只定义真正需要的依赖列表。queryInfo中的data
是引用稳定的除非数据真的发生了变化,但是queryInfo
就不是了。如果你把queryInfo
作为你的依赖,那么转换逻辑就会在每次render的时候运行:
export const useTodosQuery = () => {
const queryInfo = useQuery(['todos'], fetchTodos)
return {
...queryInfo,
// 🚨 don't do this - the useMemo does nothing at all here!
data: React.useMemo(
() => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
[queryInfo]
),
// ✅ correctly memoizes by queryInfo.data
data: React.useMemo(
() => queryInfo.data?.map((todo) => todo.name.toUpperCase()),
[queryInfo.data]
),
}
}
特别是当你在自定义hook中有一些额外的逻辑来协助进行数据转换的时候,这是一个很好的选择。需要注意的是data有可能是undefined,所以请使用可选链式访问来获取data中的数据。
- 优点:
可以通过useMemo进行优化 - 缺点
写法有一些晦涩
data可能会是undefined - 其他
确切的数据结构无法在devtool中展示
3. 使用select配置
v3引入了内置的selector,可以用它来进行数据转换:
export const useTodosQuery = () =>
useQuery(['todos'], fetchTodos, {
select: (data) => data.map((todo) => todo.name.toUpperCase()),
})
selector只会在data存在的时候被调用,所以你不用担心undefiend的问题。像上面的selector会在每次render的时候被执行,因为函数表达式变化了(因为这是一个内联函数)。如果转换逻辑比较复杂,你可以使用useCallback来进行memoize,或者把他抽象到一个稳定的函数引用中:
const transformTodoNames = (data: Todos) =>
data.map((todo) => todo.name.toUpperCase())
export const useTodosQuery = () =>
useQuery(['todos'], fetchTodos, {
// ✅ uses a stable function reference
select: transformTodoNames,
})
export const useTodosQuery = () =>
useQuery(['todos'], fetchTodos, {
// ✅ memoizes with useCallback
select: React.useCallback(
(data: Todos) => data.map((todo) => todo.name.toUpperCase()),
[]
),
})
在未来,select配置也可以被用来订阅data中的部分数据。这使得这一数据转换实现方式变得特别。看看下面这个例子:
export const useTodosQuery = (select) =>
useQuery(['todos'], fetchTodos, { select })
export const useTodosCount = () => useTodosQuery((data) => data.length)
export const useTodo = (id) =>
useTodosQuery((data) => data.find((todo) => todo.id === id))
这里,我们创建了一个像useSelector一样的API,你可以传自定义selector到useTodosQuery中。这个自定义hook仍然可以像之前一样工作,如果你没有传select,会返回整个数据。
但是如果你传了selector,你就只会订阅selector返回的部分数据。这是很有用的,因为这意味着如果我们更新了一个todo的名字,只通过useTodosCount订阅了count的组件并不会重新渲染。count没有发生变化,所以react-query可以选择不通知这部分数据的订阅者(注意这里说得很容易,但是具体实现不完全跟这个描述一样,我会在第三部分渲染优化中聊一聊这部分内容)
- 优点:
最佳优化
支持部分订阅 - 其他:
每个订阅者的数据可能都不一样
原文地址:https://tkdodo.eu/blog/react-query-data-transformations
Recommend Projects
-
React
A declarative, efficient, and flexible JavaScript library for building user interfaces.
-
Vue.js
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
-
Typescript
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
-
TensorFlow
An Open Source Machine Learning Framework for Everyone
-
Django
The Web framework for perfectionists with deadlines.
-
Laravel
A PHP framework for web artisans
-
D3
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
-
Recommend Topics
-
javascript
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
-
web
Some thing interesting about web. New door for the world.
-
server
A server is a program made to process requests and deliver data to clients.
-
Machine learning
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
-
Visualization
Some thing interesting about visualization, use data art
-
Game
Some thing interesting about game, make everyone happy.
Recommend Org
-
Facebook
We are working to build community through open source technology. NB: members must have two-factor auth.
-
Microsoft
Open source projects and samples from Microsoft.
-
Google
Google ❤️ Open Source for everyone.
-
Alibaba
Alibaba Open Source for everyone
-
D3
Data-Driven Documents codes.
-
Tencent
China tencent open source team.