Inside TanStack Query - deduplication

9 min read

紀錄 TanStack Query 如何實作 deduplication,包含:

主要查看兩個 API 大致如何實作,分別為:

程式碼的部分,參考版本為:v5.45.0

Quick overview of TanStack Query components

QueryCache:儲存、查詢 Query 實例的相關實作

Query

QueryObserver

💡

tanstack/react-query 目前是透過 useSyncExternalStore 同步 Query 上的資料,source

下方圖片取用自 Inside React Query

QueryClient.fetchQuery()

先從 QueryClient.fetch() 開始看起,大致流程如下

  1. 透過 QueryCache.build() 得到一個 QueryQueryCache.build()Query key to query hash 看過,大致流程為:
    1. 先透過 queryKey 查詢是否已經存在對應 Query
    2. 如果有,回傳該 Query,反之新增一個 Query 實例,儲存至 QueryCache 後回傳
  2. 如果最新一筆請求已過時,回傳 Query.fetch(),反之沿用最新一筆請求的資料
queryClient.ts
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)

當有最新的請求送出:

  1. 透過 #dispatch 更新 Query.state (L34)
  2. 建立 retryer 處理 queryFn
query.ts
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,這個做法類似策略模式

useQueryuseInfiniteQuery 為例,兩者有不同的 QueryObserver 類別。useBaseQuery 只關注 QueryObserver 上約定好的策略介面,每個 QueryObserver 可以關注在自己的使用情境,有不同的實作細節

useQuery.ts
import { QueryObserver } from '@tanstack/query-core'
import { useBaseQuery } from './useBaseQuery'
 
//...
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
  return useBaseQuery(
    options,
    QueryObserver,
    queryClient
  )
}
useInfiniteQuery.ts
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 中可以得知:

useBaseQuery.ts
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(),這裡關注幾個子流程:

queryObserver.ts
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 上對應的狀態,大致流程如下:

  1. 呼叫 QueryCache.build(),根據最新的 this.#options 得到 Query 實例(查詢已存在或建立新的)
  2. 比對步驟 1 得到的 Query 實例,與 this.#currentQuery 的 reference 是否相同,如果不同 (L19-28):
    • 更新 #currentQuery 相關屬性
    • 取消訂閱 prevQuery
    • 訂閱 #currentQuery

queryObserver.ts
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 透過 useSyncExternalStoreQueryObserver 註冊 callback

queryObserver.ts
class QueryObserver<
  TQueryFnData = unknown,
  TError = DefaultError,
  TData = TQueryFnData,
  TQueryData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
> extends Subscribable<QueryObserverListener<TData, TError>> {
  // ...
}
subscribable.ts
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

queryObserver.ts
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()

queryObserver.ts
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(),流程大致為:

  1. 合併 QueryClient.defaultOptions.queries 及 per-query level options,合併結果這裡以 defaultedOptions 代稱
  2. 呼叫 QueryCache.build(defaultedOptions) 找到對應 Query (如果 Query 實例不存在,則建立新的實例)
  3. 根據 Query 實例狀態,決定是否呼叫 Query.fetch()
    • QueryClient.fetchQuery() 透過 Query.isStaleByTime() 檢查資料是否過期,如果未過期,沿用目前的資料,反之呼叫 Query.fetch()
    • useQuery 會透過 QueryObserver 與 component 做連結,會多比對:
      • 確保 QueryObserver 目前有 component 訂閱中
      • 確保 QueryObserver 對應正確的 Query
      • 確保 defaultedOptions.enabled !== false
      • 上述成立後,透過 Query.isStaleByTime() 決定是否呼叫 Query.fetch()
  4. 呼叫 Query.fetch(),如果 Query.state.fetchStatus !== 'idle',則檢查 retryer 是否存在已經建立的 promise

Reference