本文正在参加「金石计划 . 瓜分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对单个符号的类型细化。一旦将它们拆分开来,它就无法再跟踪它们之间的关系。一般来说,这样做对于计算来说可能很困难。这对于人们来说也可能很困难。
更新: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相比。它已经成为我最喜欢的类型了 - 但这是另一个博客文章的主题。😊