Redux 核心概念,第 8 部分:RTK Query 高级模式
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
- 如何使用带 ID 的标签管理缓存失效和重新获取
- 如何在 React 外部操作 RTK Query 缓存
- 响应数据处理技巧
- 实现乐观更新和流式更新
- 完成第 7 部分以理解 RTK Query 的基础设置和使用
简介
在第 7 部分:RTK Query 基础中,我们学习了如何设置和使用 RTK Query API 来处理应用中的数据获取和缓存。我们向 Redux store 添加了"API slice",定义了获取帖子数据的"查询"端点,以及添加新帖子的"变更"端点。
在本节中,我们将继续迁移示例应用,使其对其他数据类型也使用 RTK Query,并探索如何运用其高级功能来简化代码库并提升用户体验。
本部分的某些修改并非绝对必要——它们主要用于展示 RTK Query 的功能特性,演示您_可以_实现的操作,以便在需要时参考使用。
编辑文章
我们已经添加了将新帖子保存到服务器的变更端点,并在 <AddPostForm> 组件中使用了该功能。接下来我们需要处理 <EditPostForm> 的更新,以实现编辑现有帖子的功能。
更新编辑帖子表单
与添加帖子类似,第一步是在 API slice 中定义新的变更端点。这看起来与添加帖子的变更很相似,但端点需要在 URL 中包含帖子 ID,并使用 HTTP PATCH 请求来表示更新部分字段。
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']
}),
editPost: builder.mutation<Post, PostUpdate>({
query: post => ({
url: `posts/${post.id}`,
method: 'PATCH',
body: post
})
})
})
})
export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation,
useEditPostMutation
} = apiSlice
添加完成后,我们就可以更新 <EditPostForm> 组件了。该组件需要从 store 中读取原始 Post 条目,用于初始化编辑字段的组件状态,然后将更新后的更改发送到服务器。当前我们使用 selectPostById 读取 Post 条目,并通过手动派发 postUpdated thunk 处理请求。
我们可以使用与 <SinglePostPage> 相同的 useGetPostQuery hook 从 store 缓存中读取 Post 条目,并使用新的 useEditPostMutation hook 处理保存更改。如果需要,还可以在更新过程中添加加载指示器并禁用表单输入。
import React from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Spinner } from '@/components/Spinner'
import { useGetPostQuery, useEditPostMutation } from '@/features/api/apiSlice'
// omit form types
export const EditPostForm = () => {
const { postId } = useParams()
const navigate = useNavigate()
const { data: post } = useGetPostQuery(postId!)
const [updatePost, { isLoading }] = useEditPostMutation()
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
const onSavePostClicked = async (
e: React.FormEvent<EditPostFormElements>
) => {
// Prevent server submission
e.preventDefault()
const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
if (title && content) {
await updatePost({ id: post.id, title, content })
navigate(`/posts/${postId}`)
}
}
// omit rendering
}
缓存数据的订阅生命周期
让我们实际操作验证效果。打开浏览器的开发者工具,进入"网络(Network)"选项卡,刷新页面后清除网络记录,然后登录。您应该能看到向 /posts 发起的 GET 请求(用于获取初始数据)。当点击"查看帖子(View Post)"按钮时,应该能看到第二个请求 /posts/:postId(返回单个帖子条目)。
现在点击单篇帖子页面内的"编辑帖子(Edit Post)"。界面会切换到显示 <EditPostForm>,但这次没有为单个帖子发起网络请求。为什么呢?

RTK Query 允许多个组件订阅相同的数据,并确保每个唯一数据集仅获取一次。 在内部,RTK Query 为每个端点 + 缓存键组合维护活跃"订阅"的引用计数器。如果组件 A 调用 useGetPostQuery(42),该数据将被获取。当组件 B 挂载并调用 useGetPostQuery(42) 时,它请求的是相同数据。由于已存在缓存条目,因此无需发送请求。两个钩子调用将返回完全相同的结果,包括获取的 data 和加载状态标志。
当活跃订阅数降为 0 时,RTK Query 会启动内部计时器。若在添加新订阅前计时器到期,RTK Query 将自动从缓存中移除该数据,因为应用不再需要此数据。然而,如果在计时器到期前添加了新订阅,计时器将被取消,并直接复用缓存数据而无需重新获取。
在此场景中,我们的 <SinglePostPage> 挂载时通过 ID 请求了特定 Post。点击"编辑文章"后,路由卸载了 <SinglePostPage> 组件,活跃订阅因卸载被移除。RTK Query 立即启动了"移除此文数据"的计时器。但 <EditPostPage> 组件随即挂载,并使用相同缓存键订阅了同一 Post 数据。因此 RTK Query 取消了计时器,继续使用现有缓存数据而非从服务器重新获取。
默认情况下,未使用数据会在 60 秒后从缓存中移除,但可通过根 API 切片配置或在单个端点定义中使用 keepUnusedDataFor 标志(指定缓存生存时间,单位为秒)来覆盖此设置。
使特定条目失效
现在 <EditPostForm> 组件可将编辑后的文章保存至服务器,但我们面临一个问题:编辑时点击"保存文章"会返回 <SinglePostPage>,但页面仍显示未编辑的旧数据。<SinglePostPage> 仍在使早前获取的缓存的 Post 条目。同样地,若返回主页查看 <PostsList>,显示的也是旧数据。我们需要强制重新获取单个 Post 条目和整个文章列表。
此前我们已了解如何使用"标签"(tags)使部分缓存数据失效。我们声明 getPosts 查询端点提供 'Post' 标签,而 addNewPost 变更端点使同名的 'Post' 标签失效。这样每次添加新文章时,都会强制 RTK Query 从 getQuery 端点重新获取整个文章列表。
我们可为 getPost 查询和 editPost 变更添加 'Post' 标签,但这会强制重新获取所有其他独立文章。幸运的是,RTK Query 允许定义特定标签,让我们能更精准地使数据失效。这些特定标签的格式为 {type: 'Post', id: 123}。
我们的 getPosts 查询定义了 providesTags 字段(字符串数组)。providesTags 字段也可接受接收 result 和 arg 的回调函数,并返回数组。这使我们能基于获取数据的 ID 创建标签条目。类似地,invalidatesTags 同样支持回调函数。
为实现正确行为,我们需要为各端点配置适当标签:
-
getPosts:为整个列表提供通用'Post'标签,并为每个接收的文章对象提供特定{type: 'Post', id}标签 -
getPost:为单个文章对象提供特定{type: 'Post', id}对象 -
addNewPost:使通用'Post'标签失效,强制重新获取整个列表 -
editPost:使特定{type: 'Post', id}标签失效。这将强制重新获取getPost的单个帖子和getPosts的完整帖子列表,因为它们都提供了匹配该{type, id}值的标签。
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: (result = [], error, arg) => [
'Post',
...result.map(({ id }) => ({ type: 'Post', id }) as const)
]
}),
getPost: builder.query<Post, string>({
query: postId => `/posts/${postId}`,
providesTags: (result, error, arg) => [{ type: 'Post', id: arg }]
}),
addNewPost: builder.mutation<Post, NewPost>({
query: initialPost => ({
url: '/posts',
method: 'POST',
body: initialPost
}),
invalidatesTags: ['Post']
}),
editPost: builder.mutation<Post, PostUpdate>({
query: post => ({
url: `posts/${post.id}`,
method: 'PATCH',
body: post
}),
invalidatesTags: (result, error, arg) => [{ type: 'Post', id: arg.id }]
})
})
})
这些回调中的 result 参数在响应无数据或出错时可能为 undefined,因此需要安全处理。对于 getPosts 可以使用默认空数组进行映射,getPost 已基于参数 ID 返回单元素数组。在 editPost 中,我们可以从传递给触发函数的局部帖子对象中获取 ID。
完成这些修改后,打开浏览器开发者工具的 Network 标签页,再次尝试编辑帖子。

保存编辑后的帖子时,会看到两个连续发生的请求:
-
editPost变更触发的PATCH /posts/:postId -
getPost查询重新获取数据的GET /posts/:postId
当返回主 "Posts" 标签页时,还会看到:
getPosts查询重新获取数据的GET /posts
通过标签建立端点间关联后,RTK Query 知道在编辑操作使特定 ID 标签失效时需要重新获取单个帖子和帖子列表——无需额外操作!同时,编辑过程中 getPosts 数据的缓存过期时间已到,缓存被清除。当再次打开 <PostsList> 组件时,RTK Query 发现缓存无数据并重新获取。
注意:在 getPosts 中使用普通 'Post' 标签并在 addNewPost 中使其失效,会导致所有单个帖子也被强制重新获取。若只需重新获取 getPosts 的列表数据,可添加额外标签如 {type: 'Post', id: 'LIST'} 并使其失效。RTK Query 文档提供了标签失效行为的详细说明。
RTK Query 提供多种数据重获控制选项,包括"条件获取"、"惰性查询"和"预获取",查询定义也支持多种定制方式。详见 RTK Query 使用指南:
更新通知提示
当我们将添加帖子的逻辑从 thunk 迁移到 RTK Query 变更时,意外导致"新帖子已添加"的通知失效,因为 addNewPost.fulfilled action 不再被触发。
幸运的是,这个问题很容易解决。RTK Query 内部实际上使用了 createAsyncThunk,我们已经看到它会在请求发出时派发 Redux 动作。我们可以更新 toast 通知监听器,让它监听 RTKQ 内部动作的派发,并在发生时显示 toast 通知。
createApi 会自动为每个端点生成 thunk。它还会自动生成 RTK "匹配器"函数,这些函数接受一个 action 对象并在匹配特定条件时返回 true。这些匹配器可以在任何需要检查动作是否符合条件的地方使用,比如在 startAppListening 内部。它们还充当 TypeScript 类型守卫,缩小 action 对象的 TS 类型范围以便安全访问其字段。
目前,toast 监听器通过 actionCreator: addNewPost.fulfilled 监听特定的动作类型。我们将更新它,改为使用 matcher: apiSlice.endpoints.addNewPost.matchFulfilled 来监听文章添加操作:
import { createEntityAdapter, createSelector, createSlice, EntityState, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { AppStartListening } from '@/app/listenerMiddleware'
import { createAppAsyncThunk } from '@/app/withTypes'
import { apiSlice } from '@/features/api/apiSlice'
import { logout } from '@/features/auth/authSlice'
// omit types, posts slice, and selectors
export const addPostsListeners = (startAppListening: AppStartListening) => {
startAppListening({
matcher: apiSlice.endpoints.addNewPost.matchFulfilled,
effect: async (action, listenerApi) => {
现在当我们添加文章时,toast 通知应该能正确显示了。
管理用户数据
我们已经完成了将文章数据管理迁移到使用 RTK Query 的工作。接下来,我们将转换用户列表的处理方式。
由于我们已经了解了如何使用 RTK Query 钩子获取和读取数据,在本节中我们将尝试一种不同的方法。与 Redux Toolkit 的其他部分一样,RTK Query 的核心逻辑与 UI 无关,可用于任何 UI 层,而不仅限于 React。
通常您应该使用 createApi 生成的 React 钩子,因为它们为您做了大量工作。但为了演示目的,这里我们将仅使用 RTK Query 核心 API 来处理用户数据,以便您了解其使用方式。
手动获取用户数据
当前我们在 usersSlice.ts 中定义了一个 fetchUsers 异步 thunk,并在 main.tsx 中手动派发该 thunk,以便尽快获取用户列表。我们可以使用 RTK Query 实现相同的流程。
首先在 apiSlice.ts 中定义一个 getUsers 查询端点,类似于我们现有的端点。为了保持一致性,我们会导出 useGetUsersQuery 钩子,但目前暂不使用它。
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Post, NewPost, PostUpdate } from '@/features/posts/postsSlice'
import type { User } from '@/features/users/usersSlice'
export type { Post }
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints
getUsers: builder.query<User[], void>({
query: () => '/users'
})
})
})
export const {
useGetPostsQuery,
useGetPostQuery,
useGetUsersQuery,
useAddNewPostMutation,
useEditPostMutation
} = apiSlice
如果我们检查 API 分片对象,会发现它包含一个 endpoints 字段,其中包含我们定义的每个端点对应的对象。

每个端点对象包含:
-
与从根 API 分片对象导出的主查询/变更钩子相同,但命名为
useQuery或useMutation -
针对查询端点,额外提供一组查询钩子,用于"惰性查询"或部分订阅等场景
-
一组"匹配器"工具,用于检查该端点请求派发的
pending/fulfilled/rejected动作 -
一个
initiatethunk,用于触发该端点的请求 -
一个
select函数,用于创建可检索该端点缓存结果数据+状态条目的记忆化选择器
如果要在 React 外部获取用户列表,我们可以在入口文件中派发 getUsers.initiate() thunk:
// omit other imports
import { apiSlice } from './features/api/apiSlice'
async function main() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })
store.dispatch(apiSlice.endpoints.getUsers.initiate())
const root = createRoot(document.getElementById('root')!)
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
}
main()
这个派发操作在查询钩子内部会自动发生,但我们可以通过手动派发 initiate thunk 来按需启动它。
注意,我们没有为 initiate() 提供参数。这是因为 getUsers 端点不需要特定的查询参数。从概念上讲,这等同于声明"此缓存条目的查询参数为 undefined"。如果需要参数,我们会将其传递给 thunk,例如 dispatch(apiSlice.endpoints.getPokemon.initiate('pikachu'))。
本例中,我们在应用初始化函数中手动分发 thunk 来预取数据。实际应用中,你可能希望在 React-Router 的"数据加载器" 中进行预取,以便在组件渲染前启动请求。(有关实现思路,可参考 RTK 仓库中关于 React-Router 加载器的讨论)。
手动分发 RTKQ 请求 thunk 会创建订阅条目,但需要你自行 稍后取消数据订阅——否则数据将永久保留在缓存中。本例中我们始终需要用户数据,因此可跳过取消订阅。
选择用户数据
当前我们拥有由 createEntityAdapter 用户适配器生成的 selectAllUsers 和 selectUserById 等选择器,它们从 state.users 读取数据。如果刷新页面,所有用户相关显示都会中断,因为 state.users 切片没有数据。既然我们正在为 RTK Query 缓存获取数据,应该用从缓存读取的等效选择器替换这些选择器。
API 切片端点中的 endpoint.select() 函数每次调用都会创建新的记忆化选择器函数。select() 以缓存键作为参数,该缓存键必须与传递给查询钩子或 initiate() thunk 的参数_完全一致_。生成的选择器使用该缓存键精确确定应从存储缓存状态返回的缓存结果。
本例中,getUsers 端点不需要参数——我们总是获取完整用户列表。因此可以创建无参数的缓存选择器(相当于传递 undefined 缓存键)。
我们可以更新 usersSlice.ts,将其选择器基于 RTKQ 查询缓存而非实际 usersSlice 调用:
import {
createEntityAdapter,
createSelector,
createSlice
} from '@reduxjs/toolkit'
import { client } from '@/api/client'
import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
import { apiSlice } from '@/features/api/apiSlice'
import { selectCurrentUsername } from '@/features/auth/authSlice'
export interface User {
id: string
name: string
}
// omit `fetchUsers` and `usersSlice`
const emptyUsers: User[] = []
// Calling `someEndpoint.select(someArg)` generates a new selector that will return
// the query result object for a query with those parameters.
// To generate a selector for a specific query argument, call `select(theQueryArg)`.
// In this case, the users query has no params, so we don't pass anything to select()
export const selectUsersResult = apiSlice.endpoints.getUsers.select()
export const selectAllUsers = createSelector(
selectUsersResult,
usersResult => usersResult?.data ?? emptyUsers
)
export const selectUserById = createSelector(
selectAllUsers,
(state: RootState, userId: string) => userId,
(users, userId) => users.find(user => user.id === userId)
)
export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
if (currentUsername) {
return selectUserById(state, currentUsername)
}
}
/* Temporarily ignore adapter selectors - we'll come back to this later
export const { selectAll: selectAllUsers, selectById: selectUserById } = usersAdapter.getSelectors(
(state: RootState) => state.users,
)
*/
首先创建特定的 selectUsersResult 选择器实例,它知道如何检索正确的缓存条目。
获得初始的 selectUsersResult 选择器后,可用从缓存结果返回用户数组的选择器替换现有 selectAllUsers 选择器。由于可能尚无有效结果,我们回退到 emptyUsers 空数组。同时用从该数组查找正确用户的选择器替换 selectUserById。
现在我们先注释掉 usersAdapter 中的这些选择器——稍后会进行另一项更改切换回使用它们。
我们的组件已导入 selectAllUsers、selectUserById 和 selectCurrentUser,因此更改应直接生效!尝试刷新页面并浏览帖子列表和单帖视图,正确用户名应显示在每个帖子和 <AddPostForm> 下拉菜单中。
注意:这完美展示了使用选择器如何提升代码可维护性! 组件已调用这些选择器,它们不关心数据来自现有 usersSlice 状态还是 RTK Query 缓存条目,只要选择器返回预期数据即可。我们更换了选择器实现,却完全_无需_更新 UI 组件。
既然 usersSlice 状态已经完全不再使用,我们可以直接删除此文件中的 const usersSlice = createSlice() 调用和 fetchUsers thunk,并从 store 配置中移除 users: usersReducer。目前仍有部分代码引用 postsSlice,因此还不能完全移除——我们稍后会处理。
拆分和注入端点
我们之前提到过RTK Query 通常每个应用只有一个 "API slice",目前我们所有的端点都直接定义在 apiSlice.ts 文件中。但对于大型应用,通常会将功能"代码拆分"到单独的包中,并在首次使用时"懒加载"这些功能。如果我们想对某些端点定义进行代码拆分,或者将它们移到其他文件中以避免 API slice 文件过大,该怎么办呢?
RTK Query 支持通过 apiSlice.injectEndpoints() 拆分端点定义。这样我们仍能维护单一的 API Slice 实例(包含单一中间件和缓存 reducer),同时可将部分端点定义移至其他文件。这既支持代码拆分场景,也能按需将端点与功能模块放在一起。
为演示此过程,我们将把 getUsers 端点改为在 usersSlice.ts 中注入,而非定义在 apiSlice.ts。
当前我们已在 usersSlice.ts 中导入 apiSlice 以访问 getUsers 端点,现在可改为在此处调用 apiSlice.injectEndpoints()。
import { apiSlice } from '../api/apiSlice'
// This is the _same_ reference as `apiSlice`, but this has
// the TS types updated to include the injected endpoints
export const apiSliceWithUsers = apiSlice.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query<User[], void>({
query: () => '/users'
})
})
})
export const { useGetUsersQuery } = apiSliceWithUsers
export const selectUsersResult = apiSliceWithUsers.endpoints.getUsers.select()
injectEndpoints() 会直接修改原始 API Slice 对象以添加新端点定义,并返回相同的 API 引用。此外,injectEndpoints 的返回值会包含注入端点的扩展 TS 类型。
因此,我们应将其保存为不同名称的新变量,以便使用更新后的 TS 类型、确保编译正确,并明确当前使用的 API Slice 版本。此处我们将其命名为 apiSliceWithUsers 以区别于原始 apiSlice。
目前唯一引用 getUsers 端点的文件是入口文件(该文件正在派发 initiate thunk)。我们需要更新该文件以导入扩展后的 API Slice:
import { apiSliceWithUsers } from './features/users/usersSlice'
import { worker } from './api/server'
import './index.css'
// Wrap app rendering so we can wait for the mock API to initialize
async function start() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })
store.dispatch(apiSliceWithUsers.endpoints.getUsers.initiate())
const root = createRoot(document.getElementById('root')!)
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
)
}
或者,也可以直接从 slice 文件中导出特定端点(类似我们在 slice 中处理 action creator 的方式)。
操作响应数据
截至目前,我们所有查询端点都直接将服务器响应数据按原样存储在缓存中。getPosts 和 getUsers 期望服务器返回数组,而 getPost 期望响应体为单个 Post 对象。
客户端通常需要从服务器响应中提取特定数据片段,或在缓存前对数据进行转换。例如,若 /getPost 请求返回形如 {post: {id}} 的嵌套数据体该如何处理?
从概念上有几种处理方式:一是提取 responseData.post 字段存入缓存而非整个响应体;二是将完整响应存入缓存,但让组件仅获取所需的缓存数据片段。
转换响应
端点可定义 transformResponse 处理函数,用于在数据缓存前提取或修改服务器返回的数据。例如若 getPost 返回 {post: {id}},可设置 transformResponse: (responseData) => responseData.post,这将仅缓存实际的 Post 对象而非整个响应体。
在第六节:性能与数据规范化中,我们探讨了将数据存储在规范化结构中的优势。特别是它能让我们基于ID直接查找和更新条目,而无需遍历数组来定位特定项。
目前我们的 selectUserById 选择器需要遍历缓存的用户数组才能找到对应的 User 对象。如果我们将响应数据转换为规范化格式存储,就能简化为直接通过ID查找用户。
此前我们在 usersSlice 中使用 createEntityAdapter 管理规范化的用户数据。现在可以将 createEntityAdapter 集成到 extendedApiSlice 中,并实际使用 createEntityAdapter 在数据缓存前对数据进行转换。我们将取消之前注释的 usersAdapter 相关代码,重新使用其更新函数和选择器。
import {
createSelector,
createEntityAdapter,
EntityState
} from '@reduxjs/toolkit'
import type { RootState } from '@/app/store'
import { apiSlice } from '@/features/api/apiSlice'
import { selectCurrentUsername } from '@/features/auth/authSlice'
export interface User {
id: string
name: string
}
const usersAdapter = createEntityAdapter<User>()
const initialState = usersAdapter.getInitialState()
// This is the _same_ reference as `apiSlice`, but this has
// the TS types updated to include the injected endpoints
export const apiSliceWithUsers = apiSlice.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query<EntityState<User, string>, void>({
query: () => '/users',
transformResponse(res: User[]) {
// Create a normalized state object containing all the user items
return usersAdapter.setAll(initialState, res)
}
})
})
})
export const { useGetUsersQuery } = apiSliceWithUsers
// Calling `someEndpoint.select(someArg)` generates a new selector that will return
// the query result object for a query with those parameters.
// To generate a selector for a specific query argument, call `select(theQueryArg)`.
// In this case, the users query has no params, so we don't pass anything to select()
export const selectUsersResult = apiSliceWithUsers.endpoints.getUsers.select()
const selectUsersData = createSelector(
selectUsersResult,
// Fall back to the empty entity state if no response yet.
result => result.data ?? initialState
)
export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
if (currentUsername) {
return selectUserById(state, currentUsername)
}
}
export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors(selectUsersData)
我们已为 getUsers 端点添加 transformResponse 选项。该选项接收完整响应数据体(此处为 User[] 数组),并返回实际要缓存的数据。通过调用 usersAdapter.setAll(initialState, responseData),它会返回标准的 {ids: [], entities: {}} 规范化数据结构。需要告知 TypeScript 现在缓存条目 data 字段的实际内容是 EntityState<User, string> 类型数据。
adapter.getSelectors() 函数需要接收"输入选择器"来定位规范化数据。由于数据嵌套在 RTK Query 缓存 reducer 内部,我们需要从缓存状态中选择正确字段。为保持一致性,可编写 selectUsersData 选择器,在未获取数据时回退到初始的空规范化状态。
规范化缓存 vs 文档式缓存
有必要回顾我们刚才的操作及其意义。
你可能在其他数据获取库(如 Apollo)中听说过"规范化缓存"。需要明确的是:RTK Query 采用"文档式缓存"而非"规范化缓存"。
完全规范化缓存会基于条目类型和ID跨_所有_查询尝试去重。例如:API 切片包含 getTodos 和 getTodo 端点,组件执行以下查询:
-
getTodos() -
getTodos({filter: 'odd'}) -
getTodo({id: 1})
每个查询结果都包含形如 {id: 1} 的 Todo 对象。
在完全规范化缓存中,此 Todo 对象只存储一份副本。但RTK Query 将每个查询结果独立缓存在 Redux store 中,因此会存在三份独立副本。不过如果所有端点都提供相同标签(如 {type: 'Todo', id: 1}),失效该标签将强制所有匹配端点重新获取数据以保持一致性。
RTK Query 有意不实现跨请求去重相同条目的缓存,原因如下:
-
完全规范化且跨查询共享的缓存是_极其复杂_的问题
-
我们当前没有足够时间、资源或兴趣解决此问题
-
多数情况下,数据失效时重新获取已足够且更易理解
-
RTKQ 的核心目标是解决"数据获取"这一普遍痛点
在这种情况下,我们只是规范化了 getUsers 端点的响应数据,将其存储为 {[id]: value} 查找表。然而,这_并非_等同于“规范化缓存”——我们仅转换了_这一响应的存储方式_,而不是跨端点或请求去重结果。
从结果中选择值
最后一个从旧的 postsSlice 中读取数据的组件是 <UserPage>,它根据当前用户筛选文章列表。我们已经看到,可以使用 useGetPostsQuery() 获取全部文章列表,然后在组件内部进行转换,例如在 useMemo 内部排序。查询钩子还提供了通过 selectFromResult 选项选择缓存状态片段的能力,并仅在选中的片段发生变化时重新渲染。
useQuery 钩子总是将缓存键参数作为第一个参数,如果需要提供钩子选项,则必须作为第二个参数,例如 useSomeQuery(cacheKey, options)。在本例中,getUsers 端点没有任何实际的缓存键参数。在语义上,这等同于缓存键为 undefined。因此,为了向钩子提供选项,我们必须调用 useGetUsersQuery(undefined, options)。
我们可以使用 selectFromResult 让 <UserPage> 仅从缓存中读取筛选后的文章列表。然而,为了让 selectFromResult 避免不必要的重新渲染,我们需要确保提取的任何数据都正确地进行记忆化。为此,我们应该创建一个新的选择器实例,以便 <UserPage> 组件每次渲染时都可以复用,这样选择器就能根据输入记忆化结果。
import { Link, useParams } from 'react-router-dom'
import { createSelector } from '@reduxjs/toolkit'
import type { TypedUseQueryStateResult } from '@reduxjs/toolkit/query/react'
import { useAppSelector } from '@/app/hooks'
import { useGetPostsQuery, Post } from '@/features/api/apiSlice'
import { selectUserById } from './usersSlice'
// Create a TS type that represents "the result value passed
// into the `selectFromResult` function for this hook"
type GetPostSelectFromResultArg = TypedUseQueryStateResult<Post[], any, any>
const selectPostsForUser = createSelector(
(res: GetPostSelectFromResultArg) => res.data,
(res: GetPostSelectFromResultArg, userId: string) => userId,
(data, userId) => data?.filter(post => post.user === userId)
)
export const UserPage = () => {
const { userId } = useParams()
const user = useAppSelector(state => selectUserById(state, userId!))
// Use the same posts query, but extract only part of its data
const { postsForUser } = useGetPostsQuery(undefined, {
selectFromResult: result => ({
// Optional: Include all of the existing result fields like `isFetching`
...result,
// Include a field called `postsForUser` in the result object,
// which will be a filtered list of posts
postsForUser: selectPostsForUser(result, userId!)
})
})
// omit rendering logic
}
我们在此创建的记忆化选择器函数有一个关键区别。通常,选择器期望整个 Redux state 作为其第一个参数,并从 state 中提取或派生值。然而,在本例中,我们仅处理缓存中保存的“结果”值。结果对象内部有一个 data 字段,包含我们需要的实际值,以及一些请求元数据字段。
由于此选择器接收的第一个参数不是通常的 RootState 类型,我们需要告诉 TS 该结果值的形式。RTK Query 包导出了一个名为 TypedUseQueryStateResult 的 TS 类型,用于表示 useQuery 钩子返回对象的类型。我们可以用它来声明期望结果包含一个 Post[] 数组,然后使用该类型定义我们的选择器。
在 RTK 2.x 和 Reselect 5.x 中,记忆化选择器具有无限缓存大小,因此即使参数改变,仍可保留先前的记忆化结果。如果你使用的是 RTK 1.x 或 Reselect 4.x,请注意记忆化选择器默认缓存大小仅为 1。你需要为每个组件创建唯一的选择器实例,以确保选择器在传递不同参数(如 ID)时能一致地记忆化。
我们的 selectFromResult 回调函数接收包含原始请求元数据和来自服务器的 data 的 result 对象,并应返回一些提取或派生的值。因为查询钩子会向此处返回的内容添加一个额外的 refetch 方法,所以 selectFromResult 应始终返回一个包含所需字段的对象。
由于 result 保存在 Redux store 中,我们不能直接修改它——需要返回一个新对象。查询钩子会对返回的对象进行“浅比较”,仅当字段之一发生变化时才重新渲染组件。我们可以通过仅返回该组件所需的特定字段来优化重新渲染——如果不需要其余的元数据标志,可以完全省略它们。如果确实需要,可以展开原始的 result 值将其包含在输出中。
在这种情况下,我们将字段命名为 postsForUser,可以从钩子函数结果中解构出这个新字段。每次调用 selectPostsForUser(result, userId) 时,它会将过滤后的数组进行记忆化,仅当获取的数据或用户 ID 发生变化时才会重新计算。
转换方法比较
至此我们已经看到三种管理响应数据转换的方式:
-
在缓存中保留原始响应,在组件中读取完整结果并派生出所需值
-
在缓存中保留原始响应,使用
selectFromResult读取派生结果 -
在存储到缓存前转换响应数据
这些方法在不同场景下各有优势,以下是使用建议:
-
transformResponse:当端点的所有消费者都需要特定格式时使用,例如对响应进行规范化以实现更快的 ID 查找 -
selectFromResult:当部分端点消费者仅需部分数据时使用,例如过滤后的列表 -
按组件使用/
useMemo:当只有特定组件需要转换缓存数据时使用
高级缓存更新
我们已完成文章和用户数据的更新,接下来将处理反应(reactions)和通知(notifications)。将这些功能迁移到 RTK Query 将让我们有机会实践其缓存数据处理的高级技巧,从而提升用户体验。
持久化反应数据
最初我们仅在客户端跟踪反应数据,未将其持久化到服务器。现在添加新的 addReaction 变更操作(mutation),在用户每次点击反应按钮时更新服务器上对应的 Post。
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints
addReaction: builder.mutation<
Post,
{ postId: string; reaction: ReactionName }
>({
query: ({ postId, reaction }) => ({
url: `posts/${postId}/reactions`,
method: 'POST',
// In a real app, we'd probably need to base this on user ID somehow
// so that a user can't do the same reaction more than once
body: { reaction }
}),
invalidatesTags: (result, error, arg) => [
{ type: 'Post', id: arg.postId }
]
})
})
})
export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation,
useEditPostMutation,
useAddReactionMutation
} = apiSlice
与其他变更操作类似,我们接收参数并向服务器发送包含请求数据的请求。由于是小型示例应用,我们仅提供反应名称,由服务器递增该文章上对应反应类型的计数器。
我们知道需要重新获取该文章才能在客户端看到数据变化,因此可以根据其 ID 使该特定 Post 条目失效。
完成上述操作后,更新 <ReactionButtons> 组件以使用此变更操作。
import { useAddReactionMutation } from '@/features/api/apiSlice'
import type { Post, ReactionName } from './postsSlice'
const reactionEmoji: Record<ReactionName, string> = {
thumbsUp: '👍',
tada: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}
interface ReactionButtonsProps {
post: Post
}
export const ReactionButtons = ({ post }: ReactionButtonsProps) => {
const [addReaction] = useAddReactionMutation()
const reactionButtons = Object.entries(reactionEmoji).map(
([stringName, emoji]) => {
// Ensure TS knows this is a _specific_ string type
const reaction = stringName as ReactionName
return (
<button
key={reaction}
type="button"
className="muted-button reaction-button"
onClick={() => {
addReaction({ postId: post.id, reaction })
}}
>
{emoji} {post.reactions[reaction]}
</button>
)
}
)
return <div>{reactionButtons}</div>
}
让我们观察实际效果!转到主页面 <PostsList>,点击任意反应按钮查看变化。

糟糕!整个 <PostsList> 组件变为禁用状态,因为我们重新获取了整篇文章列表来响应单篇文章更新。这是刻意设计的效果(模拟 API 服务器设置了 2 秒响应延迟),但即使响应更快,这种体验也不理想。
反应功能的乐观更新
对于添加反应这类小更新,我们无需重新获取整篇文章列表。可以直接更新客户端已缓存数据使其与服务器预期状态匹配。若立即更新缓存,用户点击按钮时将获得即时反馈而无需等待响应返回。这种立即更新客户端状态的方法称为**"乐观更新"**,是 Web 应用中的常见模式。
RTK Query 包含直接更新客户端缓存的实用工具,可与 RTK Query 的**"请求生命周期"方法**结合实现乐观更新。
缓存更新工具
API切片还附加了一些额外方法,位于api.util下。这包括用于修改缓存的thunk:upsertQueryData用于添加或替换缓存条目,updateQueryData用于修改缓存条目。由于这些都是thunk,在任何能访问dispatch的地方都可以使用。
特别是updateQueryData这个工具thunk接收三个参数:要更新的端点名称、用于标识特定缓存条目的缓存键参数,以及更新缓存数据的回调函数。updateQueryData使用Immer技术,因此您可以像在createSlice中那样直接"修改"草稿状态的缓存数据:
dispatch(
apiSlice.util.updateQueryData(endpointName, queryArg, draft => {
// mutate `draft` here like you would in a reducer
draft.value = 123
})
)
updateQueryData会生成一个包含修改差异(patch diff)的动作对象。当我们派发(dispatch)这个动作时,dispatch的返回值是一个patchResult对象。如果调用patchResult.undo(),它会自动派发一个反转差异修改的动作。
onQueryStarted生命周期
我们将要了解的第一个生命周期方法是onQueryStarted。该选项对查询和变更都可用。
如果提供了onQueryStarted,每次发起新请求时都会调用它。这为我们提供了在请求发出后执行额外逻辑的机会。
类似于异步thunk和监听器效果,onQueryStarted回调的第一个参数是请求中的查询参数arg,第二个参数是lifecycleApi对象。lifecycleApi包含与createAsyncThunk相同的{dispatch, getState, extra, requestId}值。它还包含一些该生命周期特有的额外字段,其中最重要的是lifecycleApi.queryFulfilled——这个Promise会在请求返回时根据请求结果resolve或reject。
实现乐观更新
我们可以利用onQueryStarted生命周期中的更新工具来实现"乐观更新"(在请求完成前更新缓存)或"保守更新"(在请求完成后更新缓存)。
要实现乐观更新,我们可以在getPosts缓存中找到特定的Post条目,并"修改"它以增加反应计数器。如果同一个帖子ID对应的Post对象在getPost缓存中还有第二个副本,我们也需要更新该缓存条目。
默认情况下,我们假设请求会成功。如果请求失败,可以await lifecycleApi.queryFulfilled捕获错误,然后撤销之前的修改以回退乐观更新。
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints
addReaction: builder.mutation<
Post,
{ postId: string; reaction: ReactionName }
>({
query: ({ postId, reaction }) => ({
url: `posts/${postId}/reactions`,
method: 'POST',
// In a real app, we'd probably need to base this on user ID somehow
// so that a user can't do the same reaction more than once
body: { reaction }
}),
// The `invalidatesTags` line has been removed,
// since we're now doing optimistic updates
async onQueryStarted({ postId, reaction }, lifecycleApi) {
// `updateQueryData` requires the endpoint name and cache key arguments,
// so it knows which piece of cache state to update
const getPostsPatchResult = lifecycleApi.dispatch(
apiSlice.util.updateQueryData('getPosts', undefined, draft => {
// The `draft` is Immer-wrapped and can be "mutated" like in createSlice
const post = draft.find(post => post.id === postId)
if (post) {
post.reactions[reaction]++
}
})
)
// We also have another copy of the same data in the `getPost` cache
// entry for this post ID, so we need to update that as well
const getPostPatchResult = lifecycleApi.dispatch(
apiSlice.util.updateQueryData('getPost', postId, draft => {
draft.reactions[reaction]++
})
)
try {
await lifecycleApi.queryFulfilled
} catch {
getPostsPatchResult.undo()
getPostPatchResult.undo()
}
}
})
})
})
在这个场景中,我们还移除了之前添加的invalidatesTags行,因为点击反应按钮时我们不需要重新获取帖子数据。
现在如果快速多次点击反应按钮,我们会看到UI中的数字每次都会增加。观察网络面板,还会看到每个独立的请求都发送到了服务器。
有时变更请求会返回带有重要数据的服务器响应,例如应该替换临时客户端ID的最终项目ID或其他相关数据。如果先执行const res = await lifecycleApi.queryFulfilled,就可以在后续使用响应数据来执行"保守更新"。
通知的流式更新
我们最后一个功能是通知面板。在第六部分最初构建此功能时,我们提到"在实际应用中,每当有事件发生时服务器都会向客户端推送更新"。我们当时通过添加"刷新通知"按钮模拟了此功能,该按钮会发起 HTTP GET 请求获取更多通知条目。
应用通常会先发起_初始_请求从服务器获取数据,然后建立 Websocket 连接以持续接收更新。RTK Query 的生命周期方法为我们提供了实现此类缓存数据"流式更新"的能力。
我们已经见过 onQueryStarted 生命周期如何实现乐观更新(或悲观更新)。此外,RTK Query 提供了 onCacheEntryAdded 端点生命周期处理器,这是实现流式更新的理想场所。我们将利用此功能实现更贴近实际的通知管理方案。
onCacheEntryAdded 生命周期
与 onQueryStarted 类似,onCacheEntryAdded 生命周期方法同时适用于查询和变更端点。
每当新缓存条目(端点 + 序列化查询参数)添加到缓存时,onCacheEntryAdded 就会被调用。这意味着它的执行频率低于 onQueryStarted(后者在每次请求发生时都会运行)。
类似于 onQueryStarted,onCacheEntryAdded 接收两个参数。第一个是常规的查询 args 值。第二个是略有不同的 lifecycleApi,它包含 {dispatch, getState, extra, requestId},以及一个 updateCachedData 工具函数——这是 api.util.updateQueryData 的替代形式,该函数已内置目标端点名称和查询参数信息,并会自动为您完成派发操作。
还有两个额外的 Promise 可供等待:
-
cacheDataLoaded:在收到首个缓存值时解析,通常用于等待实际值存入缓存后再执行后续逻辑 -
cacheEntryRemoved:在缓存条目被移除时解析(即没有活跃订阅者且缓存条目已被垃圾回收)
只要数据仍有 1 个以上活跃订阅者,缓存条目就会保持活动状态。当订阅者数量归零且缓存生存时间计时器到期时,缓存条目将被移除,此时 cacheEntryRemoved 会解析。典型的使用模式是:
-
立即
await cacheDataLoaded -
创建服务器端数据订阅(如 Websocket)
-
收到更新时,使用
updateCachedData根据更新"修改"缓存值 -
最后
await cacheEntryRemoved -
随后清理订阅
这使得 onCacheEntryAdded 成为放置长时间运行逻辑的理想场所——只要 UI 需要特定数据,该逻辑就会持续运行。典型示例是聊天应用:需要获取聊天频道的初始消息,通过 Websocket 订阅持续接收新消息,并在用户关闭频道时断开连接。
获取通知
我们需要将此工作拆分为几个步骤。
首先,我们将为通知创建新端点,并替换 fetchNotificationsWebsocket thunk,使其触发模拟后端通过 Websocket(而非 HTTP 请求)返回通知。
我们将像处理 getUsers 那样,在 notificationsSlice 中注入 getNotifications 端点,以展示此方式的可行性。
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
import { client } from '@/api/client'
import { forceGenerateNotifications } from '@/api/server'
import type { AppThunk, RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'
import { apiSlice } from '@/features/api/apiSlice'
// omit types and `fetchNotifications` thunk
export const apiSliceWithNotifications = apiSlice.injectEndpoints({
endpoints: builder => ({
getNotifications: builder.query<ServerNotification[], void>({
query: () => '/notifications'
})
})
})
export const { useGetNotificationsQuery } = apiSliceWithNotifications
getNotifications 是标准查询端点,用于存储从服务器接收的 ServerNotification 对象。
接着,在 <Navbar> 中,我们可以使用新的查询钩子自动获取通知数据。但这样做只会返回 ServerNotification 对象,而不是带有额外 {read, isNew} 字段的 ClientNotification 对象,因此需要暂时禁用对 notification.new 的检查:
// omit other imports
import { allNotificationsRead, useGetNotificationsQuery } from './notificationsSlice'
export const NotificationsList = () => {
const dispatch = useAppDispatch()
const { data: notifications = [] } = useGetNotificationsQuery()
useLayoutEffect(() => {
dispatch(allNotificationsRead())
})
const renderedNotifications = notifications.map((notification) => {
const notificationClassname = classnames('notification', {
// new: notification.isNew,
})
}
// omit rendering
}
进入"通知"选项卡后,会显示若干条目,但没有任何条目会通过着色标识为新通知。同时,点击"刷新通知"按钮时,"未读通知"计数器会持续增加。这有两个原因:该按钮仍在触发原始的 fetchNotifications thunk(将条目存储在 state.notifications 切片中),且 <NotificationsList> 组件未重新渲染(它依赖 useGetNotificationsQuery 钩子的缓存数据而非 state.notifications 切片),导致 useLayoutEffect 未运行且未派发 allNotificationsRead。
跟踪客户端状态
下一步需要重新思考如何跟踪通知的"已读"状态。
此前我们通过 fetchNotifications thunk 获取 ServerNotification 对象后,在 reducer 中添加 {read, isNew} 字段并存储这些对象。现在我们将 ServerNotification 对象保存在 RTK Query 缓存中。
我们可以进行更多手动缓存更新:使用 transformResponse 添加额外字段,然后在用户查看通知时修改缓存本身。
但我们将采用不同的方式:在 notificationsSlice 内部跟踪已读状态。
从设计理念看,我们需要跟踪每个通知项的 {read, isNew} 状态。如果能在切片中为每个收到的通知维护对应的"元数据"条目(前提是能获知查询钩子何时获取通知并访问通知ID),即可实现此目的。
幸运的是,这可以实现!因为 RTK Query 基于 createAsyncThunk 等标准 Redux Toolkit 组件构建,每次请求完成时都会派发携带结果的 fulfilled action。我们只需在 notificationsSlice 中监听该 action,而 createSlice.extraReducers 正是处理此 action 的位置。
但具体监听什么?由于这是 RTKQ 端点,我们无法访问 asyncThunk.fulfilled/pending action 创建器,因此不能直接传递给 builder.addCase()。
RTK Query 端点暴露了 matchFulfilled 匹配器函数,我们可在 extraReducers 中使用它监听该端点的 fulfilled action(注意需将 builder.addCase() 改为 builder.addMatcher())。
因此,我们将把 ClientNotification 重构为 NotificationMetadata 类型,监听 getNotifications 查询 action,并在切片中存储"纯元数据"对象而非完整通知。
作为重构的一部分,将 notificationsAdapter 重命名为 metadataAdapter,并将所有 notification 变量替换为 metadata 以提高可读性。虽然看似改动较多,但主要是变量重命名。
同时将实体适配器的 selectEntities 选择器导出为 selectMetadataEntities。我们需要在 UI 中按 ID 查找这些元数据对象,若组件中能直接访问查找表将更加便捷。
// omit imports and thunks
// Replaces `ClientNotification`, since we just need these fields
export interface NotificationMetadata {
// Add an `id` field, since this is now a standalone object
id: string
read: boolean
isNew: boolean
}
export const fetchNotifications = createAppAsyncThunk(
'notifications/fetchNotifications',
async (_unused, thunkApi) => {
// Deleted timestamp lookups - we're about to remove this thunk anyway
const response = await client.get<ServerNotification[]>(
`/fakeApi/notifications`
)
return response.data
}
)
// Renamed from `notificationsAdapter`, and we don't need sorting
const metadataAdapter = createEntityAdapter<NotificationMetadata>()
const initialState = metadataAdapter.getInitialState()
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
// Rename to `metadata`
Object.values(state.entities).forEach(metadata => {
metadata.read = true
})
}
},
extraReducers(builder) {
// Listen for the endpoint `matchFulfilled` action with `addMatcher`
builder.addMatcher(
apiSliceWithNotifications.endpoints.getNotifications.matchFulfilled,
(state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsMetadata: NotificationMetadata[] =
action.payload.map(notification => ({
// Give the metadata object the same ID as the notification
id: notification.id,
read: false,
isNew: true
}))
// Rename to `metadata`
Object.values(state.entities).forEach(metadata => {
// Any notifications we've read are no longer new
metadata.isNew = !metadata.read
})
metadataAdapter.upsertMany(state, notificationsMetadata)
}
)
}
})
export const { allNotificationsRead } = notificationsSlice.actions
export default notificationsSlice.reducer
// Rename the selector
export const {
selectAll: selectAllNotificationsMetadata,
selectEntities: selectMetadataEntities
} = metadataAdapter.getSelectors(
(state: RootState) => state.notifications
)
export const selectUnreadNotificationsCount = (state: RootState) => {
const allMetadata = selectAllNotificationsMetadata(state)
const unreadNotifications = allMetadata.filter(metadata => !metadata.read)
return unreadNotifications.length
}
随后可将该元数据查找表读入 <NotificationsList>,为每个渲染的通知查找对应元数据对象,并重新启用 isNew 检查以应用正确样式:
import { allNotificationsRead, useGetNotificationsQuery, selectMetadataEntities } from './notificationsSlice'
export const NotificationsList = () => {
const dispatch = useAppDispatch()
const { data: notifications = [] } = useGetNotificationsQuery()
const notificationsMetadata = useAppSelector(selectMetadataEntities)
useLayoutEffect(() => {
dispatch(allNotificationsRead())
})
const renderedNotifications = notifications.map((notification) => {
// Get the metadata object matching this notification
const metadata = notificationsMetadata[notification.id]
const notificationClassname = classnames('notification', {
// re-enable the `isNew` check for styling
new: metadata.isNew,
})
// omit rendering
}
}
现在查看"通知"选项卡,新通知样式已正确显示...但仍未获取更多通知,且这些通知未被标记为已读。
通过 WebSocket 推送通知
我们还需要完成几个步骤,才能完全切换到通过服务器推送获取更多通知。
下一步是将"刷新通知"按钮从派发异步 thunk 获取 HTTP 请求,改为强制模拟后端通过 WebSocket 发送通知。
我们的 src/api/server.ts 文件已经配置了模拟 WebSocket 服务器,类似于模拟 HTTP 服务器。由于没有真实后端(也没有其他用户!),我们仍需手动告知模拟服务器何时发送新通知——我们将继续通过点击按钮强制更新来模拟这一过程。为此,server.ts 导出了名为 forceGenerateNotifications 的函数,它将强制后端通过 WebSocket 推送通知条目。
我们将用 fetchNotificationsWebsocket thunk 替换现有的 fetchNotifications 异步 thunk。fetchNotificationsWebsocket 的功能与现有的 fetchNotifications 异步 thunk 类似,但无需实际发起 HTTP 请求,因此没有 await 调用也不返回有效载荷。我们只需调用 server.ts 专门导出的函数来模拟服务器端推送通知。
因此,fetchNotificationsWebsocket 甚至不需要使用 createAsyncThunk。它只是一个普通手写 thunk,我们可以使用 AppThunk 类型描述该 thunk 函数,并为 (dispatch, getState) 提供正确类型。
为实现"最新时间戳"检查,我们需要添加选择器来读取通知缓存条目。我们将采用与用户切片相同的模式。
import {
createEntityAdapter,
createSlice,
createSelector
} from '@reduxjs/toolkit'
import { forceGenerateNotifications } from '@/api/server'
import type { AppThunk, RootState } from '@/app/store'
import { apiSlice } from '@/features/api/apiSlice'
// omit types and API slice setup
export const { useGetNotificationsQuery } = apiSliceWithNotifications
export const fetchNotificationsWebsocket =
(): AppThunk => (dispatch, getState) => {
const allNotifications = selectNotificationsData(getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification?.date ?? ''
// Hardcode a call to the mock server to simulate a server push scenario over websockets
forceGenerateNotifications(latestTimestamp)
}
const emptyNotifications: ServerNotification[] = []
export const selectNotificationsResult =
apiSliceWithNotifications.endpoints.getNotifications.select()
const selectNotificationsData = createSelector(
selectNotificationsResult,
notificationsResult => notificationsResult.data ?? emptyNotifications
)
// omit slice and selectors
然后我们可以让 <Navbar> 派发 fetchNotificationsWebsocket:
import {
fetchNotificationsWebsocket,
selectUnreadNotificationsCount,
} from '@/features/notifications/notificationsSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'
import { UserIcon } from './UserIcon'
export const Navbar = () => {
// omit hooks
if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}
const fetchNewNotifications = () => {
dispatch(fetchNotificationsWebsocket())
}
快完成了!我们已通过 RTK Query 获取初始通知,在客户端跟踪已读状态,并建立了通过 WebSocket 强制推送新通知的基础设施。但是,如果现在点击"刷新通知",将抛出错误——我们尚未实现 WebSocket 处理逻辑!
现在让我们实现实际的流式更新逻辑。
实现流式更新
在本应用中,我们希望在用户登录后立即检查通知,并持续监听所有未来传入的通知更新。用户登出时应停止监听。
由于 <Navbar> 仅在用户登录后渲染且始终保持渲染状态,这是维持缓存订阅的理想位置。我们可以在该组件中渲染 useGetNotificationsQuery() 钩子来实现。
// omit other imports
import {
fetchNotificationsWebsocket,
selectUnreadNotificationsCount,
useGetNotificationsQuery
} from '@/features/notifications/notificationsSlice'
export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)
// Trigger initial fetch of notifications and keep the websocket open to receive updates
useGetNotificationsQuery()
// omit rest of the component
}
最后一步是向 getNotifications 端点添加 onCacheEntryAdded 生命周期处理器,并实现 WebSocket 处理逻辑。
我们将创建新 WebSocket 连接,订阅传入消息,从中读取通知数据,并用新增数据更新 RTKQ 缓存条目。概念上这类似于在 onQueryStarted 中实现的乐观更新。
这里还有另一个问题:通过 WebSocket 接收通知时,没有显式的"请求成功" action 被派发,但我们仍需为所有传入通知创建新的通知元数据条目。
我们将通过创建专门的新 Redux action 类型来解决此问题,该类型仅用于表示"已接收更多通知",并在 WebSocket 处理器内部派发它。然后可以使用 isAnyOf 匹配工具让 notificationsSlice 同时监听端点 action 和此 action,并在两种情况下执行相同的元数据处理逻辑。
import {
createEntityAdapter,
createSlice,
createSelector,
createAction,
isAnyOf
} from '@reduxjs/toolkit'
// omit imports and other code
const notificationsReceived = createAction<ServerNotification[]>('notifications/notificationsReceived')
export const apiSliceWithNotifications = apiSlice.injectEndpoints({
endpoints: builder => ({
getNotifications: builder.query<ServerNotification[], void>({
query: () => '/notifications',
async onCacheEntryAdded(arg, lifecycleApi) {
// create a websocket connection when the cache subscription starts
const ws = new WebSocket('ws://localhost')
try {
// wait for the initial query to resolve before proceeding
await lifecycleApi.cacheDataLoaded
// when data is received from the socket connection to the server,
// update our query result with the received message
const listener = (event: MessageEvent<string>) => {
const message: {
type: 'notifications'
payload: ServerNotification[]
} = JSON.parse(event.data)
switch (message.type) {
case 'notifications': {
lifecycleApi.updateCachedData(draft => {
// Insert all received notifications from the websocket
// into the existing RTKQ cache array
draft.push(...message.payload)
draft.sort((a, b) => b.date.localeCompare(a.date))
})
// Dispatch an additional action so we can track "read" state
lifecycleApi.dispatch(notificationsReceived(message.payload))
break
}
default:
break
}
}
ws.addEventListener('message', listener)
} catch {
// no-op in case `cacheEntryRemoved` resolves before `cacheDataLoaded`,
// in which case `cacheDataLoaded` will throw
}
// cacheEntryRemoved will resolve when the cache subscription is no longer active
await lifecycleApi.cacheEntryRemoved
// perform cleanup steps once the `cacheEntryRemoved` promise resolves
ws.close()
}
})
})
})
export const { useGetNotificationsQuery } = apiSliceWithNotifications
const matchNotificationsReceived = isAnyOf(
notificationsReceived,
apiSliceWithNotifications.endpoints.getNotifications.matchFulfilled,
)
// omit other code
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: { /* omit reducers */ },
extraReducers(builder) {
builder.addMatcher(matchNotificationsReceived, (state, action) => {
// omit logic
}
},
})
添加缓存条目时,我们会创建新的 WebSocket 实例连接到模拟服务器后端。
我们等待 lifecycleApi.cacheDataLoaded Promise 解析,此时请求已完成且实际数据已可用。
我们需要订阅来自 WebSocket 的传入消息。我们的回调函数将接收到一个 WebSocket MessageEvent 事件,并且我们知道 event.data 是一个包含来自后端的 JSON 序列化通知数据的字符串。
当我们收到该消息时,我们会解析内容,并确认解析后的对象是否匹配我们正在寻找的消息类型。如果匹配,我们将调用 lifecycleApi.updateCachedData(),将所有新通知添加到现有的缓存条目中,并重新排序以确保它们处于正确的顺序。
最后,我们还可以等待 lifecycleApi.cacheEntryRemoved 的 Promise 结果,以了解何时需要关闭 WebSocket 并进行清理。
请注意,我们并不一定需要在生命周期方法中创建 WebSocket。根据应用结构,您可能已经在应用设置过程的早期创建了它,并且它可能位于另一个模块文件或自己的 Redux 中间件中。这里真正重要的是,我们使用 onCacheEntryAdded 生命周期来知道何时开始监听传入数据、将结果插入缓存条目,以及在缓存条目消失时进行清理。
就这样!现在,当我们点击"刷新通知"时,我们应该会看到未读通知计数增加,点击进入"通知"选项卡应能适当地高亮显示已读和未读通知。
清理
作为最后一步,我们可以进行一些额外的清理。postsSlice.ts 中实际的 createSlice 调用不再使用,因此我们可以删除该 slice 对象及其关联的选择器和类型,然后从 Redux store 中移除 postsReducer。我们将保留 addPostsListeners 函数和类型,因为这是放置该代码的合理位置。
学习要点
至此,我们已经完成了将应用程序转换为使用 RTK Query 的工作!所有的数据获取都已切换为使用 RTKQ,并且我们通过添加乐观更新和流式更新改善了用户体验。
正如我们所看到的,RTK Query 包含了一些强大的选项来控制我们如何管理缓存数据。虽然您可能不会立即需要所有这些选项,但它们提供了灵活性和关键能力,以帮助实现特定的应用程序行为。
让我们最后再看一下整个应用程序的运行情况:
- 可以使用特定的缓存标签进行更细粒度的缓存失效
- 缓存标签可以是
'Post'或{type: 'Post', id} - 端点可以根据结果和参数缓存键来提供或使缓存标签失效
- 缓存标签可以是
- RTK Query 的 API 是与 UI 无关的,可以在 React 之外使用
- 端点对象包含用于发起请求、生成结果选择器以及匹配请求动作对象的函数
- 可以根据需要以不同方式转换响应数据
- 端点可以定义
transformResponse回调函数以在缓存前修改数据 - 钩子可以接收
selectFromResult选项来提取/转换数据 - 组件可以读取整个值并使用
useMemo进行转换
- 端点可以定义
- RTK Query 提供了操作缓存数据的高级选项以改善用户体验
onQueryStarted生命周期可用于乐观更新,即在请求返回前立即更新缓存onCacheEntryAdded生命周期可用于流式更新,即基于服务器推送连接随时间更新缓存- RTKQ 端点具有
matchFulfilled匹配器,可用于监听 RTKQ 端点动作并运行额外逻辑,例如更新 slice 的状态
下一步是什么?
恭喜,您已经完成了 Redux 要点教程! 现在,您应该对 Redux Toolkit 和 React-Redux 是什么、如何编写和组织 Redux 逻辑、Redux 数据流及其在 React 中的使用,以及如何使用 configureStore 和 createSlice 等 API 有了扎实的理解。您还应该了解 RTK Query 如何简化获取和使用缓存数据的过程。
有关使用 RTK Query 的更多详细信息,请参阅 RTK Query 使用指南文档 和 API 参考。
本教程目前涵盖的概念已足够帮助您开始使用 React 和 Redux 构建自己的应用。现在是尝试自己动手完成项目的绝佳时机,通过实践巩固这些概念并观察其运作方式。如果您不确定要构建什么类型的项目,可以参考这个应用创意列表获取灵感。
《Redux 精要》教程侧重于"如何正确使用 Redux",而非"其工作原理"或"为何如此设计"。特别是 Redux Toolkit(RTK)作为高层抽象工具集,理解其抽象层背后的实际运作机制非常有价值。阅读《Redux 基础》教程将帮助您理解如何"手动编写" Redux 代码,以及我们为何推荐 Redux Toolkit 作为 Redux 逻辑的默认实现方式。
使用 Redux 章节包含多个重要概念的信息,例如如何组织 reducer 结构,而样式指南页面则提供了关于推荐模式和最佳实践的关键信息。
如果您想深入了解 Redux 的_存在意义_、它试图解决的问题以及设计使用哲学,请参阅 Redux 维护者 Mark Erikson 的博文:Redux 之道 第一部分:实现与意图和Redux 之道 第二部分:实践与哲学。
如需获取 Redux 问题帮助,欢迎加入 Discord 上 Reactiflux 服务器的 #redux 频道。
感谢您阅读本教程,祝您在使用 Redux 构建应用的过程中收获满满!