【译文】TanStack Query: 在变更之后自动使查询失效

2024年07月11上次更新于 3 个月前
编程翻译

查询(Query)和变更(Mutation)就像是一枚硬币的两面。查询(Query)定义了一个异步资源用于读取,这通常来源于数据获取。而变更(Mutation)则是用于更新此类资源的操作。

当一个变更(Mutation)完成时,它很可能会影响查询(Query)。例如,变更一个issue很可能会影响issue列表。因此,有些人可能会觉得奇怪,为什么不把QueryMutation关联起来?

原因其实很简单:TanStack Query完全没有主观地要求你如何管理自己的资源,并非每个人都喜欢在mutation之后重新获取数据。

有些情况下,我们希望将mutation返回的数据手动放进缓存中,以避免一次网络请求。此外还有很多不同的方法来执行invalidate(无效化)操作。

Tanstack Query 将 query 的数据缓存起来,开发者可以通过 invalidate 行为让其失效,从而自动执行 queryFn 请求最新的数据

  • 你会在onSuccess还是onSettled回调函数中执行invalidate操作?前者仅在变更成功之后调用,后者则在出错的时候也会调用。
  • 你想要等待 invalidations结束吗?等待invalidate行为结束的话将会保持mutation的挂起状态,直到invalidate结束(通常是refetch资源数据)。这是可能是一件好事,例如,如果你希望在提交模态框表单之后等待列表数据更新再关闭模态框。但是,如果你确定想要提交成功后立即关闭模态框,甚至可能会导致用户一开始因为网络请求的原因可能看不到最新的数据而感到惊奇,那么就没必要等待invalidations结束。

由于没有一种普适的解决方案,Tanstack Query并不提供对应开箱即用的功能。然而,感谢Tanstack Query支持全局缓存回调功能,在Tanstack Query中实现你想要的自动无效化并不难。

全局缓存回调(The Global Cache Callbacks)

变更(Mutations)有回调函数——onSuccessonErroronSettled,这些回调函数需要在每个单独的 useMutation 中定义。此外,这些回调函数也存在于 MutationCache 中。每一个queryClient实例在创建的时候都可以配置一个mutationCache,这个mutationCache里的回调函数会在每一个mutation调用之后执行(并且会在单独定义的mutation参数中定义的回调之前执行)。

如下所示:

import { QueryClient, MutationCache } from '@tanstack/react-query'

// 创建 MutationCache 并提供回调函数
const mutationCache = new MutationCache({
  onSuccess: (data, variables, context, mutation) => {
    console.log('Mutation was successful');
    if(Array.isArray(mutation?.meta?.awaits)) {
      return queryClient.invalidateQuires({
          queryKey: mutation.meta.awaits	        
        },{ cancelRefetch: false })
    }
  },
  onError: (error, variables, context) => {
    console.error('Mutation failed');
  },
  onSettled: (data, error, variables, context) => {
    console.log('Mutation settled');
  }
});

// 创建 QueryClient 并传入自定义的 MutationCache
const queryClient = new QueryClient({
  mutationCache,
  // 其他选项
});

这些回调函数接收的参数与useMutation中的参数相同,只是它们还会接收变更实例(Mutation instance)作为最后一个参数。而且与通常的回调函数一样,返回的 Promise 会被等待。

那么如何借此实现自动invalidation呢?我们只需要在全局回调函数内部调用queryClient.invalidateQueries()就行了:

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: () => {
      queryClient.invalidateQueries()
    },
  }),
})

通过上面的五行代码,我们得到了在 Remix(抱歉,React-Router)中相似的行为:在每次提交之后让一切失效。向你致敬,Alex 展示了这一思路:Alex / KATT 🐱 on X: "@housecor I just invalidate everything on every mutation https://t.co/0TALY8NdrV" / X

image-20240620173116614

但这是不是有点激进了?

可能是,也可能不是。这得看情况。再强调一次,为什么没有内置这个机制,因为有很多方法可以实现这一点。在这里我们需要澄清一点,无效化并不总是等同于重新获取数据

Invalidation仅仅是重新获取所有匹配的活跃查询,并将其余标记为stale(过期),这样它们在下次使用时会被重新获取。

这通常是一个很好且折衷的方案。想想你现在有一个带有筛选条件的issue列表,由于你将过滤条件对象作为queryKey的一部分,每次筛选条件对象变化都会生成不同queryKey的缓存记录,然而我们每次只渲染一个筛选对象对应的结果。重新获取所有数据会产生大量当前不必要的请求,我们也没法保证用户会修改筛选条件来让查看对应的结果,从而让这些请求有价值。

因此,invalidation 只重新获取我当前在屏幕上看到的内容(活跃的查询),以获取最新的视图,而其余的内容将在需要时重新获取。

尝试让特定的查询失效

好吧,精细化验证是有必要的。变更issue列表的数据后让个人资料的数据失效从而触发其重新获取个人资料???这真的没什么意义可言。

再说一次,这需要权衡考虑。如果你明确知道需要重新获取什么数据,那么精细化控制让哪些Query失效是一个很好的实践方案。如果你希望代码尽量简单,宁愿频繁获取多一些数据也不希望错过重新回去的机会,那么重新获取所有匹配的活跃查询,并将其余标记为stale(过期)也可以。

过去,我们经常进行精细化重新验证,结果发现稍后需要添加的另一个资源与使用的失效模式不匹配。在那时,我们不得不遍历所有变化回调函数,看看是否需要重新获取该资源。这样做很麻烦且容易出错。

此外,我们通常为大多数查询使用中等大小的staleTime(大约2分钟)。因此,在无关的用户交互后进行失效的影响可以忽略不计。

当然,你可以使你的逻辑更复杂,使重新验证更智能化。以下是我过去使用过的一些技术:

将其与mutationKey相关联。

MutationKeyQueryKey之间没有共同点,而且MutationKey也是可选的。如果需要的话,你可以通过使用MutationKey来指定应该失效哪些查询,从而将它们关联起来。

如果你不需要标记一个 mutation,那么定义的时候不提供mutationKey也无所谓。

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: (_data, _variables, _context, mutation) => {
      queryClient.invalidateQueries({
        queryKey: mutation.options.mutationKey,
      })
    },
  }),
})

这样一来就可以通过定义时的mutationKey去让对应queryKey的数据失效,触发重新执行对应的queryFn请求数据,如果你没有提供mutationKey,它仍然会让所有匹配的活跃查询失效。

根据 staleTime 排除 Queries

我经常通过给查询设置staleTime: Infinity来将其标记为“静态”。如果我们不希望这些查询失效,可以设置 queryClient实例的staleTime设置,并通过断言(predicate)过滤器来排除它们。

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: (_data, _variables, _context, mutation) => {
      const nonStaticQueries = (query: Query) => {
        // 获取默认的失效时间
        const defaultStaleTime =
          queryClient.getQueryDefaults(query.queryKey).staleTime ?? 0
        // 不同的 useQuery 使用相同的 queryKey 仅产生多个观察者,收集失效时间数组
        const staleTimes = query.observers
          .map((observer) => observer.options.staleTime)
          .filter((staleTime) => staleTime !== undefined)
                // 获取失效时间
        const staleTime =
          query.getObserversCount() > 0
            ? Math.min(...staleTimes)
            : defaultStaleTime
                // 判断是否使用默认的失效机制
        return staleTime !== Number.POSITIVE_INFINITY
      }

      queryClient.invalidateQueries({
        queryKey: mutation.options.mutationKey,
        predicate: nonStaticQueries,
      })
    },
  }),
})

invalidateQueries 中的 predicate函数将对每一个 query实例进行遍历,并且根据其返回的布尔值决定是否要让其失效。

确定查询的实际staleTime并不是那么简单,因为staleTime是观察者级别的属性。但这是可行的,我们还可以将断言过滤器与其他过滤器(如queryKey)结合使用。很棒。

使用 meta 选项

我们可以使用meta字段来存储关于mutation的元数据(静态信息)。举个例子,我们可以添加一个invalidates字段来标记这个mutation,然后再用这些标记来模糊匹配我们希望失效的查询:

import { matchQuery } from '@tanstack/react-query'

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: (_data, _variables, _context, mutation) => {
      queryClient.invalidateQueries({
        predicate: (query) =>
          // 一次性让所有匹配的标签失效
          // 或者让所有活跃内容失效(亦或是 false 不让任何缓存失效)
          mutation.meta?.invalidates?.some((queryKey) =>
            matchQuery({ queryKey }, query)
          ) ?? true,
      })
    },
  }),
})

// usage:
useMutation({
  mutationFn: updateLabel,
  meta: {
    invalidates: [['issues'], ['labels']],
  },
})

如上所示,我们通过meta字段让issuelabels匹配的项失效,触发其重新获取数据。

这里,我们仍然使用谓词函数来进行一次调用到queryClient.invalidateQueries。但在函数内部,我们使用matchQuery进行模糊匹配,你可以从 React Query 中导入这个函数。这个函数在将单个queryKey作为过滤器传递时内部也会使用,但现在我们可以用多个键来进行匹配。

这种模式比在useMutationonSuccess回调函数中调用queryClient.invalidateQueries更好一些,至少我们不用每次都使用useQueryClient来引入queryClient。另外,这种模式还和默认让所有内容失效结合起来,非常灵活。

在 TypeScript 项目中使用 meta 属性,需要修改其默认类型:

declare module '@tanstack/react-query' {
  interface Register {
    mutationMeta: {
      invalidates?: Array<QueryKey>
    }
  }
}

等还是不等?

在上面的例子中我们几乎等待invalidation结束,如果你希望你的变更(mutation)尽快结束,那么这就够了。

我经常遇到一个具体的场景是希望所有内容都失效,但是却让mutation保持挂起的状态,直到其中若干重要的更新完成。例如,我希望变更标签数据后等待特定标签的refetch完成,但是并不等待其他同样invalidation的数据重新获取完成。

我们可以扩展一下meta对象来实现这个需求,举个例子:

useMutation({
  mutationFn: updateLabel,
  meta: {
    invalidates: [['labels', 'issues']],
    awaits: [['labels']],
  },
})

以下内容为译者扩展,原文并未出现如何实现 awaits 控制的代码

我们可以在配置 queryClient的时候,扩展onSuccess函数,内部检查是否提供awaits数组来控制返回值。

 mutationCache: new MutationCache({
    onSuccess(data, variables, context, mutation): unknown {
      queryClient.invalidateQueries({
        predicate: (query) =>
          // invalidate all matching tags at once
          // or nothing if no meta is provided
          mutation.meta?.invalidates?.some((queryKey) =>
            matchQuery({ queryKey }, query)
          ) ?? false,
      });
      const awaits = mutation.meta?.awaits;
      if (Array.isArray(awaits)) {
        return queryClient.invalidateQueries(
          { queryKey: awaits },
          { cancelRefetch: false }
        );
      }
    },
  }),

这里我们如果提供了awaits内容,就将Promise返回,useMutation处定义的onSuccess函数将会在这个Promise转为resolve状态之后才执行,如此一来我们就可以等待指定的query实例失效并且请求新数据之后再执行自己的onSuccess回调函数逻辑了。

回到原文。

或者,我们可以利用 MutationCache 上的回调在 useMutation 上的回调之前运行的机制,配合全局回调设置为使所有内容无效,再添加一个本地回调来等待我们想要的结果:

const queryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSuccess: () => {
      queryClient.invalidateQueries()
    },
  }),
})

useMutation({
  mutationFn: updateLabel,
  onSuccess: () => {
    // returning the Promise to await it
    return queryClient.invalidateQueries(
      { queryKey: ['labels'] },
      { cancelRefetch: false }
    )
  },
})

这将会:

  • 全局配置中让所有匹配的 query 失效,但是在回调中我们既没有等待,也没有返回Promise内容,因此这是一个一劳永逸的失效行为
  • 然后,我们本地提供的 onSuccess回调将会立即执行,我们可以主动返回一个仅让匹配['labels']查询key的失效 Promise状态,因此 Mutation 将保持待pending状态,直到重新获取['labels'] 为止。

cancelRefetch

我们将 cancelRefetch 设置为 false 传递给 invalidateQueries,默认为 true 是因为我们通常希望命令式重新获取调用优先并取消当前正在运行的调用,以保证之后的数据是最新的。

但是在这里,情况刚好相反。由于我们的全局回调已经使所有内容无效,包括我们想要等待的查询,因此我们只需使用无效查询来“拾取”已经在运行的 Promise 并返回它。如果你不这样,那么就会看到一个针对['labels']的请求被创建。

我认为这表明添加您熟悉的自动失效抽象并不需要很多代码。请记住,每个抽象都有成本:它是一个新的 API,需要正确学习、理解和应用。

我希望通过展示所有这些可能性,可以更清楚地了解为什么我们没有在 React Query 中内置任何内容。找到一个足够灵活、能够覆盖所有情况而不臃肿的 API 并不是一件容易的事情。为此,我更愿意为您提供在用户空间中构建此工具的工具。

not-by-ainot-by-ai
文章推荐

Friends

Jimmy老胡SubmaraBruce SongScarsu