Inside TanStack Query - deduplication
紀錄 TanStack Query 如何實作 deduplication,包含:
queryKey
對應的請求進行中,避免送出重複的請求queryKey
對應的最新一筆請求,尚未超過staleTime
,沿用相同的結果
主要查看兩個 API 大致如何實作,分別為:
QueryClient.fetchQuery()
@tanstack/react-query
的useQuery
程式碼的部分,參考版本為:v5.45.0
Quick overview of TanStack Query components
QueryCache
:儲存、查詢 Query
實例的相關實作
Query
:
- 執行(重試、取消 )
queryFn
相關邏輯 - 紀錄請求相關狀態,例如:
data
,dataUpdatedAt
,fetchStatus
,fetchFailureCount
, etc. - 提供
QueryObserver
訂閱的機制,並通知變更
QueryObserver
:
- 每個
QueryObserver
只會訂閱一個Query
- 當
Query
狀態更新時,會通知QueryObserver
,由QueryObserver
判斷是否需要觸發 component 更新 - 以 react 為例,在 component 中,可以透過
useQuery
得到Query
上的資料,QueryObserver
會在第一次呼叫useQuery
時被建立,作為 query-core 與 component 之間的連結件
tanstack/react-query 目前是透過 useSyncExternalStore
同步 Query
上的資料,source
下方圖片取用自 Inside React Query
QueryClient.fetchQuery()
先從 QueryClient.fetch()
開始看起,大致流程如下
- 透過
QueryCache.build()
得到一個Query
,QueryCache.build()
在 Query key to query hash 看過,大致流程為:- 先透過
queryKey
查詢是否已經存在對應Query
- 如果有,回傳該
Query
,反之新增一個Query
實例,儲存至QueryCache
後回傳
- 先透過
- 如果最新一筆請求已過時,回傳
Query.fetch()
,反之沿用最新一筆請求的資料
class QueryClient {
#queryCache: QueryCache
// ...
fetchQuery<
TQueryFnData,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
TPageParam = never,
>(
options: FetchQueryOptions<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>,
): Promise<TData> {
const defaultedOptions = this.defaultQueryOptions(options)
// ...
const query = this.#queryCache.build(this, defaultedOptions)
return query.isStaleByTime(
resolveStaleTime(defaultedOptions.staleTime, query),
)
? query.fetch(defaultedOptions)
: Promise.resolve(query.state.data as TData)
}
// ...
}
接著看到 Query.fetch()
Query.fetch()
會參考 state.fetchStatus
,當請求進行中,沿用 retryer 上的 promise,藉此避免送出重複請求 (L20-25)
當有最新的請求送出:
- 透過
#dispatch
更新Query.state
(L34) - 建立
retryer
處理queryFn
class Query<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> extends Removable {
// ...
state: QueryState<TData, TError>
#retryer?: Retryer<TData>
// ...
fetch(
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
fetchOptions?: FetchOptions<TQueryFnData>,
): Promise<TData> {
if (this.state.fetchStatus !== 'idle') {
if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
// Silently cancel current fetch if the user wants to cancel refetch
this.cancel({ silent: true })
} else if (this.#retryer) {
// make sure that retries that were potentially cancelled due to unmounts can continue
this.#retryer.continueRetry()
// Return current promise if we are already fetching
return this.#retryer.promise
}
}
// ...
// Set to fetching state if not already in it
if (
this.state.fetchStatus === 'idle' ||
this.state.fetchMeta !== context.fetchOptions?.meta
) {
this.#dispatch({ type: 'fetch', meta: context.fetchOptions?.meta })
}
// ...
// Try to fetch the data
this.#retryer = createRetryer(
//...
)
return this.#retryer.start()
}
}
useQuery
在 react 中,呼叫 useQuery
主要的實作細節在 useBaseQuery
,這個做法類似策略模式
- 每個
QueryObserver
都需遵守約定的策略介面 - 將不同場景的算法封裝在不同的
QueryObserver
實作,透過類別取代 if-else
以 useQuery
及 useInfiniteQuery
為例,兩者有不同的 QueryObserver
類別。useBaseQuery
只關注 QueryObserver
上約定好的策略介面,每個 QueryObserver
可以關注在自己的使用情境,有不同的實作細節
import { QueryObserver } from '@tanstack/query-core'
import { useBaseQuery } from './useBaseQuery'
//...
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
return useBaseQuery(
options,
QueryObserver,
queryClient
)
}
import { InfiniteQueryObserver } from '@tanstack/query-core'
import { useBaseQuery } from './useBaseQuery'
//...
export function useInfiniteQuery(
options: UseInfiniteQueryOptions,
queryClient?: QueryClient,
) {
return useBaseQuery(
options,
InfiniteQueryObserver as typeof QueryObserver,
queryClient,
)
}
在 useBaseQuery
中可以得知:
- 每次 re-render 會得到新的
defaultedOptions
(L19-L20) - 調用
useBaseQuery
,會建立一個 stable 的observer
實例 (L22-L28) - 透過
useEffect
,當defaultedOptions
或observer
變化時,調用observer.setOptions(defaultedOptions)
(L30-L34)
export function useBaseQuery<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey extends QueryKey,
>(
options: UseBaseQueryOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
Observer: typeof QueryObserver,
queryClient?: QueryClient,
): QueryObserverResult<TData, TError> {
// ...
const client = useQueryClient(queryClient)
const defaultedOptions = client.defaultQueryOptions(options)
const [observer] = React.useState(
() =>
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
client,
defaultedOptions,
),
)
// ...
React.useEffect(() => {
// Do not notify on updates because of changes in the options because
// these changes should already be reflected in the optimistic result.
observer.setOptions(defaultedOptions, { listeners: false })
}, [defaultedOptions, observer])
// ...
}
接著看到 QueryObserver.setOptions()
,這裡關注幾個子流程:
this.#updateQuery()
(L39)this.hasListeners()
(L40)shouldFetchOptionally(...)
(L45-50)this.#executeFetch()
(L52)
class QueryObserver<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> extends Subscribable<QueryObserverListener<TData, TError>> {
#client: QueryClient
#currentQuery: Query<TQueryFnData, TError, TQueryData, TQueryKey> = undefined!
// ...
constructor(
client: QueryClient,
public options: QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
) {
// ...
}
setOptions(
options: QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
notifyOptions?: NotifyOptions,
): void {
const prevOptions = this.options
const prevQuery = this.#currentQuery
this.options = this.#client.defaultQueryOptions(options)
// ...
this.#updateQuery()
const mounted = this.hasListeners()
// Fetch if there are subscribers
if (
mounted &&
shouldFetchOptionally(
this.#currentQuery,
prevQuery,
this.options,
prevOptions,
)
) {
this.#executeFetch()
}
// ...
}
}
QueryObserver.#updateQuery()
會檢查 QueryObserver
對應的 Query
實例是否改變,如果有則更新 QueryObserver
上對應的狀態,大致流程如下:
- 呼叫
QueryCache.build()
,根據最新的this.#options
得到Query
實例(查詢已存在或建立新的) - 比對步驟 1 得到的
Query
實例,與this.#currentQuery
的 reference 是否相同,如果不同 (L19-28):- 更新
#currentQuery
相關屬性 - 取消訂閱
prevQuery
- 訂閱
#currentQuery
- 更新
class QueryObserver<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> extends Subscribable<QueryObserverListener<TData, TError>> {
#client: QueryClient
#currentQuery: Query<TQueryFnData, TError, TQueryData, TQueryKey> = undefined!
// ...
#updateQuery(): void {
const query = this.#client.getQueryCache().build(this.#client, this.options)
if (query === this.#currentQuery) {
return
}
const prevQuery = this.#currentQuery as
| Query<TQueryFnData, TError, TQueryData, TQueryKey>
| undefined
this.#currentQuery = query
this.#currentQueryInitialState = query.state
if (this.hasListeners()) {
prevQuery?.removeObserver(this)
query.addObserver(this)
}
}
}
QueryObserver.#hasListeners()
是繼承自 Subscribable
。應該是判斷有沒有 component 透過 useSyncExternalStore
向 QueryObserver
註冊 callback
class QueryObserver<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> extends Subscribable<QueryObserverListener<TData, TError>> {
// ...
}
class Subscribable<TListener extends Function = Listener> {
protected listeners: Set<TListener>
constructor() {
this.listeners = new Set()
this.subscribe = this.subscribe.bind(this)
}
subscribe(listener: TListener): () => void {
//...
}
hasListeners(): boolean {
return this.listeners.size > 0
}
protected onSubscribe(): void {
// ...
}
protected onUnsubscribe(): void {
// ...
}
}
shouldFetchOptionally
相關實作如下,與 deduplication 相關的判斷,主要在 isStale
:
options.enabled
不為 false- 未超過指定的
option.staleTime
function shouldFetchOptionally(
query: Query<any, any, any, any>,
prevQuery: Query<any, any, any, any>,
options: QueryObserverOptions<any, any, any, any, any>,
prevOptions: QueryObserverOptions<any, any, any, any, any>,
): boolean {
return (
(query !== prevQuery || prevOptions.enabled === false) &&
(!options.suspense || query.state.status !== 'error') &&
isStale(query, options)
)
}
function isStale(
query: Query<any, any, any, any>,
options: QueryObserverOptions<any, any, any, any, any>,
): boolean {
return (
options.enabled !== false &&
query.isStaleByTime(resolveStaleTime(options.staleTime, query))
)
}
QueryObserver.#executeFetch
可以在下方程式碼L16-19 得知,也是呼叫 Query.fetch()
class QueryObserver<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> extends Subscribable<QueryObserverListener<TData, TError>> {
// ...
#executeFetch(
fetchOptions?: Omit<ObserverFetchOptions, 'initialPromise'>,
): Promise<TQueryData | undefined> {
// Make sure we reference the latest query as the current one might have been removed
this.#updateQuery()
// Fetch
let promise: Promise<TQueryData | undefined> = this.#currentQuery.fetch(
this.options as QueryOptions<TQueryFnData, TError, TQueryData, TQueryKey>,
fetchOptions,
)
if (!fetchOptions?.throwOnError) {
promise = promise.catch(noop)
}
return promise
}
}
Summary
呼叫 QueryClient.fetchQuery()
或 useQuery
,都是透過 queryKey
找出 Query
實例 (一對一的關係),根據 Query
實例上的狀態,決定是否呼叫 Query.fetch()
,流程大致為:
- 合併
QueryClient.defaultOptions.queries
及 per-query level options,合併結果這裡以defaultedOptions
代稱 - 呼叫
QueryCache.build(defaultedOptions)
找到對應Query
(如果Query
實例不存在,則建立新的實例) - 根據
Query
實例狀態,決定是否呼叫Query.fetch()
QueryClient.fetchQuery()
透過Query.isStaleByTime()
檢查資料是否過期,如果未過期,沿用目前的資料,反之呼叫Query.fetch()
useQuery
會透過QueryObserver
與 component 做連結,會多比對:- 確保
QueryObserver
目前有 component 訂閱中 - 確保
QueryObserver
對應正確的Query
- 確保
defaultedOptions.enabled !== false
- 上述成立後,透過
Query.isStaleByTime()
決定是否呼叫Query.fetch()
- 確保
- 呼叫
Query.fetch()
,如果Query.state.fetchStatus !== 'idle'
,則檢查retryer
是否存在已經建立的 promise