三、ReactQuery数据获取

React Query 有三个核心概念:

  • Queries - 查
  • Mutations - 增删改
  • Query Invalidation - 查询失效(重新查询)

TanStack Query 目前支持多种前端框架,我们本章节专注适用于 ReactReact Query 并会在需要做示例的时候使用 Nextjs 示例。

快速上手

我们先以官方文档的 Quick Start 示例来演示三个核心概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import {
useQuery,
useMutation,
useQueryClient,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
// 引入发送请求的API,具体的API内容不关注
import { getTodos, postTodo } from '../my-api'

// 创建 Query Client实例
const queryClient = new QueryClient()

function App() {
return (
// 在App组件将queryClient注入
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}

function Todos() {
// 在我们的业务组件中获取到queryClient实例
const queryClient = useQueryClient()

// useQuery用于发送查询请求,对应核心概念之一的 Queries
// 会调用queryFn的请求方法,并且拥有自己的queryKey
const query = useQuery({ queryKey: ['todos'], queryFn: getTodos })

// useMutation用于创建一个mutation,其功能对应核心概念的 Mutations
// mutationFn是执行mutation的时候要调用的函数,但是不会在useMutation的时候立即执行,需要我们在合适的时机手动执行
// onSuccess是mutationFn执行成功之后的回调
const mutation = useMutation({
mutationFn: postTodo,
onSuccess: () => {
// queryClient.invalidateQueries可以使指定queryKey的请求失效,失效后会重新执行query请求
// 在这里就是重新执行我们的 getTodos
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})

return (
<div>
{/* query查询的结果可以用来渲染,查询成功后会自动更新 */}
<ul>{query.data?.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>

<button
onClick={() => {
{/* mutation.mutate手动执行一个mutation,用于实现增删改操作,传递的参数会传递给mutationFn */}
mutation.mutate({
id: Date.now(),
title: 'Do Laundry',
})
}}
>
Add Todo
</button>
</div>
)
}

render(<App />, document.getElementById('root'))

在示例代码中,我在适当位置做了注释,查看注释即可。

Nextjs 引入 React Query

由于 React Query 需要用于客户端组件,所以我们不能直接在 app/layout.tsx 中注入,我们可以在封装一层组件

1
2
3
4
5
6
7
8
9
10
11
// components/query-provider.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
export default function QueryProvider({children}: {children: React.ReactNode}) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}

然后我们在root layout中引入使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// app/layout.tsx
// ...
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable
)}>
<QueryProvider>
{children}
</QueryProvider>
</body>
</html>
);
}

这样就可以在业务组件中使用 React Query

Query可以和任何基于Promise的方法(包括GET, POST)一起使用,以从服务器获取数据。如果方法修改了服务器的数据,那么建议使用Mutations而不是Query

使用useQuery至少需要提供:

  • 查询的唯一key - queryKey 这个唯一的key在内部用于整个应用程序重新提取、缓存和共享查询
  • 返回Promise的函数,他应该解析数据或抛出错误 - queryFn

useQuery 返回值

useQuery返回的查询结果有所有需要的信息:

1
const {isPending, isError, isSucess, status, error, data, isFetching} = useQuery()

在任何时刻,查询只能处于以下状态之一:

  • isPending或者status === 'pending' - 尚无查询数据
  • isError或者status === 'error' - 查询遇到错误
  • isSuccess或者status === 'success' - 查询成功且数据可用

除了上边的状态之外,还可以根据状态来获取更多信息:

  • error - 处于isError状态时可以通过error获取错误
  • data - 处于isSuccess状态时可以通过data获取错误
  • isFetching - 如果查询正在获取数据,则isFetching = true

通常只需要检查isPending状态,然后检查isError状态,最后假定数据可用并呈现即可。

检查状态可以用isPending, isError等布尔值,也可以用status,两者取其一即可。

queryKey

react-query的核心是根据查询键管理查询缓存。查询键必须是顶层的数组。可以只包含简单的字符串,也可以和嵌套对象的数组一样复杂。只要是可序列化的,并且对于查询的数据是唯一的,就可以使用:

  • 具有常量值的数组

  • 当查询需要更多信息来唯一描述其数据时,可以使用带有字符串和任意数量的可序列化对象的数组来描述它

  • 如果查询函数依赖于变量, 请将其包含在查询键中

queryFn

查询函数可以是返回Promise的任意函数,这个Promise必须返回数据或者抛出错误

queryKey 不仅用于标识想要获取的数据,还可以作为QueryFunctionContext的一部分传递到查询函数中。

1
2
3
4
useQuery({
queryKey: [],
queryFn: async (context: QueryFunctionContext) => {}
})

QueryFunctionContext是传递给每个查询函数的对象,包括:

  • queryKey: QueryKey - 查询键
  • signal?: AbortSignal - 用于取消查询
  • meta: Record<string, unknown> | undefined - 可以填写有关查询的其他信息

另外,Infinite Queries无限查询会额外传递参数:

  • pageParam: TPageParam - 提取当前页面的page参数
  • direction: 'forward' | 'backward' - 页面数据的提取方向

读取queryKey的示例:

1
2
3
4
5
6
7
8
const [page, setPage] = useState(1)
useQuery({
queryKey: ['todos', page, { page }, 'others'],
queryFn: async ({queryKey}: QueryFunctionContext) => {
// ['todos', 1, { page: 1 }, 'others']
console.log(queryKey)
}
})

接收到的queryKey与定义的完全一致,依然是数组,顺序保持一致。

query options

在多个位置之间共享 queryKeyqueryFn 但使它们彼此位于同一位置的最佳方法之一是使用 queryOptions 帮助程序。在运行时,这个帮助程序只返回你传递给它的任何内容,但它在与 TypeScript 一起使用时有很多优点。您可以在一个位置定义查询的所有可能选项,并且将获取所有这些选项的类型推断和类型安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { queryOptions } from '@tanstack/react-query'

function groupOptions(id: number) {
return queryOptions({
queryKey: ['groups', id],
queryFn: () => fetchGroups(id),
staleTime: 5 * 1000,
})
}

// usage:
useQuery(groupOptions(1))
useQuery(groupOptions(5))

对于无限查询,可以使用单独的infiniteQueryOptions帮助程序。

并行查询

并行执行查询,最大限度提高查询的并发性。

手动并行查询

当并行查询数量没有变化时,不需要做任何工作,只要并排使用任意数量的useQuery或者useinfiniteQuery即可

1
2
3
4
const usersQuery = useQuery()
const teamsQuery = useQuery()
const projectsQuery = useQuery()
...

使用useQueries进行动态并行查询

当需要执行的查询数量在不同渲染之间会发生变化,那么不能用手动查询了,可以使用useQueries这个hook,他可以动态执行任意数量的查询。

useQueries接收一个带有queries字段的对象,其值是一个查询对象数组。返回查询结果数组:

1
2
3
4
5
6
7
8
9
10
function App({ users }) {
const userQueries = useQueries({
queries: users.map(user => {
return {
queryKey: ['user', user.id],
queryFn: () => fetchUserById(user.id)
}
})
})
}

这里用的是props来动态生成的请求查询,当然可以根据state或者其他来动态生成查询。

串行查询

依赖于先前的查询在执行之前完成。

useQuery 依赖查询

只需要使用enabled来告知查询何时准备好运行即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 获取 user
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: getUserByEmail
})

const userId = user?.id

// 获取 user projects
const {} = useQuery({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
// 直到 userId 存在了才会执行查询
enabled: !!userId,
})

useQueries依赖查询

useQueries也可以依赖于以前的查询,实现方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 获取 user ids
const { data: userIds } = useQuery({
queryKey: ['users'],
queryFn: getUsersData,
select: (users) => users.map(user => user.id),
})

// 然后获取用户信息
const usersMessage = useQueries({
queries: userIds
? userIds.map(id => {
return {
queryKey: ['message', id],
queryFn: () => getMessageByUsers(id)
}
})
: [] // 如果没有userIds则返回空数组
})

初始数据

使用 config.initialData 选项来设置查询的初始数据并跳过初始加载状态

1
2
3
4
5
const result = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
initialData: initialTodos,
})

initialData将保存在缓存中,因此不建议向此选项提供占位符或不完整数据,而应该使用placeholderData

占位数据

类似于 initialData 选项,但数据不会保存到缓存中。当您有足够的部分(或虚假)数据来成功呈现查询,而实际数据是在后台获取时,这会派上用场

1
2
3
4
5
6
7
function Todos() {
const result = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
placeholderData: placeholderTodos,
})
}

如果访问查询占位符数据的过程非常密集,或者您不想在每次渲染时都执行,则可以记住该值:

1
2
3
4
5
6
7
8
function Todos() {
const placeholderData = useMemo(() => generateFakeTodos(), [])
const result = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/todos'),
placeholderData,
})
}

placeholderData 也可以是一个函数,您可以在其中访问“上一个”成功 Query 的数据和 Query 元信息。对于希望将一个查询中的数据用作另一个查询的占位符数据的情况,这很有用。当 QueryKey 发生变化时,例如从 [‘['todos', 1]['todos', 2],我们可以继续显示“旧”数据,而不必在数据从一个查询转换到下一个查询时显示加载器。

1
2
3
4
5
const result = useQuery({
queryKey: ['todos', id],
queryFn: () => fetch(`/todos/${id}`),
placeholderData: (previousData, previousQuery) => previousData,
})

在某些情况下,您可以根据另一个查询的缓存结果提供占位符数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Todo({ blogPostId }) {
const queryClient = useQueryClient();
const result = useQuery({
queryKey: ['blogPost', blogPostId],
queryFn: () => fetch(`/blogPosts/${blogPostId}`),
placeholderData: () => {
// Use the smaller/preview version of the blogPost from the 'blogPosts'
// query as the placeholder data for this blogPost query
return queryClient
.getQueryData(['blogPosts'])
?.find((d) => d.id === blogPostId)
},
})
}

分页查询

分页数据是非常常见的,在React Query中通过在查询键中包含分页信息来实现:

1
2
3
4
const result = useQuery({
queryKey: ['projects', page],
queryFn: fetchProjects
})

这样确实可以工作,但是会发生一些奇怪的情况,页码改变时UI 跳入和跳出successpending状态,因为每个新页面都被视为一个全新的查询。

这种体验是不佳的,React Query中有名为placeholderData的功能,可以解决这个问题。

思路:

  • 在请求新数据时,上次成功提取的数据可用,即使查询键已更改也是如此.
  • 当新数据到达时,以前的数据将无缝交换以显示新data
  • isPlaceholderData 可用于了解查询当前为您提供的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import React from "react";

function Todos() {
const [page, setPage] = React.useState(0)

const fetchProjects = (page = 0) => fetch('/api/projects?page=' + page).then((res) => res.json())

const {
isPending,
isError,
error,
data,
isFetching,
isPlaceholderData,
} = useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData,
})

return (
<div>
{isPending ? (
<div>Loading...</div>
) : isError ? (
<div>Error: {error.message}</div>
) : (
<div>
{data.projects.map(project => (
<p key={project.id}>{project.name}</p>
))}
</div>
)}
<span>Current Page: {page + 1}</span>
<button
onClick={() => setPage(old => Math.max(old - 1, 0))}
disabled={page === 0}
>
Previous Page
</button>{' '}
<button
onClick={() => {
if (!isPlaceholderData && data.hasMore) {
setPage(old => old + 1)
}
}}
disabled={isPlaceholderData || !data?.hasMore}
>
Next Page
</button>
{isFetching ? <span> Loading...</span> : null}{' '}
</div>
)
}

keepPreviousData设置为placeholderData,会在之后的请求过程中用当前的数据进行占位。

通过isPlaceholderData判断是否是占位数据

关于 Query 的内容就先说这么多吧,这些是常用内容,当然 React Query 支持更多的配置项和操作,自己查阅文档吧

增删改

Query不同,Mutation通常用于 增删改 或 执行服务器副作用。使用useMutation这个hook

在任何给定时刻,Mutation只能处于以下状态之一:

  • isIdle or status === 'idle' - 当前处于空闲状态或处于全新/重置状态
  • isPendingstatus === 'pending' - 当前正在运行
  • isErrorstatus === 'error' - 遇到错误
  • isSuccessstatus === 'success' - 成功且数据可用

除了这些主要状态之外,根据Mutation的状态,还可以获得更多信息:

  • error - 如果处于错误状态,则可通过 error 属性获取该error
  • data - 如果处于success状态,则可通过 data 属性获取数据

即使只有变量,Mutation也不是那么特别,但是当与 onSuccess 选项、查询客户端的 invalidateQueries 方法和查询客户端的setQueryData 方法一起使用时,Mutation会成为一个非常强大的工具。

基础使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const CreateTodo = () => {
const [title, setTitle] = useState('')
const mutation = useMutation({ mutationFn: createTodo })

const onCreateTodo = (e) => {
e.preventDefault()
mutation.mutate({ title })
}

return (
<form onSubmit={onCreateTodo}>
{mutation.error && (
/*使用reset方法重置*/
<h5 onClick={() => mutation.reset()}>{mutation.error}</h5>
)}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<br />
<button type="submit">Create Todo</button>
</form>
)
}

回调

在调用 mutate 时,除了 useMutation 上定义的回调之外,还希望触发其他回调。这可用于触发特定于组件的副作用。为此,您可以在 mutation 变量之后为 mutate 函数提供任何相同的回调选项。支持的选项包括:onSuccessonErroronSettled。请记住,如果您的组件在变更完成之前卸载,这些额外的回调将不会运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const mutation = useMutation({
mutationFn: addTodo,
// 所有调用mutation.mutate()都会触发下边的回调
onSuccess: (data, variables, context) => {
// I will fire first
},
onError: (error, variables, context) => {
// I will fire first
},
onSettled: (data, error, variables, context) => {
// I will fire first
},
})

mutation.mutate(todo, {
// 仅用于当前mutate的回调
onSuccess: (data, variables, context) => {
// I will fire second!
},
onError: (error, variables, context) => {
// I will fire second!
},
onSettled: (data, error, variables, context) => {
// I will fire second!
},
})

查询失效

当您知道查询的数据由于用户执行的操作而过期时。为此,QueryClient 有一个 invalidateQueries 方法,该方法允许您智能地将查询标记为过时,并可能重新获取它们

1
2
3
4
// 将缓存中的所有查询标记为过时
queryClient.invalidateQueries()
// 通过queryKey将指定查询标记为过时
queryClient.invalidateQueries({ queryKey: ['todos'] })

查询匹配

  1. 使查询键中以指定内容开头的查询无效
1
2
3
4
5
6
7
8
9
10
11
const queryClient = useQueryClient()
queryClient.invalidateQueries({ queryKey: ['todos'] })
// 以下两个查询都会被标记为失效
useQuery({
queryKey: ['todos'],
queryFn: ,
})
useQuery({
queryKey: ['todos', { page: 1 }],
queryFn: ,
})
  1. 传递更具体的查询键
1
2
3
4
5
6
7
8
9
10
11
12
const queryClient = useQueryClient()
queryClient.invalidateQueries({ queryKey: ['todos', { type: 'done' } ] })
// 这个查询会被标记为失效
useQuery({
queryKey: ['todos', { type: 'done' }],
queryFn: ,
})
// 这个查询依然有效
useQuery({
queryKey: ['todos'],
queryFn: ,
})
  1. 严格匹配,传递exact: true选项,保证匹配仅有传递的查询键不包括更多变量的查询
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
queryClient.invalidateQueries({
queryKey: ['todos'],
exact: true,
})

// 这个查询会被标记为失效
const todoListQuery = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})

// 这个查询还有额外的查询键,所以不会失效
const todoListQuery = useQuery({
queryKey: ['todos', { type: 'done' }],
queryFn: fetchTodoList,
})
  1. 传递predicate函数实现更细粒度控制,此函数从缓存中接受每个Query实例,允许返回true / false决定是否失效
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10
}
})
// 这个查询会被标记为失效
useQuery({
queryKey: ['todos', { version: 20 }],
queryFn: ,
})
// 这个查询也会被标记为失效
useQuery({
queryKey: ['todos', { version: 10 }]
})
// 这个查询的version小于10,不会被标记为失效
useQuery({
queryKey: ['todos', { version: 5 }]
})

Mutation导致的失效

通常,当应用中的mutation执行成功时,应用程序中很可能存在需要失效并可能重新提取的相关查询,以考虑带来的新更改。

为此,您可以使用 useMutationonSuccess 选项和clientinvalidateQueries 函数

1
2
3
4
5
6
useMutation({
mutationFn: ,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})

Nextjs示例

由于目前还没接触到数据库相关内容,做示例太过于局限,所以决定在下一章节讲 Prisma 与 Nextjs 的配合使用,然后基于讲过的内容做一个相对简单但是增删改查覆盖全面的示例。

作者

胡兆磊

发布于

2024-06-08

更新于

2024-06-21

许可协议