React Query and TypeScript

537 阅读10分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

本文翻译自 TkDodo 的 # React Query and TypeScript

TypeScript 在前端社区中越来越受欢迎,这似乎已成为共识。许多开发人员期望库要么使用 TypeScript 编写,要么至少提供良好的类型定义。对我来说,如果一个库使用 TypeScript 编写,那么类型定义就是最好的文档。它永远不会出错,因为它直接反映了实现。在阅读 API 文档之前,我经常先查看类型定义。

React Query 最初是用 JavaScript 编写的(版本 1),然后在版本 2 中重写为 TypeScript。这意味着目前对 TypeScript 用户有很好的支持。然而,由于 React Query 是动态且不持有意见的库,使用 TypeScript 时可能会遇到一些“坑”。让我们一一来看看,以使您在使用过程中更顺畅。

泛型

React Query 严重依赖泛型。这是必要的,因为库本身不实际获取数据,也不能知道您的 API 返回的数据类型。

在官方文档的 TypeScript 部分并不是很详细,它告诉我们在调用 useQuery 时要显式指定所需的泛型:

tsxCopy code
function useGroups() {
  return useQuery<Group[], Error>({
    queryKey: ['groups'],
    queryFn: fetchGroups,
  });
}

更新:文档已更新,不再主要鼓励这种模式。

随着时间的推移,React Query 在 useQuery 钩子中添加了更多的泛型(现在共有四个),主要是因为添加了更多功能。上面的代码可以工作,并且会确保我们自定义钩子的 data 属性正确地被类型化为 Group[] | undefined,以及我们的 error 将是 Error | undefined 类型。但是,对于更高级的用例,特别是当需要其他两个泛型时,它将无法正常工作。

四个泛型

以下是 useQuery 钩子的当前定义:

tsxCopy code
export function useQuery<
  TQueryFnData = unknown,
  TError = unknown,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
>(
  ...

这里有很多东西,让我们尝试拆分一下:

  • TQueryFnData:从 queryFn 返回的类型。在上面的示例中,它是 Group[]。
  • TError:从 queryFn 预期的错误类型。在示例中是 Error。
  • TData:我们的 data 属性最终会有的类型。仅在使用 select 选项时相关,因为 data 属性可以与 queryFn 返回的类型不同。否则,它将默认为 queryFn 返回的类型。
  • TQueryKey:我们的 queryKey 的类型,仅在使用传递给 queryFn 的 queryKey 时相关。

正如您也可以看到的,所有这些泛型都有默认值,这意味着如果您不提供它们,TypeScript 将回退到这些类型。这与 JavaScript 中的默认参数几乎相同:

jsCopy code
function multiply(a, b = 2) {
  return a * b;
}

multiply(10); // ✅ 20
multiply(10, 3); // ✅ 30

类型推断

TypeScript在许多情况下都能够自动推断(或确定)变量的类型,这是它最擅长的。这不仅使代码编写变得更简单(因为您不必键入所有的类型 😅),而且还使代码阅读更容易。在许多情况下,这样的自动推断使代码看起来与JavaScript几乎完全相同。下面是一些简单的类型推断示例:

typescriptCopy code
const num = Math.random() + 5 // ✅ `number`

// 🚀 greeting和greet函数的返回结果都将是string类型
function greet(greeting = 'ciao') {
  return `${greeting}, ${getName()}`
}

对于泛型,它们通常也可以从其使用中推断出来,这非常棒。您也可以手动提供泛型参数,但在许多情况下,您不需要这样做。

typescriptCopy code
function identity<T>(value: T): T {
  return value
}

// 🚨 不需要提供泛型参数
let result = identity<number>(23)

// ⚠️ 也不需要为结果添加类型注解
let result: number = identity(23)

// 😎 正确地推断为`string`类型
let result = identity('react-query')

通过充分利用TypeScript的类型推断功能,可以使代码更简洁,更具表现力,同时也更容易阅读和维护。这是让TypeScript变得如此强大和受欢迎的一个重要原因。

部分类型参数推断

在TypeScript中,目前还不存在"Partial Type Argument Inference"(部分类型参数推断)功能(可以查看这个未解决的问题)。这基本上意味着如果您提供了一个泛型类型参数,您必须提供所有的泛型类型参数。但由于React Query为泛型类型参数提供了默认值,我们可能不会立即注意到这一点。结果导致的错误信息可能会非常难以理解。让我们看一个实际出现问题的例子:

typescriptCopy code
function useGroupCount() {
  return useQuery<Group[], Error>({
    queryKey: ['groups'],
    queryFn: fetchGroups,
    select: (groups) => groups.length,
    // 🚨 Type '(groups: Group[]) => number' is not assignable to type '(data: Group[]) => Group[]'.
    // Type 'number' is not assignable to type 'Group[]'.ts(2322)
  })
}

因为我们没有提供第三个泛型类型参数,系统会使用默认值,这里默认值也是Group[],但是我们的select函数返回了一个number。解决这个问题的方法是简单地添加第三个泛型类型参数:

typescriptCopy code
function useGroupCount() {
  // ✅ fixed it
  return useQuery<Group[], Error, number>({
    queryKey: ['groups'],
    queryFn: fetchGroups,
    select: (groups) => groups.length,
  })
}

在目前无法使用"Partial Type Argument Inference"的情况下,我们必须按照已有的方式来使用泛型。

那么有什么替代方案呢?

推断一切事物

让我们首先不传入任何泛型,让TypeScript自行推断。为了让这个方法生效,我们需要queryFn有一个良好的返回类型。当然,如果您内联该函数且没有明确指定返回类型,TypeScript会将其推断为any类型,因为axios或fetch返回的就是any类型:

typescriptCopy code
function useGroups() {
  // 🚨 data将会是`any`类型
  return useQuery({
    queryKey: ['groups'],
    queryFn: () => axios.get('groups').then((response) => response.data),
  })
}

如果(和我一样)您喜欢将API层与查询分开,并且希望避免隐式any类型,您仍然需要添加类型定义,以便React Query可以进行推断:

typescriptCopy code
function fetchGroups(): Promise<Group[]> {
  return axios.get('groups').then((response) => response.data)
}

// ✅ data将会是`Group[] | undefined`类型
function useGroups() {
  return useQuery({ queryKey: ['groups'], queryFn: fetchGroups })
}

// ✅ data将会是`number | undefined`类型
function useGroupCount() {
  return useQuery({
    queryKey: ['groups'],
    queryFn: fetchGroups,
    select: (groups) => groups.length,
  })
}

这种方法的优势是:

  • 不再需要手动指定泛型类型
  • 适用于需要第三个(select)和第四个(QueryKey)泛型类型的情况
  • 如果添加更多泛型类型,它仍然可以正常工作
  • 代码看起来更简洁,更像JavaScript

那么错误呢?

你可能会问,那错误怎么办?默认情况下,如果没有指定泛型类型,error会被推断为unknown类型。这可能听起来像是一个bug,为什么不是Error类型?但实际上,这是有意为之的,因为在JavaScript中,您可以抛出任何东西 - 它不一定是Error类型:

typescriptCopy code
throw 5
throw undefined
throw Symbol('foo')

由于React Query无法控制返回Promise的函数的类型,它也无法知道它可能产生什么类型的错误。因此,unknown类型是正确的。一旦TypeScript允许在调用具有多个泛型类型的函数时跳过某些泛型类型(参见此问题获取更多信息),我们可能会更好地处理这个问题。但目前,如果我们需要处理错误并且不想使用泛型类型,我们可以通过instanceof检查来缩小类型:

typescriptCopy code
const groups = useGroups()

if (groups.error) {
  // 🚨 这样不起作用,因为:Object is of type 'unknown'.ts(2571)
  return <div>An error occurred: {groups.error.message}</div>
}

// ✅ instanceof检查将缩小类型为`Error`
if (groups.error instanceof Error) {
  return <div>An error occurred: {groups.error.message}</div>
}

由于我们需要进行某种检查来查看是否有错误,instanceof检查看起来并不是一个坏主意,它还可以确保我们的错误在运行时实际上有一个message属性。这也符合TypeScript计划在4.4版本中推出的计划,其中他们将引入一个新的编译器标志useUnknownInCatchVariables,其中catch变量将使用unknown而不是any(参见此处)。

类型缩小

在使用React Query时,我很少使用解构。首先,像data和error这样的名称相当普遍(特意如此),所以您可能会重新命名它们。保持整个对象将保留数据的上下文信息以及错误的来源。这还将有助于TypeScript在使用status字段或其中一个status布尔值时缩小类型,如果您使用解构,则无法实现这一点:

typescriptCopy code
const { data, isSuccess } = useGroups()
if (isSuccess) {
  // 🚨 这里data仍然是`Group[] | undefined`类型
}

const groupsQuery = useGroups()
if (groupsQuery.isSuccess) {
  // ✅ groupsQuery.data现在将是`Group[]`类型
}

这与React Query无关,这只是TypeScript的工作方式。@danvdk对此行为有一个很好的解释。

至于danvdk @danvdk @TkDodo的评论完全正确,TypeScript对单个符号的类型细化。一旦将它们拆分开来,它就无法再跟踪它们之间的关系。一般来说,这样做对于计算来说可能很困难。这对于人们来说也可能很困难。

image.png

更新:TypeScript 4.6版本已经为解构的区分联合类型添加了控制流分析,这样上面的例子就可以正常工作了。所以这不再是一个问题。🙌

在启用(enabled)选项方面的类型安全性

我一直对启用(enabled)选项表达了我的喜爱,但是在类型级别上,如果您想在依赖查询中使用它,并且在某些参数尚未定义时禁用查询,它可能会有些棘手:

typescriptCopy code
function fetchGroup(id: number): Promise<Group> {
  return axios.get(`group/${id}`).then((response) => response.data)
}

function useGroup(id: number | undefined) {
  return useQuery({
    queryKey: ['group', id],
    queryFn: () => fetchGroup(id),
    enabled: Boolean(id),
  })
  // 🚨 参数类型为“number | undefined”的参数无法分配给类型为“number”的参数。ts(2345)
}

从技术上讲,TypeScript是正确的,因为id可能为undefined:enabled选项不会执行任何类型缩小。而且,有一些方法可以绕过enabled选项,例如通过调用useQuery返回的refetch方法。在这种情况下,id可能真的是undefined。

我发现在这里最好的方法(如果您不喜欢非空断言运算符)是接受id可以是undefined,并在queryFn中拒绝Promise。这样做有一些重复,但它也是明确且安全的:

typescriptCopy code
function fetchGroup(id: number | undefined): Promise<Group> {
  // ✅ 在运行时检查id,因为它可能是`undefined`
  return typeof id === 'undefined'
    ? Promise.reject(new Error('Invalid id'))
    : axios.get(`group/${id}`).then((response) => response.data)
}

function useGroup(id: number | undefined) {
  return useQuery({
    queryKey: ['group', id],
    queryFn: () => fetchGroup(id),
    enabled: Boolean(id),
  })
}

这样,当id为undefined时,我们在queryFn中显式地拒绝了Promise,这使得代码更加安全和明确。尽管存在一些重复,但它确保了正确的类型处理和运行时错误检查。

乐观更新

在TypeScript中正确使用乐观更新并不是一件容易的事情,因此我们决定在文档中添加一个详细的示例。

重要的一点是:您必须显式地为传递给onMutate的variables参数指定类型,以获得最佳的类型推断。我不完全理解为什么会这样,但这似乎与泛型的推断有关。可以查看这个评论了解更多信息。

·更新:TypeScript 4.7已经添加了“Improved Function Inference in Objects and Methods”,这修复了这个问题。现在,乐观更新应该正确地推断出上下文的类型,无需额外的工作。🥳·

useInfiniteQuery

在大部分情况下,对useInfiniteQuery进行类型定义与对useQuery进行类型定义没有太大区别。一个显著的问题是,传递给queryFn的pageParam值被定义为any类型。这在库中可能需要改进,但只要它是any类型,最好明确地为其添加类型注解:

typescriptCopy code
type GroupResponse = { next?: number; groups: Group[] }
const queryInfo = useInfiniteQuery({
  queryKey: ['groups'],
  // ⚠️ 显式地为pageParam添加类型注解,以覆盖`any`
  queryFn: ({ pageParam = 0 }: { pageParam: GroupResponse['next'] }) =>
    fetchGroups(groups, pageParam),
  getNextPageParam: (lastGroup) => lastGroup.next,
})

如果fetchGroups返回GroupResponse,那么lastGroup的类型将得到很好地推断,并且我们可以使用相同的类型来为pageParam添加注解。

对默认的查询函数进行类型定义

我个人不使用defaultQueryFn,但我知道很多人使用它。这是一种巧妙的方法,可以利用传递的queryKey直接构建请求URL。如果在创建queryClient时内联该函数,则传递给queryFn的QueryFunctionContext的类型也将为您推断出来。当您内联代码时,TypeScript确实更加智能 :)

typescriptCopy code
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      queryFn: async ({ queryKey: [url] }) => {
        const { data } = await axios.get(`${baseUrl}/${url}`)
        return data
      },
    },
  },
})

这样就可以工作了,然而url的类型被推断为unknown,因为整个queryKey是一个未知的数组。在创建queryClient时,绝对不能保证在调用useQuery时queryKeys的构造方式,所以React Query能做的只有这么多。这正是这个高度动态特性的本质。但这并不是一件坏事,因为它意味着您现在必须进行防御性编程,并通过运行时检查来缩小类型范围,以便处理它,例如:

typescriptCopy code
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      queryFn: async ({ queryKey: [url] }) => {
        // ✅ 缩小url的类型为string,这样我们就可以处理它了
        if (typeof url === 'string') {
          const { data } = await axios.get(`${baseUrl}/${url.toLowerCase()}`)
          return data
        }
        throw new Error('Invalid QueryKey')
      },
    },
  },
})

我认为这很好地说明了为什么unknown是一种如此出色(但很少使用)的类型,与any相比。它已经成为我最喜欢的类型了 - 但这是另一个博客文章的主题。😊