面试官: 你真的知道 React Query 吗?
一、React Query 概览
React Query 通常被描述为 React 缺少的数据获取(data-fetching)库,但是从更广泛的角度来看,它使 React 程序中的获取,缓存,同步和更新服务器状态变得轻而易举。你或许使用的框架不是 React,可能是 Vue。React Query 和 Vue Query 的核心逻辑是一样的,你只需要理解 React Query,换一个框架也会轻而易举。本文主要介绍的是 React Query ,在最后面我们会介绍 React Query 和 Vue Query 的异同。
在 React 中,如果我们要请求数据,最简单的例子是这样的:
export const mockFetch = (result, timeout = 1000) => {
return new Promise((resolve) => {
setTimeout(() => resolve(result), timeout);
});
};
export default function Example1() {
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState();
useEffect(() => {
setIsLoading(true);
mockFetch(Math.random()).then((res) => {
setIsLoading(false);
setData(res);
});
}, []);
return <div>{isLoading ? "loading..." : data}</div>;
}
再看一下我们是如何使用 React Query 的:
- 创建一个请求客户端
queryClient的外部实例,它会管理默认配置和全局状态,并通过QueryClientProvider共享queryClient。
import { QueryClient, QueryClientProvider, useQuery } from 'react-query'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<div>
<Example1 />
</div>
</QueryClientProvider>
);
}
- 在组件中使用
useQuery。
export default function Example1() {
const query = useQuery("key1", () => mockFetch("Example1", 1000));
return <div>{query.isLoading ? "loading..." : query.data}</div>;
}
这就是 React Query 中最简单例子,useQuery 主要接收 3 个参数:
queryKey:请求的唯一标识。queryFn:请求的函数。options:请求相关配置,我们在下面的文章会对options中的一些值进行介绍。
如果大家想提前了解。可以参考文档 react-query-v3.tanstack.com/reference/u…
二、准备
在使用 React Query 的时候,最容易让人困惑的就是 React Query 何时会发起请求,组件何时会重新渲染。我们将以 useQuery 这个 hook 入手,帮助大家理解 React Query 的一些基本原理,解答这些困惑。下面的文章会涉及大量的代码演示,我们在这里先定义一下基础的代码,之后会直接引用这些组件或函数,不再做重复声明了,除非需要修改这个组件。
mockFetch,用来模拟请求,可以自定义返回值和请求响应时间。
export const mockFetch = (result, timeout = 1000) => {
return new Promise((resolve) => {
setTimeout(() => resolve(result), timeout);
});
};
App, 初始化 React Query。创建一个请求客户端queryClient的外部实例,它会管理默认配置和全局状态,并通过QueryClientProvider注入 React。
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<div>
<Example1 />
<Example2 />
</div>
</QueryClientProvider>
);
}
Example1, 使用useQuery的一个组件。
export default function Example1() {
const query = useQuery("key1", () => mockFetch("Example1"), 1000);
return <div>{query.isLoading ? "loading..." : query.data}</div>;
}
Example2, 使用useQuery的一个组件。注意这里和Example1的queryKey是相同的,都是key1。
export default function Example2() {
const query = useQuery("key1", () => mockFetch("Example2"), 200);
return <div>{query.isLoading ? "loading..." : query.data}</div>;
}
三、staleTime 和 cacheTime?最容易被混淆的两个时间
想要理解 React Query,最先要了解什么是 SWR,SWR 来自于 stale-while-revalidate,一种由 HTTP RFC 5861(opens in a new tab) 推广的 HTTP 缓存失效策略。这种策略首先从缓存中返回数据(过期的),同时发送 fetch 请求(重新验证),最后得到最新数据。从而就衍生出了两个时间,staleTime 过期时间和 cacheTime 缓存时间。
staleTime,查询过期的时间,如果查询没有过期,数据会始终从缓存中返回,并且不会发送网络请求。如果查询过期(默认是 0 即立即过期),仍然会从缓存中获取数据,但是会有一个 refetch 在后台发生。cacheTime,从缓存中删除非活跃的查询的持续时间,默认是五分钟,一旦没有注册观察者,查询就会过渡到非活跃的状态,即当一个查询的组件都被卸载时。
我们下面用一个例子来理解这两个时间,因为缓存想要被删除,需要组件被卸载,所以我们需要修改 App ,让 Example1 可以随时被卸载和注册。
export default function App() {
const [count, setCount] = useState(1);
...
return (
<QueryClientProvider client={queryClient}>
<div onClick={() => setCount(count + 1)}>
...
<div>count {count}</div>
{count % 2 === 0 && <Example1 />}
</div>
</QueryClientProvider>
);
}
然后我们给 useQuery 添加 staleTime 和 cacheTime 这两个参数。这里解释一下 isLoading 和 isFetching 的区别:
isLoading, 是查询处于 “hard” 加载的状态,这表明没有缓存数据并且正在请求中。isFetching,正在请求,包含发生在后台的请求。
export default function Example1() {
const query = useQuery("key1", () => mockFetch(Math.random()), {
staleTime: 3 * 1000,
cacheTime: 6 * 1000
});
return (
<div>
<div>{query.isFetching ? "fetching..." : query.data}</div>
<div>{query.isLoading ? "loading..." : query.data}</div>
</div>
);
}
运行结果:

根据上的的运行效果可以看出:
- 第一次组件重新 mount 的时候(count === 2),间隔时间在 3s 内,没有超过
staleTime,React Query 并不会重新发起请求。 - 第二次组件重新 mount 的时候(count === 4),间隔时间在 3s - 6s,超过
staleTime,但是没有超过cacheTime,React Query 会重新请求,但是 cache 没有过期,data取的是 cache 中的数据,所以请求发生在后台,isFetching为true,但是isLoading为false。 - 第三次组件重新 mount 的时候(count === 6),间隔时间超过 6s, 超过了
staleTime和cacheTime,React Query 会重新请求,并且 cache 已经过期,将取不到 cache 中的数据,所以isFetching和isLoading都为true。
四、React Query 是如何处理请求的?
页面中出现使用相同 queryKey 的多个组件
如果一个页面中有多个组件使用了相同 queryKey 的 useQuery, useQuery 具体会怎么处理请求?
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<div>
<Example1 />
<Example2 />
</div>
</QueryClientProvider>
);
}
运行结果:

说明 React Query 只会发起一次请求,并且是第一次的请求。第二次的请求根本不会发生,因为第二次请求返回的时间要比第一次请求返回的时间要短,如果第二次请求发生,会有 “Example2” 这段文字的出现。我们对 App 进行一下修改,让 Example2 延时渲染:
export default function App() {
const [show, setShow] = useState(false);
useEffect(() => {
setTimeout(() => {
setShow(true);
}, 500);
}, []);
return (
<QueryClientProvider client={queryClient}>
<div>
<Example1 />
{show && <Example2 />}
</div>
</QueryClientProvider>
);
}
运行结果:

看上去是符合预期的,React Query 仍然只是发起了一次请求,但是将将 setTimeout 的时间改成 1500 后,运行结果发生了变化:

结果发生了两次请求,第一次请求得到的结果是 “Example1”,过了一会发起第二次请求,结果是“ Example2” 覆盖了Example1。如果我们再将 staleTime 修改为 3000 后,运行结果又发生了变化:

请求又变成了一次。所一个页面中出现多个 queryKey 相同的 useQuery 时,并不一定只发起一次请求。实际发起请求的数量可能会和组件的渲染时机有关。
React Query 的请求原理
- 我们会创建一个管理所有
Query的QueryClient实例,然后通过QueryClientProvider注入到 React 组件中。
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
...
</QueryClientProvider>
);
}
- 每次调用
useQuery,会根据queryKey创建一个Query实例存入到QueryClient实例中。注意相同queryKey会共享一个Query实例。
export class QueryCache {
private queriesMap: QueryHashMap
constructor() {
this.queries = []
this.queriesMap = {}
}
build() {
...
let query = this.get(queryHash)
if (!query) {
query = new Query({...})
this.add(query)
}
return query
}
add(query) {
if (!this.queriesMap[query.queryHash]) {
this.queriesMap[query.queryHash] = query
this.queries.push(query)
}
}
}
- 是否发起请求,是由
Query内部的状态决定的。如果出现多个queryKey相同的useQuery时,首先会创建一个Query,后面的组件共享第一个组件的Query。后面的组件会读取Query的状态,决定是否会重新发起请求。如果当前组件 mount 的时候,staleTime已经过期,并且没有在请求中,Query就会重新发起请求。
上面就是 React Query 发起请求的基本原理,React Query 还有一些参数可能会让 React Query 重新发起请求。下面是 React Query 重新发起请求的一些场景。
React Query 可能发起请求的场景
queryKey发生改变时,React Query 会重新创建一个Query,此时 React Query 会重新发起请求。- 组件 mount 时,可以通过
refetchOnMount控制组件 mount 的时候,是否重新请求。当已经有了对应的Query实例并且refetchOnMount设置为false,该组件 mount 的时候,不会重新发起请求。 - 窗口重新聚焦时,可以通过
refetchOnWindowFocus控制。 - 网络重新连接时,可以通过
refetchOnReconnect控制。 - 配置重新获取数据的间隔时,可以通过
refetchInterval控制 。
五、使用 React Query 后,组件是如何渲染的?
React Query 渲染原理
当每次调用 useQuery 时,内部都会实例一个观察者对象 observer。
const [observer] = React.useState(
() =>
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
queryClient,
defaultedOptions,
),
)
observer 会订阅 Query 的状态,当这些 Query 状态发生变化时,notifyManager.batchCalls 会触发 React 的强制更新。
useSyncExternalStore(
React.useCallback(
(onStoreChange) =>
isRestoring
? () => undefined
: observer.subscribe(notifyManager.batchCalls(onStoreChange)),
[observer, isRestoring],
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult(),
)
大家可能对 useSyncExternalStore 不太熟悉,useSyncExternalStore 的功能类似于 forceUpdate,用来从外部数据源读取和订阅状态,并且与并发特性兼容。 useSyncExternalStore 接收 3 个参数:
const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);
subscribe:订阅函数 ,React 会给订阅函数传入一个onStoreChange函数,当外部数据源改变时,必须调用onStoreChange通知到 React。getSnapshot:要订阅的状态。getServerSnapshot:在 SSR 时要订阅的状态,可选。
结构共享,保证请求结果一致时,不会发生重新渲染
结构共享是 React Query 一个非常重要的开箱即用的优化,这个特性确保我们的数据在每一层级上的引用都是稳定的。我们新增一个案例,让请求返回一个对象。
export default function Example1() {
const query = useQuery("key1", () => mockFetch({ value: 1 }));
return (
<div onClick={() => query.refetch()}>
{query.isFetching ? "loading..." : "loaded"}
<Example2 data={query.data} />
</div>
);
}
const Example2 = memo((props) => {
console.log("rerender Example2");
return <div>value: {props.data?.value}</div>;
});
运行结果:

你会发现 Example2 并没有重新渲染,这说明 React Query 如果前后两次请求返回结果一致,返回的 data 引用也是一样的。我们再修改一下案例,让请求返回数组。
const Example2 = memo((props) => {
console.log("rerender Example2");
return <div>value: {props.data?.value}</div>;
});
const Example3 = memo((props) => {
console.log("rerender Example3");
return <div>value: {props.data?.value}</div>;
});
export default function Example1() {
const query = useQuery("key1", () =>
mockFetch([{ value: 1 }, { value: Math.random() }])
);
return (
<div onClick={() => query.refetch()}>
{query.isFetching ? "loading..." : "loaded"}
<Example2 data={query.data?.[0]} />
<Example3 data={query.data?.[1]} />
</div>
);
}
运行结果:

你会发现只有 Example3 重新渲染, React Query 会对数组类型的数据做特殊处理,会逐项比较,只有内容发生变化的项,引用才会发生变化。如果你不需要这个优化可以将 structuralSharing 设为 false 。
下面是 React Query 实现结构共享的逻辑,会对数组和对象的子属性逐项进行对比,只有发生了变化,才会替换。
export function replaceData(prevData, data, options) {
// Use prev data if an isDataEqual function is defined and returns `true`
if (options.isDataEqual?.(prevData, data)) {
return prevData
} else if (typeof options.structuralSharing === 'function') {
return options.structuralSharing(prevData, data)
} else if (options.structuralSharing !== false) {
// Structurally share data between prev and new data if needed
return replaceEqualDeep(prevData, data)
}
return data
}
export function replaceEqualDeep(a: any, b: any): any {
if (a === b) {
return a
}
const array = isPlainArray(a) && isPlainArray(b)
if (array || (isPlainObject(a) && isPlainObject(b))) {
const aSize = array ? a.length : Object.keys(a).length
const bItems = array ? b : Object.keys(b)
const bSize = bItems.length
const copy: any = array ? [] : {}
let equalItems = 0
for (let i = 0; i < bSize; i++) {
const key = array ? i : bItems[i]
copy[key] = replaceEqualDeep(a[key], b[key])
if (copy[key] === a[key]) {
equalItems++
}
}
return aSize === bSize && equalItems === aSize ? a : copy
}
return b
}
replaceEqualDeep 函数比较复杂,如果有兴趣可以看一下 replaceEqualDeep 的测试用例。github.com/TanStack/qu…
notifyOnChangeProps ,追踪对应属性的变化
notifyOnChangeProps 选项,告诉 React Query,只有在这些数据发生变化时,才会通知当前观察者,重新渲染组件。
export default function Example1() {
const { isFetching } = useQuery(
"key1",
() => {
console.log("refetch");
return mockFetch(Math.random());
},
{
refetchInterval: 1000,
notifyOnChangeProps: ["data"]
}
);
return <div>{isFetching ? "fetching" : "fetched"}</div>;
}
运行结果:

当 notifyOnChangeProps 没有包含 isFetching 时,即使重启发起了请求,数据发生了变化。isFetching 也不会发生变化,将值改为 ["data", "isFetching"] 后,isFetching 发生变化,组件也就会发生对应的变化。运行结果:

有时会遇到忘记传入我们使用的属性,就像上面的例子,我们只监听了 data,但是我们使用了 isFetching,结果导致 isFetching 发生变化后,组件没有发生对应的变化。为了防止这种情况出现,我们可以将 notifyOnChangeProps 设置为 tracked ,这样 React Query 会追踪所有使用到的值,并将计算一个 list,这与你手动传递没有区别,只是你不必再考虑漏传属性的这个问题了。
下面是 React Query 实现 tracked 的逻辑,给对应的 result 添加一个 get , 当属性被读取后,就会把对应的属性加入到 trackedResult 中。
trackResult(result) {
const trackedResult = {} as QueryObserverResult<TData, TError>
Object.keys(result).forEach((key) => {
Object.defineProperty(trackedResult, key, {
configurable: false,
enumerable: true,
get: () => {
this.trackedProps.add(key as keyof QueryObserverResult)
return result[key as keyof QueryObserverResult]
},
})
})
return trackedResult
}
select 对请求数据进行转换
请求返回的数据不一定完全符合我们的期望,我们可能需要对返回的数据进行转换,我们有如下方法可以进行数据转换:
- 在服务端,如果服务端可以将数据转换为我们想要的结构,那就完全不需要前端做什么,但是这种方法不一定完全可行。
- 在
queryFn中,我们可以在queryFn直接转换请求返回的数据。
const query = useQuery("key1", async () => {
const data = await mockFetch(Math.random());
return { value: data };
});
这样做的缺点是,每次 fetch 都会执行你的数据转换,如果转换的代价十分昂贵,这种方法的转换代价也会非常大。
- 使用
useMemo缓存数据转换的结果。
const query = useQuery("key1", () => mockFetch(Math.random()));
const data = useMemo(() => {
return { value: query.data };
}, [query.data]);
这样做的缺点是,没法利用 React Query 中的结构共享,如果 data 是一个数组,即便只有一项发生了变化,所有的组件也会重新渲染。而且可能还需要判断 query.data 是否是 undefined,对 undefined 进行特殊的处理。
- 使用
select选项。
const query = useQuery("key1", () => mockFetch(Math.random()), {
select: (data) => ({ value: data })
});
select 只有在 data 存在的时候才会被调用,所以你不用担心它是 undefined。并且可以利用 React Query 中的结构共享。
总结
我们介绍 React Query 是如何请求的和 React Query 如果引起组件渲染的了原理,简单总结一下 React Query 的流程:
- 与请求相关的底层逻辑都封装在了
Query中。 Query被保管在外部的queryClient中。queryClient通过QueryClientProvider注入到 React 中。 会在 App 顶层使用 Provider 全局注入到 React- 组件使用
useQuery与Query建立连接,订阅状态触发更新。
1 和 2 是请求 Query 的核心逻辑,它是与框架无关的。3 和 4 是与 React 框架结合,建立通信的部分。如果将 3 和 4 换一下,让 Query 与 Vue 结合,就有了 Vue Query。Vue Query 的 3 和 4 是这样的:
queryClient通过provide注入到 Vue 中
app.provide(clientKey, client)
- 组件更新响应式对象与
Query建立连接,订阅状态触发更新。
const observer = new Observer(queryClient, defaultedOptions.value)
const state = reactive(observer.getCurrentResult())
watch(
queryClient.isRestoring,
(isRestoring) => {
if (!isRestoring) {
unsubscribe.value()
unsubscribe.value = observer.subscribe((result) => {
updateState(state, result)
})
}
},
{ immediate: true },
)
React Query 中的核心逻辑 query-core 是与框架无关的,Vue Query 和 React Query 的原理是相通的,我们熟悉使用 React Query 后, 使用 Vue Query 也是轻而易举。
参考链接
react-query-v3.tanstack.com/reference/u…
转载自:https://juejin.cn/post/7208741162520739896