跳至主要内容

Redux Essentials,第 8 部分:RTK Query 高级模式

您将学到什么
  • 如何使用带有 ID 的标签来管理缓存失效和重新获取
  • 如何在 React 之外使用 RTK Query 缓存
  • 操作响应数据的技巧
  • 实现乐观更新和流式更新
先决条件
  • 完成 第 7 部分 以了解 RTK Query 的设置和基本用法

简介

第 7 部分:RTK Query 基础 中,我们了解了如何设置和使用 RTK Query API 来处理应用程序中的数据获取和缓存。我们在 Redux 存储中添加了一个“API 切片”,定义了用于获取帖子数据的“查询”端点,以及用于添加新帖子的“变异”端点。

在本节中,我们将继续迁移示例应用程序以使用 RTK Query 处理其他数据类型,并了解如何使用一些高级功能来简化代码库并改善用户体验。

信息

本节中的一些更改并非严格必要 - 它们是为了演示 RTK Query 的功能,并展示一些您可以做的事情,以便您了解在需要时如何使用这些功能。

编辑帖子

我们已经添加了一个变异端点来将新的帖子条目保存到服务器,并在我们的 <AddPostForm> 中使用了它。接下来,我们需要处理更新 <EditPostForm> 以允许我们编辑现有帖子。

更新编辑帖子表单

与添加帖子一样,第一步是在我们的 API 切片中定义一个新的变异端点。这将与添加帖子的变异非常相似,但端点需要在 URL 中包含帖子 ID 并使用 HTTP PATCH 请求来指示它正在更新一些字段。

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

export const {
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation,
useEditPostMutation
} = apiSlice

添加完后,我们可以更新 <EditPostForm>。它需要从存储中读取原始 Post 条目,使用它来初始化组件状态以编辑字段,然后将更新后的更改发送到服务器。目前,我们正在使用 selectPostById 读取 Post 条目,并手动调度 postUpdated thunk 来进行请求。

我们可以使用与 <SinglePostPage> 中相同的 useGetPostQuery 钩子从存储中的缓存中读取 Post 条目,并将使用新的 useEditPostMutation 钩子来处理保存更改。

features/posts/EditPostForm.js
import React, { useState } from 'react'
import { useHistory } from 'react-router-dom'

import { Spinner } from '../../components/Spinner'
import { useGetPostQuery, useEditPostMutation } from '../api/apiSlice'

export const EditPostForm = ({ match }) => {
const { postId } = match.params

const { data: post } = useGetPostQuery(postId)
const [updatePost, { isLoading }] = useEditPostMutation()

const [title, setTitle] = useState(post.title)
const [content, setContent] = useState(post.content)

const history = useHistory()

const onTitleChanged = e => setTitle(e.target.value)
const onContentChanged = e => setContent(e.target.value)

const onSavePostClicked = async () => {
if (title && content) {
await updatePost({ id: postId, title, content })
history.push(`/posts/${postId}`)
}
}

// omit rendering logic
}

缓存数据订阅生命周期

让我们试试看,看看会发生什么。打开浏览器的 DevTools,转到 Network 选项卡,然后刷新主页面。你应该会看到一个 GET 请求到 /posts,因为我们正在获取初始数据。当你点击“查看帖子”按钮时,你应该会看到第二个请求到 /posts/:postId,它返回单个帖子条目。

现在在单个帖子页面中点击“编辑帖子”。UI 切换到显示 <EditPostForm>,但这次没有针对单个帖子的网络请求。为什么呢?

RTK Query network requests

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 条目整个帖子列表

之前,我们了解了如何使用“标签”来使缓存数据的特定部分失效。我们声明了 `getPosts` 查询端点 *提供* 一个 `'Post'` 标签,而 `addNewPost` 变异端点 *使失效* 同一个 `'Post'` 标签。这样,每次添加新帖子时,我们都会强制 RTK Query 从 `getQuery` 端点重新获取整个帖子列表。

我们可以将 `'Post'` 标签添加到 `getPost` 查询和 `editPost` 变异中,但这会导致所有其他单个帖子也被重新获取。幸运的是,**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}` 值匹配的标签。
features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: (result = [], error, arg) => [
'Post',
...result.map(({ id }) => ({ type: 'Post', id }))
]
}),
getPost: builder.query({
query: postId => `/posts/${postId}`,
providesTags: (result, error, arg) => [{ type: 'Post', id: arg }]
}),
addNewPost: builder.mutation({
query: initialPost => ({
url: '/posts',
method: 'POST',
body: initialPost
}),
invalidatesTags: ['Post']
}),
editPost: builder.mutation({
query: post => ({
url: `posts/${post.id}`,
method: 'PATCH',
body: post
}),
invalidatesTags: (result, error, arg) => [{ type: 'Post', id: arg.id }]
})
})
})

这些回调函数中的 `result` 参数可能在响应没有数据或出现错误时未定义,因此我们必须安全地处理这种情况。对于 `getPosts`,我们可以使用一个默认的数组值作为映射对象,而对于 `getPost`,我们已经根据参数 ID 返回了一个单项数组。对于 `editPost`,我们知道帖子 ID 来自传递给触发函数的局部帖子对象,因此我们可以从那里读取它。

在进行这些更改后,让我们回到浏览器 DevTools 中打开 Network 选项卡,再次尝试编辑帖子。

RTK Query invalidation and refetching

当我们这次保存编辑后的帖子时,应该会看到两个请求接连发生。

  • 来自 editPost 变异的 PATCH /posts/:postId
  • 作为 getPost 查询重新获取的 GET /posts/:postId

然后,如果我们点击回到主“帖子”选项卡,我们还应该看到

  • 作为 getPosts 查询重新获取的 GET /posts

因为我们使用标签提供了端点之间的关系,RTK Query 知道当我们进行编辑并且具有该 ID 的特定标签失效时,它需要重新获取单个帖子和帖子列表 - 不需要进一步更改!同时,当我们编辑帖子时,getPosts 数据的缓存清除计时器过期,因此它从缓存中删除。当我们再次打开 <PostsList> 组件时,RTK Query 发现它在缓存中没有数据,并重新获取了它。

这里有一个注意事项。通过在 getPosts 中指定一个简单的 'Post' 标签并在 addNewPost 中使它失效,我们实际上最终会强制重新获取所有单个帖子。如果我们真的只想重新获取 getPost 端点的帖子列表,可以包含一个带有任意 ID 的附加标签,例如 {type: 'Post', id: 'LIST'},并使该标签失效。RTK Query 文档中有一个表格描述了如果失效某些通用/特定标签组合会发生什么

信息

RTK Query 还有许多其他选项用于控制何时以及如何重新获取数据,包括“条件获取”、“延迟查询”和“预取”,并且可以以多种方式自定义查询定义。有关使用这些功能的更多详细信息,请参阅 RTK Query 使用指南文档。

管理用户数据

我们已经完成了将帖子数据管理转换为使用 RTK Query。接下来,我们将转换用户列表。

由于我们已经了解了如何使用 RTK Query 钩子来获取和读取数据,因此在本节中,我们将尝试不同的方法。RTK Query 的核心 API 与 UI 无关,可以与任何 UI 层一起使用,而不仅仅是 React。通常,您应该坚持使用钩子,但在这里,我们将使用 RTK Query 核心 API 来处理用户数据,以便您可以了解如何使用它。

手动获取用户

我们目前正在 usersSlice.js 中定义一个 fetchUsers 异步 thunk,并在 index.js 中手动调度该 thunk,以便尽快获得用户列表。我们可以使用 RTK Query 完成相同的流程。

我们将从在 apiSlice.js 中定义一个 getUsers 查询端点开始,类似于我们现有的端点。我们将导出 useGetUsersQuery 钩子仅仅是为了保持一致性,但目前我们不会使用它。

features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints

getUsers: builder.query({
query: () => '/users'
})
})
})

export const {
useGetPostsQuery,
useGetPostQuery,
useGetUsersQuery,
useAddNewPostMutation,
useEditPostMutation
} = apiSlice

如果我们检查 API 切片对象,它包含一个 endpoints 字段,其中包含我们定义的每个端点的端点对象。

API slice endpoint contents

每个端点对象包含

  • 与我们从根 API 切片对象导出的相同的主要查询/变异钩子,但命名为 useQueryuseMutation
  • 对于查询端点,还有一组用于“延迟查询”或部分订阅等场景的查询钩子
  • 一组 “匹配器”实用程序,用于检查此端点请求分派的 pending/fulfilled/rejected 操作
  • 一个 initiate thunk,用于触发对该端点的请求
  • 一个 select 函数,用于创建 记忆化选择器,可以检索此端点的缓存结果数据 + 状态条目

如果我们想在 React 之外获取用户列表,可以在我们的索引文件中调度 getUsers.initiate() thunk

index.js
// 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())

ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
)
}
main()

此调度在查询钩子内部自动发生,但如果需要,我们可以手动启动它。

注意

手动调度 RTKQ 请求 thunk 将创建一个订阅条目,但随后需要您 稍后取消订阅该数据 - 否则数据将永久保留在缓存中。在本例中,我们始终需要用户数据,因此可以跳过取消订阅。

选择用户数据

我们目前有像 selectAllUsersselectUserById 这样的选择器,它们是由我们的 createEntityAdapter 用户适配器生成的,并且正在从 state.users 中读取。如果我们重新加载页面,我们所有与用户相关的显示都会中断,因为 state.users 切片没有数据。现在我们正在为 RTK Query 的缓存获取数据,我们应该用从缓存中读取的等效项替换这些选择器。

API 切片端点中的 endpoint.select() 函数将在每次调用它时创建一个新的记忆化选择器函数。select() 将缓存键作为参数,这必须与您作为参数传递给查询钩子或 initiate() thunk 的相同缓存键。生成的 selector 使用该缓存键来准确地知道应该从存储中的缓存状态返回哪个缓存结果。

在这种情况下,我们的 getUsers 端点不需要任何参数 - 我们总是获取完整的用户列表。因此,我们可以创建一个没有参数的缓存选择器,缓存键将变为 undefined

features/users/usersSlice.js
import {
createSlice,
createEntityAdapter,
createSelector
} from '@reduxjs/toolkit'

import { apiSlice } from '../api/apiSlice'

/* Temporarily ignore adapter - we'll use this again shortly
const usersAdapter = createEntityAdapter()

const initialState = usersAdapter.getInitialState()
*/

// 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()

const emptyUsers = []

export const selectAllUsers = createSelector(
selectUsersResult,
usersResult => usersResult?.data ?? emptyUsers
)

export const selectUserById = createSelector(
selectAllUsers,
(state, userId) => userId,
(users, userId) => users.find(user => user.id === userId)
)

/* Temporarily ignore selectors - we'll come back to this later
export const {
selectAll: selectAllUsers,
selectById: selectUserById,
} = usersAdapter.getSelectors((state) => state.users)
*/

一旦我们有了最初的 selectUsersResult 选择器,我们就可以用一个从缓存结果中返回用户数组的选择器替换现有的 selectAllUsers 选择器,然后用一个从该数组中找到正确用户的选择器替换 selectUserById

现在,我们将从 usersAdapter 中注释掉这些选择器 - 我们将在稍后进行另一个更改,切换回使用这些选择器。

我们的组件已经导入了 selectAllUsersselectUserById,所以这个更改应该可以正常工作!尝试刷新页面并点击帖子列表和单个帖子视图。每个显示的帖子中应该出现正确的用户名,以及 <AddPostForm> 中的下拉菜单。

由于 usersSlice 甚至不再被使用,我们可以继续从该文件中删除 createSlice 调用,并从我们的商店设置中删除 users: usersReducer。我们仍然有一些代码引用 postsSlice,所以我们还不能完全删除它 - 我们很快就会做到这一点。

注入端点

对于大型应用程序,通常将功能“代码拆分”到单独的包中,然后在第一次使用功能时按需“延迟加载”它们。我们说过 RTK Query 通常每个应用程序只有一个“API 切片”,到目前为止,我们已经在 apiSlice.js 中直接定义了所有端点。如果我们想将一些端点定义代码拆分,或者将它们移动到另一个文件中以防止 API 切片文件变得太大,会发生什么?

RTK Query 支持使用 apiSlice.injectEndpoints() 拆分端点定义。这样,我们仍然可以拥有一个带有单个中间件和缓存 reducer 的 API 切片,但可以将一些端点的定义移动到其他文件。这使得代码拆分场景成为可能,以及根据需要将一些端点与功能文件夹一起放置。

为了说明这个过程,让我们将 getUsers 端点切换到在 usersSlice.js 中注入,而不是在 apiSlice.js 中定义。

我们已经将 apiSlice 导入到 usersSlice.js 中,以便我们可以访问 getUsers 端点,因此我们可以切换到在这里调用 apiSlice.injectEndpoints()

features/users/usersSlice.js
import { apiSlice } from '../api/apiSlice'

export const extendedApiSlice = apiSlice.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query({
query: () => '/users'
})
})
})

export const { useGetUsersQuery } = extendedApiSlice

export const selectUsersResult = extendedApiSlice.endpoints.getUsers.select()

injectEndpoints() 会修改原始的 API 切片对象以添加额外的端点定义,然后返回它。我们最初添加到商店的实际缓存 reducer 和中间件仍然可以正常工作。此时,apiSliceextendedApiSlice 是同一个对象,但将 extendedApiSlice 对象而不是 apiSlice 作为参考可能会有所帮助,提醒我们自己。(如果您使用的是 TypeScript,这一点更为重要,因为只有 extendedApiSlice 值具有新端点的附加类型。)

目前,唯一引用getUsers端点的文件是我们的索引文件,它正在分发initiatethunk。我们需要更新它以导入扩展的API切片。

index.js
  // omit other imports
- import { apiSlice } from './features/api/apiSlice'
+ import { extendedApiSlice } from './features/users/usersSlice'


async function main() {
// Start our mock API server
await worker.start({ onUnhandledRequest: 'bypass' })


- store.dispatch(apiSlice.endpoints.getUsers.initiate())
+ store.dispatch(extendedApiSlice.endpoints.getUsers.initiate())


ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
)
}
main()

或者,您可以直接从切片文件中导出特定的端点。

操作响应数据

到目前为止,我们所有的查询端点都只是将从服务器接收到的响应数据原封不动地存储在主体中。getPostsgetUsers都期望服务器返回一个数组,而getPost期望单个Post对象作为主体。

客户端通常需要从服务器响应中提取数据片段,或者在缓存数据之前以某种方式转换数据。例如,如果/getPost请求返回一个类似{post: {id}}的主体,数据嵌套怎么办?

从概念上讲,我们可以用几种方法来处理这个问题。一种选择是提取responseData.post字段并将其存储在缓存中,而不是存储整个主体。另一种方法是将整个响应数据存储在缓存中,但让我们的组件只指定他们需要的缓存数据的特定部分。

转换响应

端点可以定义一个transformResponse处理程序,它可以在将从服务器接收的数据缓存之前提取或修改数据。对于getPost示例,我们可以使用transformResponse: (responseData) => responseData.post,它将只缓存实际的Post对象,而不是整个响应主体。

第 6 部分:性能和规范化中,我们讨论了将数据存储在规范化结构中的原因。特别是,它允许我们根据 ID 查找和更新项目,而不是必须循环遍历数组以找到正确的项目。

我们之前的selectUserById选择器必须循环遍历缓存的用户数组以找到正确的User对象。如果我们将响应数据转换为使用规范化方法存储,我们可以将其简化为直接根据 ID 查找用户。

我们之前在usersSlice中使用createEntityAdapter来管理规范化的用户数据。我们可以将createEntityAdapter集成到我们的extendedApiSlice中,并实际上使用createEntityAdapter在缓存数据之前转换数据。我们将取消注释最初使用的usersAdapter行,并再次使用它的更新函数和选择器。

features/users/usersSlice.js
import { apiSlice } from '../api/apiSlice'

const usersAdapter = createEntityAdapter()

const initialState = usersAdapter.getInitialState()

export const extendedApiSlice = apiSlice.injectEndpoints({
endpoints: builder => ({
getUsers: builder.query({
query: () => '/users',
transformResponse: responseData => {
return usersAdapter.setAll(initialState, responseData)
}
})
})
})

export const { useGetUsersQuery } = extendedApiSlice

// 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 = extendedApiSlice.endpoints.getUsers.select()

const selectUsersData = createSelector(
selectUsersResult,
usersResult => usersResult.data
)

export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors(state => selectUsersData(state) ?? initialState)

我们在getUsers端点添加了一个transformResponse选项。它接收整个响应数据主体作为其参数,并应返回要缓存的实际数据。通过调用usersAdapter.setAll(initialState, responseData),它将返回包含所有接收到的项目的标准{ids: [], entities: {}}规范化数据结构。

adapter.getSelectors() 函数需要一个“输入选择器”,以便它知道在哪里找到规范化后的数据。在本例中,数据嵌套在 RTK Query 缓存 reducer 内部,因此我们从缓存状态中选择正确的字段。

规范化缓存与文档缓存

值得花点时间进一步讨论我们刚才所做的事情。

你可能听说过与其他数据获取库(如 Apollo)相关的“规范化缓存”一词。重要的是要理解 **RTK Query 使用“文档缓存”方法,而不是“规范化缓存”**。

一个完全规范化的缓存试图根据项目类型和 ID 对所有查询中的类似项目进行去重。例如,假设我们有一个带有 getTodosgetTodo 端点的 API 切片,并且我们的组件执行以下查询

  • getTodos()
  • getTodos({filter: 'odd'})
  • getTodo({id: 1})

每个查询结果都将包含一个看起来像 {id: 1} 的 Todo 对象。

在一个完全规范化的去重缓存中,只会存储此 Todo 对象的一个副本。但是,**RTK Query 会将每个查询结果独立地保存在缓存中**。因此,这将导致在 Redux 存储中缓存此 Todo 的三个独立副本。但是,如果所有端点始终提供相同的标签(例如 {type: 'Todo', id: 1}),那么使该标签失效将强制所有匹配的端点重新获取其数据以保持一致性。

RTK Query 故意 **不实现一个会对多个请求中的相同项目进行去重的缓存**。这有几个原因

  • 一个完全规范化的跨查询共享缓存是一个 *很难* 解决的问题
  • 我们现在没有时间、资源或兴趣去尝试解决这个问题
  • 在许多情况下,当数据失效时简单地重新获取数据效果很好,而且更容易理解
  • 至少,RTKQ 可以帮助解决“获取一些数据”的一般用例,这对很多人来说是一个很大的痛点

相比之下,我们只是规范了 getUsers 端点的响应数据,因为它被存储为一个 {[id]: value} 查找表。但是,**这与“规范化缓存” *不同* - 我们只是转换了 *此响应的存储方式*,而不是对端点或请求中的结果进行去重。

从结果中选择值

读取旧 postsSlice 的最后一个组件是 <UserPage>,它根据当前用户过滤帖子列表。我们已经看到,我们可以使用 useGetPostsQuery() 获取整个帖子列表,然后在组件中对其进行转换,例如在 useMemo 中进行排序。查询钩子还允许我们通过提供 selectFromResult 选项来选择缓存状态的片段,并且仅在选定的片段发生变化时重新渲染。

我们可以使用 selectFromResult<UserPage> 仅从缓存中读取过滤后的帖子列表。但是,为了让 selectFromResult 避免不必要的重新渲染,我们需要确保我们提取的任何数据都被正确地记忆化。为此,我们应该创建一个新的选择器实例,<UsersPage> 组件可以在每次渲染时重复使用,这样选择器就可以根据其输入记忆化结果。

features/users/UsersPage.js
import { createSelector } from '@reduxjs/toolkit'

import { selectUserById } from '../users/usersSlice'
import { useGetPostsQuery } from '../api/apiSlice'

export const UserPage = ({ match }) => {
const { userId } = match.params

const user = useSelector(state => selectUserById(state, userId))

const selectPostsForUser = useMemo(() => {
const emptyArray = []
// Return a unique selector instance for this page so that
// the filtered results are correctly memoized
return createSelector(
res => res.data,
(res, userId) => userId,
(data, userId) => data?.filter(post => post.user === userId) ?? emptyArray
)
}, [])

// Use the same posts query, but extract only part of its data
const { postsForUser } = useGetPostsQuery(undefined, {
selectFromResult: result => ({
// We can optionally include the other metadata fields from the result here
...result,
// Include a field called `postsForUser` in the hook result object,
// which will be a filtered list of posts
postsForUser: selectPostsForUser(result, userId)
})
})

// omit rendering logic
}

这里我们创建的记忆化选择器函数有一个关键区别。通常,选择器期望整个 Redux state 作为其第一个参数,并从 state 中提取或派生一个值。但是,在本例中,我们只处理缓存中保留的“结果”值。结果对象内部有一个 data 字段,其中包含我们需要的实际值,以及一些请求元数据字段。

我们的 selectFromResult 回调接收包含原始请求元数据和来自服务器的 dataresult 对象,并应返回一些提取或派生的值。由于查询钩子会将额外的 refetch 方法添加到这里返回的任何内容中,因此最好始终从 selectFromResult 返回一个包含您需要的字段的对象。

由于 result 被保存在 Redux 存储中,我们不能对其进行变异 - 我们需要返回一个新对象。查询钩子将对返回的对象进行“浅层”比较,并且仅在其中一个字段发生变化时才重新渲染组件。我们可以通过仅返回此组件所需的特定字段来优化重新渲染 - 如果我们不需要其余的元数据标志,我们可以完全省略它们。如果您需要它们,您可以将原始 result 值展开以将其包含在输出中。

在本例中,我们将字段命名为 postsForUser,我们可以从钩子结果中解构该新字段。通过每次调用 selectPostsForUser(result, userId),它将记忆化过滤后的数组,并且仅在获取的数据或用户 ID 发生变化时重新计算。

比较转换方法

我们已经看到了三种不同的方法来管理转换响应

  • 将原始响应保存在缓存中,在组件中读取完整结果并推导出值
  • 将原始响应保存在缓存中,使用 selectFromResult 读取推导出的结果
  • 在将响应存储到缓存之前转换响应

每种方法在不同的情况下都很有用。以下是一些关于何时应该考虑使用它们的建议

  • transformResponse:端点的所有使用者都需要特定的格式,例如将响应规范化以通过 ID 启用更快的查找
  • selectFromResult:端点的某些使用者只需要部分数据,例如过滤后的列表
  • 每个组件 / useMemo:当只有某些特定组件需要转换缓存数据时

高级缓存更新

我们已经完成了更新帖子和用户数据,所以剩下的就是处理反应和通知。将这些切换到使用 RTK Query 将使我们有机会尝试一些用于处理 RTK Query 缓存数据的先进技术,并允许我们为用户提供更好的体验。

持久化反应

最初,我们只在客户端跟踪反应,并没有将它们持久化到服务器。让我们添加一个新的 addReaction 变异,并使用它在用户每次点击反应按钮时更新服务器上的相应 Post

features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints
addReaction: builder.mutation({
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> 以使用此变异。

features/posts/ReactionButtons.js
import React from 'react'

import { useAddReactionMutation } from '../api/apiSlice'

const reactionEmoji = {
thumbsUp: '👍',
hooray: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}

export const ReactionButtons = ({ post }) => {
const [addReaction] = useAddReactionMutation()

const reactionButtons = Object.entries(reactionEmoji).map(
([reactionName, emoji]) => {
return (
<button
key={reactionName}
type="button"
className="muted-button reaction-button"
onClick={() => {
addReaction({ postId: post.id, reaction: reactionName })
}}
>
{emoji} {post.reactions[reactionName]}
</button>
)
}
)

return <div>{reactionButtons}</div>
}

让我们看看它的实际效果!转到主 <PostsList>,并点击其中一个反应,看看会发生什么。

PostsList disabled while fetching

哦,不。整个 <PostsList> 组件变成了灰色,因为我们只是重新获取了整个帖子列表以响应该帖子被更新。这是故意更明显,因为我们的模拟 API 服务器设置为在响应之前延迟 2 秒,但即使响应更快,这仍然不是良好的用户体验。

实现乐观更新

对于像添加反应这样的小更新,我们可能不需要重新获取整个帖子列表。相反,我们可以尝试只更新客户端上已经缓存的数据,使其与我们预期在服务器上发生的情况相匹配。此外,如果我们立即更新缓存,用户在点击按钮时会立即获得反馈,而不必等待响应返回。这种立即更新客户端状态的方法被称为“乐观更新”,它是 Web 应用程序中常见的模式。

RTK Query 允许您通过修改基于“请求生命周期”处理程序的客户端缓存来实现乐观更新。端点可以定义一个 onQueryStarted 函数,该函数将在请求开始时被调用,我们可以在该处理程序中运行额外的逻辑。

features/api/apiSlice.js
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/fakeApi' }),
tagTypes: ['Post'],
endpoints: builder => ({
// omit other endpoints

addReaction: builder.mutation({
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 }
}),
async onQueryStarted({ postId, reaction }, { dispatch, queryFulfilled }) {
// `updateQueryData` requires the endpoint name and cache key arguments,
// so it knows which piece of cache state to update
const patchResult = 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]++
}
})
)
try {
await queryFulfilled
} catch {
patchResult.undo()
}
}
})
})
})

onQueryStarted 处理程序接收两个参数。第一个是请求开始时传递的缓存键 arg。第二个是一个包含与 createAsyncThunk 中的 thunkApi 相同字段的对象 ({dispatch, getState, extra, requestId}),但还包含一个名为 queryFulfilledPromise。这个 Promise 将在请求返回时解析,并根据请求的结果成功或失败。

API 切片对象包含一个 updateQueryData 实用函数,它允许我们更新缓存的值。它接受三个参数:要更新的端点名称、用于识别特定缓存数据的相同缓存键值,以及更新缓存数据的回调函数。updateQueryData 使用 Immer,因此您可以像在 createSlice 中一样“修改”草稿缓存数据

我们可以通过在 getPosts 缓存中找到特定的 Post 条目,并“修改”它来增加反应计数器来实现乐观更新。

updateQueryData 生成一个包含我们所做更改的补丁差异的操作对象。当我们分派该操作时,返回值是一个 patchResult 对象。如果我们调用 patchResult.undo(),它会自动分派一个操作来撤消补丁差异更改。

默认情况下,我们预期请求会成功。如果请求失败,我们可以await queryFulfilled,捕获错误,并撤销补丁更改以恢复乐观更新。

对于这种情况,我们还删除了刚刚添加的invalidatesTags行,因为我们希望在点击反应按钮时重新获取帖子。

现在,如果我们快速点击反应按钮多次,我们应该看到 UI 中的数字每次都会增加。如果我们查看 Network 选项卡,我们也会看到每个单独的请求都发送到服务器。

流式缓存更新

我们的最终功能是通知选项卡。当我们在第 6 部分中最初构建此功能时,我们说“在真实的应用程序中,服务器会在每次发生事件时将更新推送到我们的客户端”。我们最初通过添加一个“刷新通知”按钮来模拟该功能,并让它对更多通知条目进行 HTTP GET 请求。

应用程序通常会发出一个初始请求从服务器获取数据,然后打开一个 Websocket 连接以接收随时间的额外更新。RTK Query 提供了一个onCacheEntryAdded 端点生命周期处理程序,它允许我们实现对缓存数据的“流式更新”。我们将使用该功能来实现一种更现实的通知管理方法。

我们的src/api/server.js 文件已经配置了一个模拟 Websocket 服务器,类似于模拟 HTTP 服务器。我们将编写一个新的getNotifications 端点,它获取初始通知列表,然后建立 Websocket 连接以监听未来的更新。我们仍然需要手动告诉模拟服务器何时发送新通知,因此我们将继续通过一个我们点击的按钮来模拟它,以强制更新。

我们将像对getUsers一样将getNotifications 端点注入到notificationsSlice 中,只是为了表明这是可能的。

features/notifications/notificationsSlice.js
import { forceGenerateNotifications } from '../../api/server'
import { apiSlice } from '../api/apiSlice'

export const extendedApi = apiSlice.injectEndpoints({
endpoints: builder => ({
getNotifications: builder.query({
query: () => '/notifications',
async onCacheEntryAdded(
arg,
{ updateCachedData, cacheDataLoaded, cacheEntryRemoved }
) {
// 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 cacheDataLoaded

// when data is received from the socket connection to the server,
// update our query result with the received message
const listener = event => {
const message = JSON.parse(event.data)
switch (message.type) {
case 'notifications': {
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))
})
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 cacheEntryRemoved
// perform cleanup steps once the `cacheEntryRemoved` promise resolves
ws.close()
}
})
})
})

export const { useGetNotificationsQuery } = extendedApi

const emptyNotifications = []

export const selectNotificationsResult =
extendedApi.endpoints.getNotifications.select()

const selectNotificationsData = createSelector(
selectNotificationsResult,
notificationsResult => notificationsResult.data ?? emptyNotifications
)

export const fetchNotificationsWebsocket = () => (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)
}

// omit existing slice code

onQueryStarted 类似,onCacheEntryAdded 生命周期处理程序接收 arg 缓存键作为其第一个参数,并接收包含 thunkApi 值的选项对象作为第二个参数。选项对象还包含一个 updateCachedData 实用函数,以及两个生命周期 Promise - cacheDataLoadedcacheEntryRemovedcacheDataLoaded 在将此订阅的初始数据添加到存储时解析。当为该端点 + 缓存键添加第一个订阅时,就会发生这种情况。只要 1+ 个订阅者仍然处于活动状态,缓存条目就会保持活动状态。当订阅者数量变为 0 且缓存生命周期计时器过期时,缓存条目将被删除,并且 cacheEntryRemoved 将解析。通常,使用模式是

  • 立即 await cacheDataLoaded
  • 创建类似于 Websocket 的服务器端数据订阅
  • 当收到更新时,使用 updateCachedData 根据更新“修改”缓存的值
  • 最后 await cacheEntryRemoved
  • 之后清理订阅

我们的模拟 Websocket 服务器文件公开了一个 forceGenerateNotifications 方法来模拟将数据推送到客户端。这取决于知道最新的通知时间戳,因此我们添加了一个 thunk,我们可以调度它从缓存状态读取最新的时间戳,并告诉模拟服务器生成更新的通知。

onCacheEntryAdded 内部,我们创建了一个连接到 localhost 的真实 Websocket 连接。在实际应用中,这可以是您需要接收持续更新的任何类型的外部订阅或轮询连接。每当模拟服务器向我们发送更新时,我们将所有接收到的通知推送到缓存并重新排序。

当缓存条目被删除时,我们清理 Websocket 订阅。在这个应用程序中,通知缓存条目永远不会被删除,因为我们从未取消订阅数据,但了解清理在实际应用程序中的工作方式很重要。

跟踪客户端状态

我们需要进行最后一组更新。我们的<Navbar>组件需要启动通知的获取,而<NotificationsList>需要显示具有正确已读/未读状态的通知条目。但是,我们之前在收到条目时在客户端的notificationsSlice reducer 中添加了已读/未读字段,现在通知条目被保存在 RTK Query 缓存中。

我们可以重写notificationsSlice,使其监听任何收到的通知,并在客户端为每个通知条目跟踪一些额外的状态。

当收到新的通知条目时,有两种情况:当我们通过 HTTP 获取初始列表时,以及当我们收到通过 Websocket 连接推送的更新时。理想情况下,我们希望在响应这两种情况时使用相同的逻辑。我们可以使用 RTK 的"匹配实用程序"来编写一个在响应多个操作类型时运行的 case reducer。

让我们看看在添加此逻辑后notificationsSlice 的样子。

features/notifications/notificationsSlice.js
import {
createAction,
createSlice,
createEntityAdapter,
createSelector,
isAnyOf
} from '@reduxjs/toolkit'

import { forceGenerateNotifications } from '../../api/server'
import { apiSlice } from '../api/apiSlice'

const notificationsReceived = createAction(
'notifications/notificationsReceived'
)

export const extendedApi = apiSlice.injectEndpoints({
endpoints: builder => ({
getNotifications: builder.query({
query: () => '/notifications',
async onCacheEntryAdded(
arg,
{ updateCachedData, cacheDataLoaded, cacheEntryRemoved, dispatch }
) {
// 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 cacheDataLoaded

// when data is received from the socket connection to the server,
// update our query result with the received message
const listener = event => {
const message = JSON.parse(event.data)
switch (message.type) {
case 'notifications': {
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
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 cacheEntryRemoved
// perform cleanup steps once the `cacheEntryRemoved` promise resolves
ws.close()
}
})
})
})

export const { useGetNotificationsQuery } = extendedApi

// omit selectors and websocket thunk

const notificationsAdapter = createEntityAdapter()

const matchNotificationsReceived = isAnyOf(
notificationsReceived,
extendedApi.endpoints.getNotifications.matchFulfilled
)

const notificationsSlice = createSlice({
name: 'notifications',
initialState: notificationsAdapter.getInitialState(),
reducers: {
allNotificationsRead(state, action) {
Object.values(state.entities).forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addMatcher(matchNotificationsReceived, (state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsMetadata = action.payload.map(notification => ({
id: notification.id,
read: false,
isNew: true
}))

Object.values(state.entities).forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})

notificationsAdapter.upsertMany(state, notificationsMetadata)
})
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

export const {
selectAll: selectNotificationsMetadata,
selectEntities: selectMetadataEntities
} = notificationsAdapter.getSelectors(state => state.notifications)

有很多事情要做,但让我们一次分解一个更改。

目前,notificationsSlice reducer 没有很好的方法来知道我们何时通过 Websocket 收到更新的通知列表。因此,我们将导入createAction,专门为“收到一些通知”的情况定义一个新的操作类型,并在更新缓存状态后分派该操作。

我们希望对“已完成的getNotifications操作“从 Websocket 收到的”操作运行相同的“添加已读/新元数据”逻辑。我们可以通过调用isAnyOf() 并传入每个操作创建者来创建一个新的“匹配器”函数。matchNotificationsReceived 匹配器函数将在当前操作与这些类型中的任何一个匹配时返回 true。

之前,我们有一个针对所有通知的规范化查找表,UI 将它们选择为一个排序的数组。我们将重新利用此切片来存储描述已读/未读状态的“元数据”对象。

我们可以在extraReducers 中使用builder.addMatcher() API 添加一个 case reducer,该 reducer 在我们匹配这两个操作类型中的任何一个时运行。在其中,我们添加一个新的“已读/新”元数据条目,该条目对应于每个通知的 ID,并将该条目存储在notificationsSlice 中。

最后,我们需要更改我们从该切片中导出的选择器。我们不会将selectAll 导出为selectAllNotifications,而是将其导出为selectNotificationsMetadata。它仍然返回规范化状态中的值的数组,但我们更改了名称,因为项目本身已更改。我们还将导出selectEntities 选择器,它将查找表对象本身返回为selectMetadataEntities。这在我们尝试在 UI 中使用此数据时将很有用。

有了这些更改,我们可以更新我们的 UI 组件以获取和显示通知。

app/Navbar.js
import React from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Link } from 'react-router-dom'

import {
fetchNotificationsWebsocket,
selectNotificationsMetadata,
useGetNotificationsQuery
} from '../features/notifications/notificationsSlice'

export const Navbar = () => {
const dispatch = useDispatch()

// Trigger initial fetch of notifications and keep the websocket open to receive updates
useGetNotificationsQuery()

const notificationsMetadata = useSelector(selectNotificationsMetadata)
const numUnreadNotifications = notificationsMetadata.filter(
n => !n.read
).length

const fetchNewNotifications = () => {
dispatch(fetchNotificationsWebsocket())
}

let unreadNotificationsBadge

if (numUnreadNotifications > 0) {
unreadNotificationsBadge = (
<span className="badge">{numUnreadNotifications}</span>
)
}

// omit rendering logic
}

<NavBar> 中,我们使用 useGetNotificationsQuery() 触发初始通知获取,并切换到从 state.notificationsSlice 读取元数据对象。现在,点击“刷新”按钮会触发模拟 Websocket 服务器推送另一组通知。

我们的 <NotificationsList> 同样切换到读取缓存数据和元数据。

features/notifications/NotificationsList.js
import {
useGetNotificationsQuery,
allNotificationsRead,
selectMetadataEntities,
} from './notificationsSlice'

export const NotificationsList = () => {
const dispatch = useDispatch()
const { data: notifications = [] } = useGetNotificationsQuery()
const notificationsMetadata = useSelector(selectMetadataEntities)
const users = useSelector(selectAllUsers)

useLayoutEffect(() => {
dispatch(allNotificationsRead())
})

const renderedNotifications = notifications.map((notification) => {
const date = parseISO(notification.date)
const timeAgo = formatDistanceToNow(date)
const user = users.find((user) => user.id === notification.user) || {
name: 'Unknown User',
}

const metadata = notificationsMetadata[notification.id]

const notificationClassname = classnames('notification', {
new: metadata.isNew,
})

// omit rendering logic
}

我们从缓存中读取通知列表,从 notificationsSlice 读取新的元数据条目,并继续以与以前相同的方式显示它们。

作为最后一步,我们可以在此处进行一些额外的清理 - postsSlice 现在不再使用,因此可以完全删除。

这样,我们就完成了将应用程序转换为使用 RTK Query!所有数据获取都已切换到使用 RTKQ,并且我们通过添加乐观更新和流式更新来改善了用户体验。

您学到了什么

正如我们所见,RTK Query 包含一些强大的选项来控制我们如何管理缓存数据。虽然您可能不会立即需要所有这些选项,但它们提供了灵活性,并提供了关键功能来帮助实现特定的应用程序行为。

让我们最后看一下整个应用程序的运行情况

总结
  • 可以使用特定的缓存标签来实现更细粒度的缓存失效
    • 缓存标签可以是 'Post'{type: 'Post', id}
    • 端点可以根据结果和参数缓存键提供或使缓存标签失效
  • RTK Query 的 API 与 UI 无关,可以在 React 之外使用
    • 端点对象包括用于启动请求、生成结果选择器和匹配请求操作对象的函数
  • 可以根据需要以不同的方式转换响应
    • 端点可以定义 transformResponse 回调函数,在缓存之前修改数据
    • 可以为钩子提供 selectFromResult 选项来提取/转换数据
    • 组件可以使用 useMemo 读取整个值并进行转换
  • RTK Query 提供了高级选项来操作缓存数据,以改善用户体验。
    • onQueryStarted 生命周期可用于在请求返回之前立即更新缓存,从而实现乐观更新。
    • onCacheEntryAdded 生命周期可用于基于服务器推送连接随时间更新缓存,从而实现流式更新。

下一步?

恭喜,您已完成 Redux Essentials 教程! 您现在应该对 Redux Toolkit 和 React-Redux 有了深入的了解,包括如何编写和组织 Redux 逻辑、Redux 数据流以及与 React 的使用,以及如何使用 configureStorecreateSlice 等 API。您还应该了解 RTK Query 如何简化获取和使用缓存数据的过程。

第 6 部分的 "下一步?" 部分 提供了指向应用程序创意、教程和文档的更多资源的链接。

有关使用 RTK Query 的更多详细信息,请参阅 RTK Query 使用指南文档API 参考

如果您需要有关 Redux 的帮助,请加入 Discord 上 Reactiflux 服务器的 #redux 频道

感谢您阅读本教程,我们希望您享受使用 Redux 构建应用程序的乐趣!