跳至主内容

Redux 核心教程,第 7 部分:RTK Query 基础

非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

你将学到
  • RTK Query 如何简化 Redux 应用的数据获取
  • 如何配置 RTK Query
  • 如何使用 RTK Query 进行基础数据获取和更新请求
先决条件
  • 完成本教程前几部分以理解 Redux Toolkit 使用模式
更喜欢视频课程?

如果你更喜欢视频课程,可以在 Egghead 免费观看 RTK Query 创建者 Lenz Weber-Tronic 主讲的 RTK Query 视频课程,或直接观看第一课:

简介

第 5 部分:异步逻辑与数据获取第 6 部分:性能与数据规范化中,我们看到了 Redux 数据获取和缓存的标准模式。这些模式包括使用异步 thunk 获取数据、通过分发 action 传递结果、在 store 中管理请求加载状态,以及对缓存数据进行规范化以便按 ID 查找和更新单个数据项。

在本节中,我们将学习如何使用专为 Redux 应用设计的数据获取与缓存解决方案 RTK Query,并了解它如何简化组件中的数据获取和使用流程。

RTK Query 概述

RTK Query 是一个强大的数据获取与缓存工具。它旨在简化 Web 应用中常见的数据加载场景,让你无需手动编写数据获取和缓存逻辑

RTK Query 包含在 Redux Toolkit 包中,其功能构建在 Redux Toolkit 其他 API 的基础之上。我们推荐将 RTK Query 作为 Redux 应用中数据获取的默认方案

设计动机

Web 应用通常需要从服务器获取数据并展示。它们还需要更新这些数据、将更新发送至服务器,并保持客户端缓存数据与服务器同步。实现现代应用所需的其他行为会使问题更加复杂:

  • 跟踪加载状态以显示 UI 加载指示器

  • 避免对相同数据的重复请求

  • 通过乐观更新提升 UI 响应速度

  • 根据用户交互管理缓存生命周期

我们已经了解如何使用 Redux Toolkit 实现这些行为。

然而,Redux 最初并未内置能彻底解决这些用例的功能。即使我们结合使用 createAsyncThunkcreateSlice,在发起请求和管理加载状态时仍涉及大量手动工作:需要创建异步 thunk、发起实际请求、从响应中提取关键字段、添加加载状态字段、在 extraReducers 中编写处理 pending/fulfilled/rejected 状态的处理函数,并实际编写正确的状态更新逻辑。

随着时间的推移,React 社区逐渐认识到**"数据获取与缓存"和"状态管理"实际上是两个不同的关注维度**。虽然可以使用 Redux 这类状态管理库来缓存数据,但由于应用场景差异显著,专门为数据获取场景设计的工具具有独特价值。

服务端状态的挑战

值得援引 React Query "设计动机"文档页面 的精辟阐述:

虽然传统状态管理库擅长处理客户端状态,但在处理异步或服务端状态时表现欠佳。这是因为服务端状态具有本质差异:

  • 持久化存储在不受控的远程位置
  • 需通过异步 API 进行获取和更新
  • 具有共享属性,可能被他人未知修改
  • 若不谨慎处理,可能在应用中变为"过期状态"

理解服务端状态特性后,更多挑战接踵而至:

  • 缓存机制...(编程中最复杂的挑战之一)
  • 将重复数据请求合并为单次请求
  • 后台自动更新"过期"数据
  • 精准判断数据"过期"时机
  • 实时反映数据更新
  • 分页/懒加载等性能优化
  • 服务端状态的内存管理与垃圾回收
  • 通过结构共享实现查询结果记忆化

RTK Query 的独特之处

RTK Query 借鉴了 Apollo Client、React Query 等数据获取方案的先驱经验,同时在 API 设计上实现创新:

  • 数据获取/缓存逻辑基于 Redux Toolkit 的 createSlicecreateAsyncThunk API 构建

  • 因 Redux Toolkit 的 UI 无关性,RTK Query 可跨框架应用于 Angular、Vue 或原生 JS 项目

  • 支持预定义 API 端点,包括参数生成规则和响应转换逻辑

  • 自动生成 React Hooks 封装完整数据流,向组件提供 data/isFetching 状态,智能管理组件生命周期内的缓存

  • 提供"缓存条目生命周期"配置,支持 WebSocket 消息流式更新等高级场景

  • 内置 OpenAPI 规范转 RTK Query API 的代码生成器

  • 完全基于 TypeScript 构建,提供卓越的 TS 开发体验

功能构成

API 体系

RTK Query 作为 Redux Toolkit 核心包的组成部分,可通过以下入口使用:

// UI-agnostic entry point with the core logic
import { createApi } from '@reduxjs/toolkit/query'

// React-specific entry point that automatically generates
// hooks corresponding to the defined endpoints
import { createApi } from '@reduxjs/toolkit/query/react'

核心 API 包含两个关键模块:

createApi():RTK Query 功能的核心。它允许你定义一组接口端点,这些端点描述了如何从一系列后端接口获取数据,包括如何配置数据的获取与转换方式。在大多数情况下,每个应用只需使用一次该函数,经验法则是"每个基础 URL 对应一个 API 切片"。

  • fetchBaseQuery():轻量级 fetch 封装器。虽支持任意异步请求缓存,但 fetchBaseQuery 默认优化 HTTP 场景

包体积分析

RTK Query 会增加应用包体积的一次性固定开销。由于 RTK Query 构建在 Redux Toolkit 和 React-Redux 之上,实际增加的大小取决于应用中是否已使用这些库。预估的 min+gzip 包体积如下:

  • 如果已在使用 RTK:RTK Query 约增加 9kb,钩子函数约增加 2kb

  • 如果尚未使用 RTK:

    • 不使用 React:RTK + 依赖项 + RTK Query 共约 17 kB
    • 使用 React:约 19kB + React-Redux(作为 peer dependency)

添加额外的端点定义只会根据 endpoints 中的实际代码增加体积,通常只需几个字节

RTK Query 的功能会迅速抵消新增的包体积,并且消除手写数据获取逻辑通常会在实际应用中带来包体积的净优化

RTK Query 缓存设计理念

Redux 始终强调可预测性和显式行为。Redux 中没有"魔法"——您应该能够理解应用程序中发生的一切,因为所有 Redux 逻辑都遵循相同的模式:派发 action 并通过 reducer 更新状态。这确实意味着有时需要编写更多代码,但换来的是清晰的数据流和行为

Redux Toolkit 核心 API 并未改变 Redux 应用的基本数据流 您仍然在派发 action 并编写 reducer,只是代码量比手动编写所有逻辑要少。RTK Query 同样如此。它增加了一层抽象,但内部仍执行着我们熟悉的异步请求管理步骤——使用 thunk 执行异步请求、派发包含结果的 action、在 reducer 中处理 action 以缓存数据

然而,使用 RTK Query 时会发生思维转变。我们不再思考"状态管理"本身,而是转向思考"管理缓存数据"。与其尝试自行编写 reducer,我们现在将专注于定义**"数据从何而来?"、"更新应如何发送?"、"缓存数据何时应重新获取?"以及"缓存数据应如何更新?"**。数据如何获取、存储和检索成为我们无需再关心的实现细节

随着教程推进,我们将看到这种思维转变的实际应用

配置 RTK Query

我们的示例应用目前运行正常,但现在需要将所有异步逻辑迁移至 RTK Query。在此过程中,我们将学习如何使用 RTK Query 的主要功能,以及如何将现有的 createAsyncThunkcreateSlice 用法迁移至 RTK Query API

定义 API Slice

此前,我们为不同数据类型(如 Posts、Users 和 Notifications)分别定义了独立的"slice"。每个 slice 拥有自己的 reducer,定义自己的 action 和 thunk,并单独缓存该数据类型的条目

使用 RTK Query 后,缓存数据的管理逻辑被集中到每个应用的单一"API slice"中。正如每个应用只有一个 Redux store,现在所有缓存数据也只有一个 slice

我们将从定义新的 apiSlice.ts 文件开始。由于它不依赖于已编写的其他"功能",我们将新建 features/api/ 文件夹并将 apiSlice.ts 置于其中。接下来填充该文件内容,并解析其代码功能:

features/api/apiSlice.ts
// Import the RTK Query methods from the React-specific entry point
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

// Use the `Post` type we've already defined in `postsSlice`,
// and then re-export it for ease of use
import type { Post } from '@/features/posts/postsSlice'
export type { Post }

// Define our single API slice object
export const apiSlice = createApi({
// The cache reducer expects to be added at `state.api` (already default - this is optional)
reducerPath: 'api',
// All of our requests will have URLs starting with '/fakeApi'
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
// The "endpoints" represent operations and requests for this server
endpoints: builder => ({
// The `getPosts` endpoint is a "query" operation that returns data.
// The return value is a `Post[]` array, and it takes no arguments.
getPosts: builder.query<Post[], void>({
// The URL for the request is '/fakeApi/posts'
query: () => '/posts'
})
})
})

// Export the auto-generated hook for the `getPosts` query endpoint
export const { useGetPostsQuery } = apiSlice

RTK Query 的功能基于单一方法 createApi。迄今为止介绍的所有 Redux Toolkit API 都是 UI 无关的,可用于_任何_ UI 层。RTK Query 的核心逻辑同样如此。不过,RTK Query 还包含 React 专属版本的 createApi,由于我们将 RTK 与 React 配合使用,因此需要利用该版本实现 RTK 的 React 集成。为此,我们需要从 '@reduxjs/toolkit/query/react' 进行特定导入。

技巧

应用中应仅存在一个 createApi 调用。该 API 切片应包含所有与相同基础 URL 通信的端点定义。例如,端点 /api/posts/api/users 都从同一服务器获取数据,因此应归入同一 API 切片。若应用需从多个服务器获取数据,可在各端点指定完整 URL,或在必要时为每个服务器创建独立 API 切片。

端点通常直接在 createApi 调用内部定义。如需在多个文件间拆分端点,请参阅文档第 8 部分的"注入端点"章节

API 切片参数

调用 createApi 时有两个必填字段:

  • baseQuery:负责从服务器获取数据的函数。RTK Query 提供 fetchBaseQuery,这是对标准 fetch() 的轻量封装,可处理 HTTP 请求/响应的典型流程。创建 fetchBaseQuery 实例时,可传入所有请求的基础 URL,并覆盖修改请求头等行为。您可创建自定义基础查询来自定义错误处理和认证等行为。

  • endpoints:定义用于与该服务器交互的操作集合。端点分为返回缓存数据的查询(queries)和向服务器发送更新的变更(mutations)。端点通过接收 builder 参数的回调函数定义,返回包含 builder.query()builder.mutation() 创建的端点定义的对象。

createApi 还接受 reducerPath 字段,用于指定生成 reducer 的顶级 state 切片字段。对于 postsSlice 等其他切片,无法保证其用于更新 state.posts——我们_可_将 reducer 附加到根 state 的任何位置(如 someOtherField: postsReducer)。而 createApi 要求明确告知缓存 reducer 添加到 store 时的存储位置。若不提供 reducerPath 选项,默认值为 'api',因此所有 RTKQ 缓存数据将存储在 state.api 下。

若忘记将 reducer 添加到 store,或将其附加到与 reducerPath 指定键名不同的位置,RTKQ 将记录错误提示需修复此问题。

定义端点

所有请求 URL 的首段路径在 fetchBaseQuery 中定义为 '/fakeApi'

第一步需添加返回模拟 API 服务器完整帖子列表的端点。我们将创建名为 getPosts 的端点,使用 builder.query() 将其定义为查询端点。该方法接受众多配置请求和响应处理的选项。当前只需通过定义 query 选项提供 URL 路径的剩余部分,该选项是返回 URL 字符串的回调函数:() => '/posts'

默认情况下,查询端点会使用 GET HTTP 请求,但你可以通过返回对象(如 {url: '/posts', method: 'POST', body: newPost})而非单纯 URL 字符串来覆盖此行为。这种方式还能定义请求的其他选项,例如设置请求头。

在 TypeScript 使用中,builder.query()builder.mutation() 端点定义函数接受两个泛型参数:<ReturnType, ArgumentType>。例如,通过名称获取宝可梦的端点可定义为 getPokemonByName: builder.query<Pokemon, string>()若端点无需参数,请使用 void 类型,例如 getAllPokemon: builder.query<Pokemon[], void>()

导出 API Slice 和 Hooks

在之前的 createSlice 函数中,我们只需导出 action creators 和 slice reducers,因为它们是 slice 函数中唯一需要在其他文件中使用的部分。而在 RTK Query 中,我们通常导出整个 "API slice" 对象,因为它包含多个可能用到的字段。

最后注意文件末行:useGetPostsQuery 值从何而来?

RTK Query 的 React 集成会自动为我们定义的每个端点生成 React hooks! 这些 hooks 封装了组件挂载时触发请求、请求处理过程中重新渲染组件以及数据就绪时更新的完整流程。我们可以将这些 hooks 从 API slice 文件导出,供 React 组件使用。

hooks 的命名遵循标准约定:

  • use:所有 React hook 的标准前缀

  • 端点名称(首字母大写)

  • 端点类型:QueryMutation

本例中端点为 getPosts(查询类型),因此生成 hook 命名为 useGetPostsQuery

配置 Store

现在需要将 API slice 连接到 Redux store。修改现有 store.ts 文件,将 API slice 的缓存 reducer 加入状态树。此外,API slice 生成的自定义中间件也必须添加到 store 中——该中间件负责管理缓存生命周期和过期策略。

app/store.ts
import { configureStore } from '@reduxjs/toolkit'

import { apiSlice } from '@/features/api/apiSlice'
import authReducer from '@/features/auth/authSlice'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'
import notificationsReducer from '@/features/notifications/notificationsSlice'

import { listenerMiddleware } from './listenerMiddleware'

export const store = configureStore({
// Pass in the root reducer setup as the `reducer` argument
reducer: {
auth: authReducer,
posts: postsReducer,
users: usersReducer,
notifications: notificationsReducer,
[apiSlice.reducerPath]: apiSlice.reducer
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware()
.prepend(listenerMiddleware.middleware)
.concat(apiSlice.middleware)
})

可将 apiSlice.reducerPath 字段作为计算属性键用于 reducer 参数,确保缓存 reducer 添加到正确位置。

如我们此前添加监听中间件时所见,需要保留所有现有标准中间件(如 redux-thunk),而 API slice 的中间件通常置于其后。由于已调用 getDefaultMiddleware() 并将监听中间件置于首位,可通过 .concat(apiSlice.middleware) 将其添加在末尾。

使用查询展示文章

在组件中使用查询 Hooks

完成 API slice 定义并添加到 store 后,即可将生成的 useGetPostsQuery hook 导入 <PostsList> 组件使用。

当前 <PostsList> 显式导入了 useSelectoruseDispatchuseEffect,从 store 读取文章数据和加载状态,并在挂载时派发 fetchPosts() thunk 触发数据获取。useGetPostsQueryHook 可完全替代这些操作!

让我们观察使用此 hook 后 <PostsList> 的形态:

features/posts/PostsList.tsx
import React from 'react'
import { Link } from 'react-router-dom'

import { Spinner } from '@/components/Spinner'
import { TimeAgo } from '@/components/TimeAgo'

import { useGetPostsQuery, Post } from '@/features/api/apiSlice'

import { PostAuthor } from './PostAuthor'
import { ReactionButtons } from './ReactionButtons'

// Go back to passing a `post` object as a prop
interface PostExcerptProps {
post: Post
}

function PostExcerpt({ post }: PostExcerptProps) {
return (
<article className="post-excerpt" key={post.id}>
<h3>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>
<ReactionButtons post={post} />
</article>
)
}

export const PostsList = () => {
// Calling the `useGetPostsQuery()` hook automatically fetches data!
const {
data: posts = [],
isLoading,
isSuccess,
isError,
error
} = useGetPostsQuery()

let content: React.ReactNode

// Show loading states based on the hook status flags
if (isLoading) {
content = <Spinner text="Loading..." />
} else if (isSuccess) {
content = posts.map(post => <PostExcerpt key={post.id} post={post} />)
} else if (isError) {
content = <div>{error.toString()}</div>
}

return (
<section className="posts-list">
<h2>Posts</h2>
{content}
</section>
)
}

从概念上讲,<PostsList> 仍在执行原有工作,但我们成功用单次 useGetPostsQuery() 调用替代了多个 useSelector 调用和 useEffect 中的派发逻辑

(请注意,此时应用程序中仍有一些代码在查看现有的 state.posts 切片获取数据,而新代码则从 RTK Query 读取数据,这会导致不匹配。这是预期的情况,我们将逐步修复这些不匹配问题。)

之前,我们是从 store 中选择一组帖子 ID,将每个帖子 ID 传递给 <PostExcerpt> 组件,然后分别从 store 中选择每个 Post 对象。由于 posts 数组已包含所有帖子对象,我们已改回直接将帖子对象本身作为 props 传递。

技巧

通常,你应该使用查询钩子(query hooks)来访问组件中的缓存数据——你不应该编写自己的 useSelector 调用来获取数据,也不应该用 useEffect 来触发数据获取!

查询钩子结果对象

每个生成的查询钩子都会返回一个包含多个字段的"结果"对象:

  • data:服务器返回的最新成功缓存条目数据的实际响应内容。在收到响应之前,该字段为 undefined

  • currentData当前查询参数对应的响应内容。如果查询参数发生变化且由于没有现有缓存条目而发起新请求,该字段可能变为 undefined

  • isLoading:布尔值,表示该钩子是否正在向服务器发起首次请求(因为尚未有任何数据)(注意:如果参数变化导致请求不同数据,isLoading 将保持为 false)

  • isFetching:布尔值,表示该钩子是否正在向服务器发起任何请求

  • isSuccess:布尔值,表示该钩子是否已成功发起请求并有可用的缓存数据(即 data 此时应该有定义)

  • isError:布尔值,表示最后一次请求是否出错

  • error:序列化的错误对象

通常我们会从结果对象中解构字段,并可能将 data 重命名为更具体的变量(如 posts)以描述其内容。然后,我们可以使用状态布尔值和 data/error 字段来渲染所需的 UI。但是,如果你使用的是旧版 TypeScript,可能需要保持原对象不变,并在条件检查中使用 result.isSuccess 等标志,以便 TS 能正确推断 data 的有效性。

加载状态字段

请注意 isLoadingisFetching 是具有不同行为的标志。你可以根据需要在 UI 中显示加载状态的时机和方式来决定使用哪一个。例如,如果要在首次加载页面时显示骨架屏(skeleton),可以检查 isLoading;或者,每当用户选择不同项目时有任何请求发生时,可以检查 isFetching 来显示加载动画或置灰现有结果。

类似地,datacurrentData 会在不同时间变化。大多数情况下应使用 data 中的值,但 currentData 可用于更精细地控制加载行为。例如,如果要在 UI 中将数据显示为半透明以表示重新获取状态,可以结合 dataisFetching 实现,因为 data 会保持不变直到新请求完成。但是,如果还希望仅显示与当前参数对应的值(例如在新请求完成前清空 UI),则可以使用 currentData 来实现。

帖子排序

遗憾的是,现在帖子的显示顺序是混乱的。之前,我们在 reducer 层使用 createEntityAdapter 的排序选项按日期排序。由于 API 切片只是缓存从服务器返回的原始数组,没有进行特定排序——服务器返回的顺序就是我们现在看到的顺序。

目前我们有几种处理方式。现在,我们将在 <PostsList> 组件内部进行排序,稍后再讨论其他方案及其权衡。

不能直接调用 posts.sort(),因为 Array.sort() 会改变原始数组,因此需要先创建副本。为避免每次重新渲染都重新排序,我们可以使用 useMemo() 钩子。同时需要为 posts 设置默认空数组(以防其为 undefined),确保始终有可排序的数组。

features/posts/PostsList.tsx
// omit setup

export const PostsList = () => {
const {
data: posts = [],
isLoading,
isSuccess,
isError,
error
} = useGetPostsQuery()

const sortedPosts = useMemo(() => {
const sortedPosts = posts.slice()
// Sort posts in descending chronological order
sortedPosts.sort((a, b) => b.date.localeCompare(a.date))
return sortedPosts
}, [posts])

let content

if (isLoading) {
content = <Spinner text="Loading..." />
} else if (isSuccess) {
content = sortedPosts.map(post => <PostExcerpt key={post.id} post={post} />)
} else if (isError) {
content = <div>{error.toString()}</div>
}

// omit rendering
}

显示单篇帖子

我们已更新 <PostsList> 来获取 所有 帖子列表,并在列表中展示每篇 Post 的摘要。但如果点击"查看帖子",当前 <SinglePostPage> 组件会因在旧的 state.posts 切片中找不到帖子而显示"未找到帖子"错误。我们需要更新 <SinglePostPage> 组件以同样使用 RTK Query。

有两种实现方式:一是让 <SinglePostPage> 调用相同的 useGetPostsQuery() 钩子获取 全部 帖子数组,然后找出需要展示的特定 Post 对象;二是通过查询钩子的 selectFromResult 选项在钩子内部提前完成筛选(稍后会演示)。

这里我们将采用另一种方案:添加新的端点定义,支持根据 ID 向服务器请求单篇帖子。虽然这与现有功能有所重叠,但能展示 RTK Query 如何基于参数定制查询请求。

添加单篇帖子查询端点

apiSlice.ts 中添加名为 getPost(注意单数形式)的新查询端点定义:

features/api/apiSlice.ts
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
endpoints: builder => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts'
}),
getPost: builder.query<Post, string>({
query: postId => `/posts/${postId}`
})
})
})

export const { useGetPostsQuery, useGetPostQuery } = apiSlice

getPost 端点的结构与现有 getPosts 类似,但 query 参数不同。这里 query 接收一个名为 postId 的参数,并且我们使用该 postId 来构建服务器 URL,这样我们就可以向服务器请求一个特定的 Post 对象。

这会生成新的 useGetPostQuery 钩子,我们同样将其导出。

查询参数与缓存键

当前 <SinglePostPage>state.posts 中通过 ID 读取一个 Post 条目。我们需要更新它来调用新的 useGetPostQuery 钩子,并采用与主列表类似的加载状态处理。

features/posts/SinglePostPage.tsx
// omit some imports

import { useGetPostQuery } from '@/features/api/apiSlice'
import { selectCurrentUsername } from '@/features/auth/authSlice'

export const SinglePostPage = () => {
const { postId } = useParams()

const currentUsername = useAppSelector(selectCurrentUsername)
const { data: post, isFetching, isSuccess } = useGetPostQuery(postId!)

let content: React.ReactNode

const canEdit = currentUsername === post?.user

if (isFetching) {
content = <Spinner text="Loading..." />
} else if (isSuccess) {
content = (
<article className="post">
<h2>{post.title}</h2>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content}</p>
<ReactionButtons post={post} />
{canEdit && (
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
)}
</article>
)
}

return <section>{content}</section>
}

注意我们从路由匹配中获取 postId 并作为参数传递给 useGetPostQuery。查询钩子将据此构建请求 URL 并获取这个特定的 Post 对象。

那么这些数据是如何缓存的?点击某篇帖子的"查看帖子"后,观察此时 Redux 存储的状态:

RTK Query 数据在存储中的缓存状态

可以看到顶层的 state.api 切片(符合存储设置预期)。其内部的 queries 部分当前包含两个条目:键名 getPosts(undefined) 对应 getPosts 端点的请求元数据和响应内容;键名 getPost('abcd1234') 则对应刚获取的特定帖子。

RTK Query 为每个唯一的"端点+参数"组合生成缓存键,并将结果分别存储。这意味着可以多次使用相同查询钩子并传递不同参数,每个结果都会在 Redux 存储中独立缓存

技巧

如果需要在多个组件中使用相同的数据,只需在每个组件中调用相同的查询钩子并传入相同的参数即可!例如,你可以在三个不同组件中调用 useGetPostQuery('123'),RTK Query 将确保数据仅获取一次,每个组件会根据需要重新渲染。

同样重要的是注意:查询参数必须是单个值!如果需要传递多个参数,必须传入包含多个字段的对象(与 createAsyncThunk 完全一致)。RTK Query 会对字段进行"浅层稳定"比较,并在任何字段变更时重新获取数据。

注意左侧列表中的动作名称更通用且描述性降低:显示为 api/executeQuery/fulfilled 而非 posts/fetchPosts/fulfilled。这是使用额外抽象层的权衡结果。单个动作在 action.meta.arg.endpointName 下包含具体端点名称,但在动作历史列表中不易直接查看。

技巧

Redux DevTools 设有专门的"RTK Query"选项卡,以更实用的格式展示 RTK Query 数据,聚焦于缓存条目而非原始 Redux 状态结构。这包括每个端点和缓存结果的信息、查询时间统计等:

Redux store 中缓存的 RTK Query 数据

你也可查看 RTK Query 开发者工具的实时演示:

使用变更操作创建文章

我们已经了解如何通过定义"查询"端点从服务器获取数据,但如何向服务器发送更新呢?

RTK Query 允许定义用于更新服务器数据的变更端点。现在添加一个支持创建新文章的变更操作。

添加新建文章变更端点

添加变更端点与添加查询端点非常相似。主要区别在于我们使用 builder.mutation() 而非 builder.query() 定义端点。此外,现在需要将 HTTP 方法改为 'POST' 请求,并提供请求体内容。

我们将从 postsSlice.ts 导出已有的 NewPost TS 类型,然后在变更操作中将其作为参数类型使用(这正是组件需要传入的类型)。

features/api/apiSlice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

import type { Post, NewPost } from '@/features/posts/postsSlice'
export type { Post }

export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
endpoints: builder => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts'
}),
getPost: builder.query<Post, string>({
query: postId => `/posts/${postId}`
}),
addNewPost: builder.mutation<Post, NewPost>({
query: initialPost => ({
// The HTTP URL will be '/fakeApi/posts'
url: '/posts',
// This is an HTTP POST request, sending an update
method: 'POST',
// Include the entire post object as the body of the request
body: initialPost
})
})
})
})

export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation
} = apiSlice

与查询端点类似,我们需指定 TS 类型:变更操作返回完整的 Post 对象,并接受部分 NewPost 值作为参数。

此处的 query 选项返回包含 {url, method, body} 的对象,这允许我们指定该请求为 HTTP POST 方法并定义 body 内容。由于使用 fetchBaseQuery 发起请求,body 字段会自动进行 JSON 序列化(是的,本示例中"post"一词出现得过于频繁了 :) )

与查询端点类似,API Slice 会自动为变更端点生成 React 钩子——本例中为 useAddNewPostMutation

在组件中使用变更钩子

当前 <AddPostForm> 组件在点击"保存文章"按钮时通过分发异步 thunk 来添加文章。为此需导入 useDispatchaddNewPost thunk。变更钩子可替代这两者,使用模式基本一致:

features/posts/AddPostForm.tsx
import React from 'react'

import { useAppSelector } from '@/app/hooks'

import { useAddNewPostMutation } from '@/features/api/apiSlice'
import { selectCurrentUsername } from '@/features/auth/authSlice'

// omit field types

export const AddPostForm = () => {
const userId = useAppSelector(selectCurrentUsername)!
const [addNewPost, { isLoading }] = useAddNewPostMutation()

const handleSubmit = async (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value

const form = e.currentTarget

try {
await addNewPost({ title, content, user: userId }).unwrap()

form.reset()
} catch (err) {
console.error('Failed to save the post: ', err)
}
}

return (
<section>
<h2>Add a New Post</h2>
<form onSubmit={handleSubmit}>
<label htmlFor="postTitle">Post Title:</label>
<input type="text" id="postTitle" defaultValue="" required />
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue=""
required
/>
<button disabled={isLoading}>Save Post</button>
</form>
</section>
)
}

变更钩子返回包含两个值的数组:

  • 第一个值是"触发函数"。调用时携带提供的任何参数向服务器发起请求。该函数实际是已包装成可立即分发自身的 thunk。

  • 第二个值是包含当前进行中请求元数据的对象(如有)。其中包含 isLoading 标志位用于指示请求是否正在进行。

我们可以用 useAddNewPostMutation 钩子中的触发函数和 isLoading 标志替换现有的 thunk 分发和组件加载状态,而组件的其余部分保持不变。

与之前的 thunk 分发类似,我们用初始文章对象调用 addNewPost。该方法会返回一个特殊的 Promise(包含 .unwrap() 方法),我们可以通过 await addNewPost().unwrap() 配合标准的 try/catch 块来处理潜在错误。(这与 我们使用 createAsyncThunk 时的处理方式 相同,因为它们本质相同——RTK Query 内部使用了 createAsyncThunk

刷新缓存数据

点击"保存文章"后,我们可以在浏览器开发者工具的 Network 选项卡中确认 HTTP POST 请求已成功。但如果我们返回查看 <PostsList> 组件,新文章并不会显示。Redux 存储状态没有变化,内存中仍然保存着相同的缓存数据。

我们需要通知 RTK Query 刷新其缓存的文章列表,这样才能看到刚添加的新文章。

手动重新获取文章

第一种选择是手动强制 RTK Query 重新获取指定端点的数据。这不是实际应用中推荐的做法,但我们将作为中间步骤进行尝试。

查询钩子返回的对象包含 refetch 函数,调用该函数可以强制重新获取数据。我们可以临时在 <PostsList> 中添加"刷新文章"按钮,并在添加新文章后点击它:

features/posts/PostsList.tsx
export const PostsList = () => {
const {
data: posts = [],
isLoading,
isSuccess,
isError,
error,
refetch
} = useGetPostsQuery()

// omit content

return (
<section className="posts-list">
<h2>Posts</h2>
<button onClick={refetch}>Refetch Posts</button>
{content}
</section>
)
}

现在,如果我们添加新文章,等待操作完成,然后点击"刷新文章",应该就能看到新文章显示出来。

但遗憾的是,目前没有任何指示器显示刷新过程正在进行。如果能展示某些加载状态来表示重新获取请求正在进行会更有帮助。

之前我们提到查询钩子有两个标志:isLoading(在首次数据请求时为 true)和 isFetching(在任意数据请求期间为 true)。我们可以根据 isFetching 标志,在刷新过程中用加载动画完全替换文章列表。但这可能带来较差的用户体验——既然已经存在这些文章数据,为何要完全隐藏它们?

更优的方案是保持现有文章列表可见,但将其设为半透明状态以指示数据已过期。当请求完成后,再恢复正常显示。

features/posts/PostsList.tsx
import classnames from 'classnames'

import { useGetPostsQuery, Post } from '@/features/api/apiSlice'

// omit other imports and PostExcerpt

export const PostsList = () => {
const {
data: posts = [],
isLoading,
isFetching,
isSuccess,
isError,
error,
refetch
} = useGetPostsQuery()

const sortedPosts = useMemo(() => {
const sortedPosts = posts.slice()
sortedPosts.sort((a, b) => b.date.localeCompare(a.date))
return sortedPosts
}, [posts])

let content: React.ReactNode

if (isLoading) {
content = <Spinner text="Loading..." />
} else if (isSuccess) {
const renderedPosts = sortedPosts.map(post => (
<PostExcerpt key={post.id} post={post} />
))

const containerClassname = classnames('posts-container', {
disabled: isFetching
})

content = <div className={containerClassname}>{renderedPosts}</div>
} else if (isError) {
content = <div>{error.toString()}</div>
}

// omit return
}

添加新文章后点击"刷新文章",现在我们应该能看到文章列表变为半透明状态持续几秒,然后重新渲染并在顶部显示新增文章。

通过缓存失效实现自动刷新

根据用户行为手动强制刷新数据偶尔是必要的,但绝非常规场景的理想解决方案。

我们知道"服务器"拥有包含新增文章在内的完整文章列表。理想情况下,我们希望应用能在变更请求完成后立即自动重新获取更新后的文章列表,从而确保客户端缓存数据与服务器保持同步。

RTK Query 允许通过"标签"(tags)定义查询与变更之间的关系,实现自动数据刷新。"标签"是字符串或小型对象,用于标识特定数据类型,并可"失效"(invalidate)部分缓存。当缓存标签失效时,RTK Query 会自动重新获取标记了该标签的端点数据。

基本标签使用需要向 API Slice 添加三部分信息:

  • 在 API Slice 对象中添加根级 tagTypes 字段,声明字符串标签名称数组(如 'Post'

  • 查询端点中的 providesTags 数组,列出一组用于描述该查询数据的标签

  • 变更端点中的 invalidatesTags 数组,列出一组每次变更运行时将失效的标签

我们可以向 API 切片添加名为 'Post' 的单一标签,这样每次添加新帖子时都能自动重新获取 getPosts 端点:

features/api/apiSlice.ts
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Post']
}),
getPost: builder.query<Post, string>({
query: postId => `/posts/${postId}`
}),
addNewPost: builder.mutation<Post, NewPost>({
query: initialPost => ({
url: '/posts',
method: 'POST',
body: initialPost
}),
invalidatesTags: ['Post']
})
})
})

这就是全部所需操作!现在,如果点击"保存帖子",您应该会看到 <PostsList> 组件在几秒钟后自动变灰,然后重新渲染并在顶部显示新添加的帖子。

请注意,字面字符串 'Post' 本身并无特殊含义。我们也可以将其命名为 'Fred''qwerty' 或其他任意名称。关键在于每个字段使用相同的字符串,这样 RTK Query 就能知道"当此变更发生时,使所有列出相同标签字符串的端点失效"。

学习要点

RTK Query 将数据获取、缓存和加载状态管理的具体实现细节进行了抽象化处理。这极大简化了应用代码,让我们能专注于更高层次的应用行为设计。由于 RTK Query 使用我们已熟知的 Redux Toolkit API 实现,我们仍可通过 Redux DevTools 查看状态随时间的变化。

总结
  • RTK Query 是 Redux Toolkit 内置的数据获取与缓存解决方案
    • 它抽象化了服务器缓存数据的管理过程,无需手动编写加载状态、结果存储和请求逻辑
    • RTK Query 基于 Redux 的相同模式构建(如异步 thunks)
  • 每个应用使用单一"API 切片",通过 createApi 定义
    • 提供与 UI 无关的版本和 React 专用版 createApi
    • API 切片为不同服务器操作定义多个"端点"
    • 使用 React 集成时,API 切片包含自动生成的 React 钩子
  • 查询端点支持从服务器获取并缓存数据
    • 查询钩子返回 data 值及加载状态标志
    • 可通过手动重新获取或"标签"自动缓存失效机制更新查询
  • 变更端点支持更新服务器数据
    • 变更钩子返回发送更新请求的"触发"函数及加载状态
    • 触发函数返回可"解包"并等待的 Promise

下一步是什么?

RTK Query 提供可靠的默认行为,同时包含大量自定义选项用于管理请求和处理缓存数据。在第八部分:RTK Query 高级模式中,我们将了解如何运用这些选项实现乐观更新等实用功能。