Inside TanStack Query - QueryCache queries

6 min read

看了 Inside React Query 後,想進一步了解:

程式碼是參考 v5.45.0

QueryCache queries

QueryClient 開始看起,可以在 QueryClient.prototype.constructor 發現,建立 QueryClient 實例時,同時也會建立 QueryCache 實例(如果沒額外提供)

class QueryClient {
  #queryCache: QueryCache
  // ...
  constructor() {
    this.#queryCache = config.queryCache || new QueryCache()
    // ...
  }
  // ...
}

接著看到 QueryCache.prototype.constructor,可以得知,QueryCache.queries 是用來存放 Query 實例的 Map,key 為 string 型別

class QueryCache extends Subscribable<QueryCacheListener> {
  #queries: QueryStore
 
  constructor(public config: QueryCacheConfig = {}) {
    super()
    this.#queries = new Map<string, Query>()
  }
  // ...
}

Query key to query hash

知道 queries 是一個 Map 之後,想了解要如何新增一筆 Query,在 QueryCache.add() 得知:

class QueryCache extends Subscribable<QueryCacheListener> {
  // ...
  add(query: Query<any, any, any, any>): void {
    if (!this.#queries.has(query.queryHash)) {
      this.#queries.set(query.queryHash, query)
 
      this.notify({
        type: 'added',
        query,
      })
    }
  }
  // ...
}

繼續深入 queryHash,這裡方向是先觀察 QueryCache.add() 如何被調用,可以在 QueryCache.build() 中得知:

class QueryCache extends Subscribable<QueryCacheListener> {
  #queries: QueryStore
 
  build<
    TQueryFnData = unknown,
    TError = DefaultError,
    TData = TQueryFnData,
    TQueryKey extends QueryKey = QueryKey,
  >(
    client: QueryClient,
    options: WithRequired<
      QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
      'queryKey'
    >,
    state?: QueryState<TData, TError>,
  ): Query<TQueryFnData, TError, TData, TQueryKey> {
    const queryKey = options.queryKey
    const queryHash =
      options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
    let query = this.get<TQueryFnData, TError, TData, TQueryKey>(queryHash)
 
    if (!query) {
      query = new Query({
        cache: this,
        queryKey,
        queryHash,
        options: client.defaultQueryOptions(options),
        state,
        defaultOptions: client.getQueryDefaults(queryKey),
      })
      this.add(query)
    }
 
    return query
  }
}

繼續看到 hashQueryKeyByOptions,這個 function 用途很單純:

💡

補充:以 useQuery 為例,hashQueryKeyByOptions 收到的 options,是在初始 QueryClient 設置的 defaultOptions.queriesuseQuery 設置的 queryOptions,合併後的結果


function hashQueryKeyByOptions<TQueryKey extends QueryKey = QueryKey>(
  queryKey: TQueryKey,
  options?: Pick<QueryOptions<any, any, any, any>, 'queryKeyHashFn'>,
): string {
  const hashFn = options?.queryKeyHashFn || hashKey
  return hashFn(queryKey)
}

預設的 hashKey 是使用 JSON.stringify搭配 replacer parameter,針對物件進行 key 的排序,目的是:確保不會因為物件內 key 的順序不同,得到不一致的 hash

/**
 * Default query & mutation keys hash function.
 * Hashes the value into a stable hash.
 */
function hashKey(queryKey: QueryKey | MutationKey): string {
  return JSON.stringify(queryKey, (_, val) =>
    isPlainObject(val)
      ? Object.keys(val)
          .sort()
          .reduce((result, key) => {
            result[key] = val[key]
            return result
          }, {} as any)
      : val,
  )
}
 
hashKey(['todos', { page: 1, status: 'done' }])
// '["todos",{"page":1,"status":"done"}]'
 
hashKey(['todos', { status: 'done', page: 1 }])
// '["todos",{"page":1,"status":"done"}]'
 
(hashKey(['todos', { page: 1, status: 'done' }])
  === hashKey(['todos', { status: 'done', page: 1 }]))
// true

Matching queries

紀錄 TanStack Query 如何透過 queryKey 找到對應的 Query,像是:

// Invalidate every query with a key that starts with `todos`
queryClient.invalidateQueries({ queryKey: ['todos'] })

QueryClient.invalidateQueries() 開始看起,可以發現:調用 QueryCache.findAll() 得到 Query[]

class QueryClient {
  // ...
  invalidateQueries(
    filters: InvalidateQueryFilters = {},
    options: InvalidateOptions = {},
  ): Promise<void> {
    return notifyManager.batch(() => {
      this.#queryCache.findAll(filters).forEach((query) => {
        query.invalidate()
      })
 
      if (filters.refetchType === 'none') {
        return Promise.resolve()
      }
      const refetchFilters: RefetchQueryFilters = {
        ...filters,
        type: filters.refetchType ?? filters.type ?? 'active',
      }
      return this.refetchQueries(refetchFilters, options)
    })
  }
  // ...
}

QueryCache.findAll() 的實作,大致流程:

  1. 呼叫 QueryCache.getAll(),將 QueryCache.queries 內所有的 QueryQuery[] 表示,這裡以 allQueries 代稱
  2. allQueries.filter((query) => matchQuery(filters, query))
class QueryCache extends Subscribable<QueryCacheListener> {
  #queries: QueryStore
 
  constructor(public config: QueryCacheConfig = {}) {
    super()
    this.#queries = new Map<string, Query>()
  }
  // ...
  getAll(): Array<Query> {
    return [...this.#queries.values()]
  }
 
  findAll(filters: QueryFilters = {}): Array<Query> {
    const queries = this.getAll()
    return Object.keys(filters).length > 0
      ? queries.filter((query) => matchQuery(filters, query))
      : queries
  }
  // ...
}

matchQuery 中得知,如果 filters.queryKey 存在:

function matchQuery(
  filters: QueryFilters,
  query: Query<any, any, any, any>,
): boolean {
  const {
    type = 'all',
    exact,
    fetchStatus,
    predicate,
    queryKey,
    stale,
  } = filters
 
  if (queryKey) {
    if (exact) {
      if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) {
        return false
      }
    } else if (!partialMatchKey(query.queryKey, queryKey)) {
      return false
    }
  }
 
  if (type !== 'all') {
    const isActive = query.isActive()
    if (type === 'active' && !isActive) {
      return false
    }
    if (type === 'inactive' && isActive) {
      return false
    }
  }
 
  if (typeof stale === 'boolean' && query.isStale() !== stale) {
    return false
  }
 
  if (fetchStatus && fetchStatus !== query.state.fetchStatus) {
    return false
  }
 
  if (predicate && !predicate(query)) {
    return false
  }
 
  return true
}

partialMatchKey(['todos', { page: 1 }], ['todos']) 為例:

export function partialMatchKey(a: QueryKey, b: QueryKey): boolean {
  if (a === b) {
    return true
  }
 
  if (typeof a !== typeof b) {
    return false
  }
 
  if (a && b && typeof a === 'object' && typeof b === 'object') {
    return !Object.keys(b).some((key) => !partialMatchKey(a[key], b[key]))
  }
 
  return false
}
💡

補充:QueryCache.find 也使是透過 matchQuery,不過預設將 filters.exact 設為 true

class QueryCache extends Subscribable<QueryCacheListener> {
// ...
find<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData>(
  filters: WithRequired<QueryFilters, 'queryKey'>,
): Query<TQueryFnData, TError, TData> | undefined {
  const defaultedFilters = { exact: true, ...filters }
 
  return this.getAll().find((query) =>
    matchQuery(defaultedFilters, query),
  ) as Query<TQueryFnData, TError, TData> | undefined
}
// ...
}

Summary

Reference