这篇是这周要作为组内分享的文档,复制过来时候由于格式问题可能有些地方没改完,如果有什么不足的地方或者有错误的地方可以指出一下,立马修改。
背景
早在 2022 年底,umi
就在最佳实践里把 react query
作为了请求方案的最佳实践。
说来惭愧,时隔两年我才迈上 react query
的学习之路(其实中间我看过几次,都被它的文档劝退了)。
写这篇的时候遇到了很多问题,最大的问题其实还是自我怀疑有没有必要写这一篇,2024年了还能有人看不懂英文文档吗,真的还需要专门写一篇文档来介绍吗?直到有一天,我听到组内的同学把标题的英文 title /ˈtaɪt(ə)l/ 念成了 /ˈtiːt(ə)l/,我觉得还是有意义的(开玩笑)。
React Query是什么
简介
react query
是由一个来自犹他的开发者用他的空余时间开发的一个库,每周约有 330w 的下载量,平均每 6 个 react
的应用程序,就有一个使用了 react query
。
但它不是银弹,所有的流行库都会有自己的优势,不然就不会 6 个应用程序才有 1 个使用了,大家还是可以根据自己的喜好来选择使用的库。
you-might-not-need-react-query(你可能并不需要react query)
配置
剩下的就不用看 umi
里的文档了,那儿也没打算怎么教你好好用。
React Query 使用
react query
几乎提供了所有场景下的解决方案。
缓存管理、缓存失效、自动重新请求、离线支持、列表滚动恢复、页面获取焦点重新发起请求、依赖查询、分页查询、请求取消、预请求、轮询、无限滚动、状态变更、数据选择器 等等。
基本配置
QueryClientProvider
是 react-query
提供的 Provider
,包裹在应用的最外层,保证我们整个应用能获取到缓存。
QueryClient
包含了并所有缓存的数据,它是一个静态对象(可以看成一个 Map),永远不会改变,所以虽然 react query
依赖 context
实现,但是缓存的改变不会直接触发 re-render
。
import {
QueryClient,
QueryClientProvider
} from '@tanstack/react-query'
const queryClient = new QueryClient()
export default function App () {
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
}
useQuery基础用法
useQuery
是 react query
最核心的 api
,用来获取请求。
基本用法
import { useQuery } from '@tanstack/react-query'
const { data } = useQuery({
queryKey: ['luckyNumber'],
queryFn: () => Promise.resolve(7),
})
这是最基本的用法,我们给 useQuery
提供一个 queryKey
和 queryFn
,从返回的结果的中解构出 data
在页面中使用,data
会有一个 undefined -> 7
的转变。
如果我们要根据不同的参数来重新发起请求,可以把参数加到 queryKey
,当 queryKey
发生改变时,react query
会重新调用 queryFn
。
const { data } = useQuery({
queryKey: ['list', { page: page, pageSize: 10 }],
queryFn: () => getData(page, 10),
})
注意: queryKey
必须是可以序列化的,因为 react query
是把 key
序列化之后比较 hash
的
常用参数和返回内容
基础流程
缓存机制
useQuery
是否重新请求取决于 useQuery
的一个配置项 -- staleTime
单位为毫秒,代表的意思为,在一次数据请求完成后,在 staleTime
设置的这个时间段里,不会再重新发起请求,而是直接使用缓存中存在的数据,默认为 0。
react-query
并不会主动重新去发起请求更新数据,但是下面几种情况下,queryFn
会重新执行。
queryKey
变更- 新组件挂载,并且订阅了这个
queryKey
,对应的配置项,refetchOnMount
默认true
- 窗口重新聚焦 ,对应的配置项,
refetchOnWindowFocus
,默认为true
- 断网重连,对应的配置项,
refetchOnReconnect
,默认为true
控制按需获取
enabled
可以用来控制 useQuery
是否可用。
如果在 enabled
为 false
的时候,key
在缓存中已经有了数据,会把已有的数据返回,不重新触发 queryFn
。
useMutation
前面的案例和用法基本都是获取数据的用法,但是我们实际开发场景中,也有很多是通过请求,来变更服务端数据的,比如修改表单。
这样的请求和之前的 useQuery
有着明显的区别 -- 不需要缓存。
react query
提供了 useMutation
来处理这个场景。
声明
mutationFn
就是我们实际的请求函数。
onMutate
onError
onSuccess
onSettled
分别代表 运行时
运行失败
运行成功
运行结束
。
variables
代表传递进来的参数。
onMutate
返回的内容,会作为 context
传递给后面的生命周期函数。
export const useAddTodo = () => {
return useMutation({
mutationFn: addTodo,
onMutate: (variables) => {
// A mutation is about to happen!
// Optionally return a context containing data to use when for example rolling back
return { id: 1 }
},
onError: (error, variables, context) => {
// An error happened!
console.log(`rolling back optimistic update with id ${context.id}`)
},
onSuccess: (data, variables, context) => {
// Boom baby!
},
onSettled: (data, error, variables, context) => {
// Error or success... doesn't matter!
},
})
}
使用
mutate
其实就是我们声明的 mutationFn
,mutate
中的生命周期函数,会运行在声明的周期函数之后。
const {
mutate,
isPending,
isError,
isSuccess,
data,
error,
isIdle
} = useAddTodo();
mutate(todo, {
onSuccess: (data, variables, context) => {
// I will fire second!
},
onError: (error, variables, context) => {
// I will fire second!
},
onSettled: (data, error, variables, context) => {
// I will fire second!
},
})
queryClient 使用
初始配置
react query
提供了设置初始配置的方法,就像 umi
文档里说到的,推荐关闭 refetchOnWindowFocus
或者统一设置一个 staleTime
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 10 * 1000
// ...
},
},
})
项目中使用
因为 queryClient
中存储了所有的缓存信息,所以我们可以轻松地通过它对缓存进行操作。这边简单介绍几个常用的方法。
获取缓存
const queryClient = useQueryClient();
const data = queryClient.getQueryData>(['todos']);
修改缓存
const queryClient = useQueryClient();
queryClient.setQueryData(['todos'], data);
失效缓存
通过传递给 queryClient.invalidateQueries()
一个 queryKey
,它会把匹配到的 query
全部失效,并重新发起请求。
还有个参数为 refetchType
,默认为 active
,表示在活跃状态下(有订阅)的 query
会重新请求。
还有其他几个选项 ,inactive
all
none
,具体含义看名字就了解了。
const queryClient = useQueryClient();
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryKey匹配规则
由于我们 queryKey
是按照数组的形式传入的,匹配 queryKey
的时候,也是按层级来的。
比如我们调用 queryClient.invalidateQueries
失效缓存。
如果传入的 queryKey
是 ['lol']
,那么 ['lol', 1]
['lol', 'list', '1']
这些都会失效。
如果需要只匹配到 ['lol']
可以加一个 exact: true
表达精确匹配。
const queryClient = useQueryClient();
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true });
useQuery 的部分使用场景
轮询
refetchInterval
可以用来控制轮询的间隔,可以返回一个数值代表,时间间隔,也可以返回函数,参数是存储的 query
对象,返回值为 false
代表终止轮询,返回值为数字代表下一次轮询的间隔。
useQuery
中返回 dataUpdatedAt
可以用来表示,数据更新的时间戳。
const { data, dataUpdatedAt } = useQuery({
queryKey: ['lol'],
queryFn: () => getData(),
refetchInterval: (query) => {
console.log('query', query);
if (query.state.data) {
return false;
}
return 3000;
},
});
预查询
实际项目中,我们很少会用预查询,因为查到了也没有地方存储数据,专门建个 context
或者 map
使用起来也太过麻烦,而且我们的交互要求也没这么高。
很简单的一个例子,在用户在从列表页点击进详情页时,有时候我们的选择框会先出现 id
,等 options
请求完成后,才会显示 label
,我们完全可以在用户浏览列表页,或者完成列表页查询后的时间,完成这些数据的预加载。
import{ useQueryClient } from '@tanstack/react-query'
const Component = () => {
const queryClient = useQueryClient()
const handlePrefetch = () => {
queryClient.prefetchQuery({
queryKey: ['lol'],
queryFn: () => fetchLol(),
staleTime: 5000
})
}
}
并发查询
可以使用 useQueries 将多个请求放到一个数组里,返回的是一个带有 query 的数组(每个都和 useQuery 返回的对象一致),每个 query 的配置,和使用单个 useQuery 是一致的。
而且可以使用 combine
属性,来进行自定义的返回。
const { isPending, isError, t1, ig } = useQueries({
queries: [
{
queryKey: ['t1', id],
queryFn: () => getData(id, { loading: true }),
refetchInterval: 30000,
},
{
queryKey: ['ig', id],
queryFn: () => getData(id, { loading: true, change: true }),
},
],
combine: (queries) => {
const isPending = queries.some((query) => query.status === "pending");
const isError = queries.some((query) => query.status === "error");
const [t1, ig] = queries.map((query) => query.data);
return {
isPending,
isError,
t1,
ig
};
}
});
依赖查询
很多时候,我们一个请求需要的 params
会来自另一个请求的 response
。
举个例子,比如我们的 dima
一个需求关联了另一个需求,这个 dima
本身有一个 id
,在通过这个 id
查询到详情后,我们从 response
里拿到 relationId
,去查询关联需求的详情。
你可能会这样写:
const useDimaDetail = (id) => {
return useQuery({
queryKey: ['dima', id],
queryFn: async () => {
const dimaDetail = await getDima(id);
const relationId = dimaDetail.data.id;
const relationDimaDetail = await getRelationDima(relationId);
return {
dimaDetail,
relationDimaDetail
}
}
})
}
这样写会有几个问题,
- 两个请求共用
error
loading
pending
这些信息不容易区分状态,并且一个请求挂了另一个也挂了 - 需要两个请求都完成后才会返回结果,消耗了不必要的时间
- 单个请求不能够使用缓存,两个请求会一起刷新
- 共用了配置,比如
staleTime
gctime
,可能在后续维护中会出现问题。
更好的写法
const useDimaDetail = (id) => {
return useQuery({
queryKey: ['dima', id],
queryFn: () => getDima(id)
})
}
const useRelationDimaDetail = (id) => {
return useQuery({
queryKey: ['relationDima', id],
queryFn: () => getRelationDima(id),
enabled: id !== undefined
})
}
const useDetail = (id) => {
const dimaDetail = useDimaDetail(id);
const relationId = dimaDetail.data.id;
const relationDimaDetail = useRelationDimaDetail(relationId);
return {
dimaDetail,
relationDimaDetail
}
}
todoList示例
掘金的代码块功能太难用了,只能手贴代码了。
先加一个模拟数据获取更新的 data.js
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const list = [
{
id: 1,
label: 'react',
done: false,
},
{
id: 2,
label: 'vue',
done: false,
},
{
id: 3,
label: 'angular',
done: false,
},
{
id: 4,
label: 'solid',
done: true,
},
];
export const getData = () => {
const data = JSON.parse(JSON.stringify(list));
return Promise.resolve(list);
};
export const setData = async (id) => {
await sleep(1000);
const num = Math.random();
if (num < 0.5) {
return Promise.reject(false);
}
const item = list.find((item) => item.id === id);
item!.done = !item!.done;
return Promise.resolve(true);
};
简单的todoList
添加了获取todo
列表和修改todo
状态
import React from 'react';
import { Checkbox, message, Spin } from 'antd';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getData, setData } from './data';
export const TodoList = () => {
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ['todo'],
queryFn: () => getData(),
placeholderData: [],
});
const { mutate, isPending } = useMutation({
mutationFn: (id) => setData(id),
onError: (err, vars, context) => {
console.log('err', err, vars, context);
message.error('操作失败');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true });
},
});
const onChange = (id) => {
mutate(id);
};
return (
<div>
<h2>todoList: </h2>
<Spin spinning={isPending}>
{data.map((item) => {
return (
<Checkbox checked={item.done} key={item.id} onChange={() => onChange(item.id)}>
{item.label}
</Checkbox>
);
})}
</Spin>
</div>
);
};
优化后的todoList
因为每次修改状态都需要发起请求再修改状态,状态更新的反应比较慢,可以稍微优化下体验,点击时候先把todo
显示勾上,再去发起请求,然后同步状态。
import React from 'react';
import { Checkbox, message, Spin } from 'antd';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getData, setData } from './data';
export const TodoList = () => {
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: ['todo'],
queryFn: () => getData(),
placeholderData: [],
});
const { mutate, isPending } = useMutation({
mutationFn: (id) => setData(id),
onMutate: async (id) => {
// 取消正在进行中的查询
await queryClient.cancelQueries({ queryKey: ['todo'], exact: true });
queryClient.setQueryData(['todo'], (list) => {
return list.map((item) => {
if (item.id === id) {
item.done = !item.done;
}
return {
...item,
};
});
});
},
onError: (err, vars, context) => {
console.log('err', err, vars, context);
message.error('操作失败');
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todo'], exact: true });
},
});
const onChange = (id) => {
mutate(id);
};
return (
<div>
<h2>todoList: </h2>
<Spin spinning={isPending}>
{data.map((item) => {
return (
<Checkbox checked={item.done} key={item.id} onChange={() => onChange(item.id)}>
{item.label}
</Checkbox>
);
})}
</Spin>
</div>
);
};
其他技巧
自定义默认配置
虽然设置了默认值,如果在 useQuery
里写了对应的 options
,还是按 useQuery
里的为准。
统一配置
react query
提供了设置初始配置的方法,就像 umi
文档里说到的,推荐关闭 refetchOnWindowFocus
或者统一设置一个 staleTime
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 10 * 1000
// ...
},
},
})
可以用这个来统一配置 queryFn
const client = new QueryClient({
defaultOptions: {
queries: {
queryFn: async ({ queryKey }) => {
const slug = queryKey.join("/");
const url = `${BASE_URL}/${slug}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error("Unable to fetch data");
}
const data = await response.json();
return data;
},
},
},
});
手动修改
我们还可以通过 queryClient.setQueryDefaults
来修改我们的默认值,第一个参数也是 queryKey
, 匹配的逻辑和之前的一样 ,第二个参数是 options
。
// 影响 ['todo'] 、 ['todo', id] ...
queryClient.setQueryDefaults(
['todos'],
{ staleTime: 10 * 1000 }
)
管理 queryKey
有时,你会在多个组件内使用同一套 queryOptions
,或者你觉得每次都写字面量的 queryOptions
不规范难以维护,你可以在其他地方定义一套 options
,然后导入使用,为了确保你的参数命名正确,react-query
也提供了 api
来帮助你实现。
import { queryOptions } from '@tanstack/react-query'
const lolQueryOptions = queryOptions({
queryKey: ['lol'],
queryFn: fetchLol,
staleTime: 5000
})
甚至你可以这样,把一个模块下的请求放到一起,类似一个 controller
。
const todoQueries = {
all: () => ['todos'],
lists: () => [...todoQueries.all(), 'list'],
list: (filters: string) =>
queryOptions({
queryKey: [...todoQueries.lists(), filters],
queryFn: () => fetchTodos(filters),
}),
details: () => [...todoQueries.all(), 'detail'],
detail: (id: number) =>
queryOptions({
queryKey: [...todoQueries.details(), id],
queryFn: () => fetchTodo(id),
staleTime: 5000,
}),
}
性能优化
减少重复渲染
react query
会把返回的数据结果和之前储存的数据进行比较,如果是一致的,就不会触发重新渲染。
react query
还会订阅你使用的内容,只有你使用的内容变更,才会触发重新渲染,如下面的 2 个例子,只有 data
和 isStale
变更了,才会触发组件的重新渲染
const { data, isStale } = useQuey({
...
})
const result = useQuery({
...
})
console.log(result.data);
console.log(result.isStale);
使用 select 减少重复渲染
有些数据会返回 traceId
或者时间戳,导致数据不一样,可以用 select
函数进行筛选。
const { data } = useQuery({
queryKey: [],
queryFn: () => {},
select: (data) => ({ name: data.name })
})
如果返回数据的 name
没有变化,就不会触发重新渲染。
短时间内重复触发
使用 debounce 解决
我们可以自己使用 debounce
函数来控制
取消之前的请求
react query
会提供 signal
用来取消请求,只要当作参数传给我们的请求方法就行。
比如 axios
就可以使用 signal
取消请求,详情可以看文档。
const query = useQuery({
queryKey: ['todos'],
queryFn: ({ signal }) =>
axios.get('/todos', {
// Pass the signal to `axios`
signal,
}),
})
错误处理
retry && retryDelay
react query
提供了 retry
和 retryDelay
,作为在请求失败时,自动重新请求的参数,retry
默认为 3 次,同样可以在 defaultOptions
中配置成 false
。retryDelay
是每次间隔的时间。
两个参数都可以传数字或者函数,failureCount
表示失败次数。
retry: (failureCount, error) => {},
retryDelay: (failureCount, error) => {},
同样在 useQuery
的返回内容中,也有 failureCount
。
onError
在最开始创建 queryClient
时,我们也可以创建一个全局的 onError
事件,用来捕获我们的失败的请求。可以用来弹 message
或者其他事情。
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// ...
}
},
queryCache: new QueryCache({
onError: (error, query) => {
// 在 useQuery 时候,可以传 meta 参数记录信息
if (!query.meta.notShow) {
message.error(error.message);
}
}
})
})
配合 ErrorBoundary
首先 ErrorBoundary
是一个处理 react
页面渲染问题的组件,当你的 react
页面发生错误时,会显示最近的 ErrorBoundary
中的内容,并提供方法,让你可以重新渲染页面。
<ErrorBoundary
onError={(err) => console.log("err", err)}
fallbackRender={({ resetErrorBoundary }) => (
<div>
There was an error!
<Button onClick={() => resetErrorBoundary()}>Try again</Button>
</div>
)}
>
<div>
<Content />
</div>
</ErrorBoundary>
但是 ErrorBoundary
不支持捕获事件中的报错和异步的报错。
通过配置 useQuery
中的 throwOnError
可以把请求报错抛出,展现出 ErrorBoundary
中的内容。
同时,react query
也提供了 api
来更好地结合 ErrorBoundary
的使用。可以使用 reset
将最近的 ErrorBoundary
下出错的请求重新发起。
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
const App = () => {
const { reset } = useQueryErrorResetBoundary()
return (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
There was an error!
<Button onClick={() => resetErrorBoundary()}>Try again</Button>
</div>
)}
>
<Page />
</ErrorBoundary>
)
}
验证请求数据
可以配合 zod
验证返回的请求的数据格式
插件和适配器
持久化缓存
可以配合 PersistQueryClientProvider
将请求保存到 storage
,重新打开网页时恢复缓存内容。一般用到的比较少,想要了解的同学可以查看官方文档或者私聊我。
useQuery 丐版实现
简单的请求获取
带上 error
loading
竞态请求
状态的封装。
const useQuery = () => {
const [data, setData] = useState();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
const handleQuery = async () => {
setLoading(true);
setError(null);
setDetail();
try {
const data = await getData()
if (ignore) {
return;
}
setData(data);
setLoading(false);
} catch(e) {
if (ignore) {
return;
}
setError(err);
setLoading(false);
}
};
handleQuery();
return () => {
ignore = true;
};
}, [id]);
return {
detail,
loading,
error,
};
};
带上发布订阅
也是上面说的问题,掘金的代码块功能太鸡肋了,就简单描述下实现过程。
通过 subscribe
类,配合 react
的 useSyncExternalStore
,实现发布订阅的流程,在数据更新时候通知到订阅的组件,实现 rerender
。
export class Subscribable {
constructor(data?) {
this.data = data;
this.listeners = new Set();
this.subscribe = this.subscribe.bind(this);
}
subscribe(listener: any): () => void {
this.listeners.add(listener);
this.onSubscribe();
return () => {
this.listeners.delete(listener);
this.onUnsubscribe();
};
}
changeData(data) {
this.data = data;
this.dispatchEvent();
}
hasListeners(): boolean {
return this.listeners.size > 0;
}
dispatchEvent() {
this.listeners.forEach((listen) => {
listen(this.data);
});
}
onSubscribe(): void {
// Do nothing
}
onUnsubscribe(): void {
// Do nothing
}
}
export const useQuery = (id) => {
const map = useContext(MyContext);
if (!map[id]) {
map[id] = new Subscribable({
data: [],
loading: false,
});
}
const data = useSyncExternalStore(
map[id].subscribe,
useCallback(() => map[id].data, []),
);
const handleQuery = () => {
map[id].changeData({
data: [],
loading: true,
});
getData(id).then((res: any) => {
map[id].changeData({
data: res,
loading: false,
});
});
};
useEffect(() => {
handleQuery();
}, []);
return data;
};
带上属性监听
原理其实和上文一致,只需要加一步,监听 data
的 getter
,得到每个组件使用了哪些 key
,在 dispatch
时候对比一下使用的 key
的数据有没有变更,如果没有变更,就不重新 update data
。
dispatchEvent(oldData) {
this.listeners.forEach((listen) => {
const uuid = Object.keys(this.listenerKeys).find((k) => this.listenerKeys[k] === listen);
const keys = this.subKeys[uuid];
let change = false;
for (let k of keys) {
const oD = JSON.stringify(oldData[k]);
const nD = JSON.stringify(this.data[k]);
if (oD !== nD) {
change = true;
break;
}
}
if (change) {
listen(this.data);
}
});
}
总结
首先,这篇分享的标题是 skip the docs react query
,意思是帮助我们跳过阅读文档就能够使用 react query
。
肯定是不可能的,但是应该可以帮助我们了解大多数的概念和使用方法,在使用的时候遇到有疑问的地方,再去看官方文档,会变得更加简单,至少知道该从哪个地方入手去看文档。
这篇分享我大概准备了一个多月,从系统地学习 react query
到整理文档.
整个过程下来给我的感受就是,不用它其实并不会有太大的影响,反而用它的时候,你可能会因为了解得不透彻,被它的一些内置的操作感到困惑。
举个例子,在断网情况下,它不会发请求也不会报错,而是会把请求的状态置为 paused
,你搁那疯狂操作发现什么表现都没有,请求也不发,一看 wifi
才发现断网了,需要配置下 networkMode
,才能和我们正常的请求一致。
但是如果你想在请求上做一些 ui 的展示优化,或者性能的优化, react query
能很方便的帮你做到这些,比如一些跨组件的数据重新获取。
最后,如果在使用上遇到什么问题都可以和我来讨论。