面试官: 你真的知道 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