代码下载
了解了如何设置和使用 RTK Query API 来处理应用中的数据获取和缓存。已经向 Redux 存储添加了 “API 切片”,定义了 “query” 端点来获取帖子数据,定义了 “mutation” 端点来添加新帖子。接下来继续迁移示例应用以将 RTK Query 用于其他数据类型,并了解如何使用一些高级功能来简化代码库并改善用户体验。
编辑帖子
已经添加了一个突变端点来将新的 Post 条目保存到服务器,并在 <AddPostForm> 中使用它。接下来更新 <EditPostForm>,以便编辑现有帖子。
更新编辑帖子表单
与添加帖子一样,第一步是在 API 切片中定义新的可变端点。这看起来很像添加帖子的突变,但端点需要在 URL 中包含帖子 ID 并使用 HTTP PATCH 请求来指示它正在更新某些字段。
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({baseUrl: '/fakeApi'}),
tagTypes: ['Posts'],
endpoints: (builder) => ({
getPosts: builder.query({
query: () => '/posts',
providesTags: ['Posts']
}),
getPost: builder.query({
query: (postId) => `/posts/${postId}`
}),
addNewPost: builder.mutation({
query: (initialPost) => ({
url: '/posts',
method: 'POST',
body: initialPost
}),
invalidatesTags: ['Posts']
}),
editPost: builder.mutation({
query: post => ({
url: `/posts/${post.id}`,
method: 'PATCH',
body: post
})
})
})
})
export const { useGetPostsQuery, useGetPostQuery, useAddNewPostMutation, useEditPostMutation } = apiSlice
添加完毕后,新建 <QueryEditPostForm>。它需要从存储中读取原始的 Post 条目,使用它来初始化组件状态以编辑字段,然后将更新的更改发送到服务器。
可以使用在 <AsyncSinglePostPage> 中使用的相同 useGetPostQuery 钩子从存储中的缓存中读取 Post 条目,并且使用新的 useEditPostMutation 钩子来处理保存更改。如果需要还可以添加加载控件并在更新过程中禁止表单输入。
import { useNavigate, useParams } from "react-router-dom";
import { useGetPostQuery, useEditPostMutation } from "../api/apiSlice";
export default function QueryEditPostForm() {
const {postId} = useParams()
const { data: post } = useGetPostQuery(postId)
const [ updatePost, { isLoading } ] = useEditPostMutation()
const navigate = useNavigate()
if (!post) {
return <section>
<h2>Post not found!</h2>
</section>
}
const onSavePostClicked = async (e) => {
e.preventDefault()
const {elements} = e.target
const title = elements.postTitle.value
const content = elements.postContent.value
if (title && content) {
await updatePost({id: post.id, title, content})
navigate(`/async/posts/${post.id}`)
}
}
return <section>
<h2>Edit Post</h2>
<form onSubmit={onSavePostClicked}>
<label htmlFor="postTitle">Post Title: </label>
<input id="postTitle" name="postTitle" type="text" defaultValue={post.title} required disabled={isLoading}></input>
<br></br>
<label htmlFor="postContent">Post Content: </label>
<textarea id="postContent" name="postContent" defaultValue={post.content} required disabled={isLoading}></textarea>
<button>Save Post</button>
</form>
</section>
}
添加路由,在 data.js 中增加如下数据:
{
path: '/async/editPost/:postId',
title: '异步编辑帖子示例',
element: (<AsyncProtectedRoute>
<QueryEditPostForm></QueryEditPostForm>
</AsyncProtectedRoute>),
showHome: false
}
修改 AsyncSinglePostPage 组件中编辑帖子的路由路径:
……
{canEdit && <Link to={`/async/editPost/${postId}`}>Edit Post</Link>}
……
缓存数据订阅的生命周期
打开浏览器的 DevTools,转到“网络”选项卡,刷新页面,清除“网络”选项卡,然后登录。当获取初始数据时,会看到对 /posts 的 GET 请求。单击 “查看帖子” 按钮时,会看到对 /posts/:postId 的第二个请求,该请求返回单个帖子条目。现在单击单个帖子页面内的 “编辑帖子”。UI 切换为显示 <QueryEditPostForm>,但这次单个帖子没有网络请求。为什么?
RTK Query 允许多个组件订阅相同的数据,并将确保每个唯一的数据集仅获取一次。在内部,RTK Query 为每个 端点 + 缓存 键组合保留活动 “subscriptions” 的引用计数。如果组件 A 调用 useGetPostQuery(42),则会获取该数据。如果组件 B 随后挂载并调用 useGetPostQuery(42),则它会请求相同的数据。已经有一个现有的缓存条目,因此不需要请求。两个钩子用法将返回完全相同的结果,包括获取的 data 和加载状态标志。
当活动订阅数量降至 0 时,RTK Query 将启动内部计时器。如果在添加任何新的数据订阅之前计时器到期,RTK Query 将自动从缓存中删除该数据,因为应用不再需要该数据。但是,如果在计时器到期之前添加了新的订阅,则计时器将被取消,并且将使用已缓存的数据,而无需重新获取。
<AsyncSinglePostPage> 并通过 ID 请求该个人 Post。当点击 “编辑帖子” 时,<AsyncSinglePostPage> 组件被路由卸载,并且活动订阅也因卸载而被删除。RTK Query 立即启动 “删除此帖子数据” 计时器。但是,<QueryEditPostForm> 组件立即使用相同的缓存键订阅了相同的 Post 数据。因此,RTK Query 取消了计时器并继续使用相同的缓存数据,而不是从服务器获取数据。
默认情况下,未使用的数据会在 60 秒后从缓存中删除,但这可以在根 API 切片定义中进行配置,也可以使用 keepUnusedDataFor 标志在各个端点定义中进行覆盖,该标志指定缓存生命周期(以秒为单位)。
使特定项无效
<QueryEditPostForm> 组件现在可以将编辑后的帖子保存到服务器,但有一个问题。如果在编辑时单击 “保存帖子”,返回到 <AsyncSinglePostPage>,但它仍然显示未经编辑的旧数据。<AsyncSinglePostPage> 仍在使用之前获取的缓存 Post。就此而言,如果返回主页并查看 <AsyncPostsList>,它也会显示旧数据。需要一种方法来强制重新获取单个 Post 条目和整个帖子列表。
之前了解了如何使用 “tags” 使部分缓存数据无效。声明 getPosts 查询端点提供 ‘Post’ 标记,而 addNewPost 突变端点使相同的 ‘Post’ 标记无效。这样,每次添加新帖子时都会强制 RTK 查询从 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: ['Posts'],
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 中打开“网络”选项卡。这次当保存编辑后的帖子时,我们应该看到两个请求连续发生:
- 来自 editPost mutation 的
PATCH /posts/:postId GET /posts/:postId作为 getPost 查询重新获取
然后单击返回主 “帖子” 选项卡,还应该看到:
GET /posts作为 getPosts 查询重新获取
因为使用标签提供端点之间的关系,所以 RTK 查询知道当进行编辑并且具有该 ID 的特定标签无效时,它需要重新获取单个帖子和帖子列表 - 无需进一步更改!同时,当我们编辑帖子时,getPosts 数据的缓存删除计时器到期,因此它已从缓存中删除。当我们再次打开 <AsyncPostsList> 组件时,RTK Query 发现它的缓存中没有数据并重新获取它。
这里有一个警告。通过在 getPosts 中指定一个普通的 ‘Post’ 标签并在 addNewPost 中使其无效,实际上最终也会强制重新获取所有单独的帖子。如果我们确实只想重新获取 getPosts 端点的帖子列表,可以包含具有任意 ID 的附加标签(例如 {type: 'Post', id: 'LIST'}),然后使该标签无效。RTK 查询文档有 一个表格描述如果某些通用/特定标签组合无效将会发生什么。
信息:RTK 查询还有许多其他选项用于控制何时以及如何重新获取数据,包括 “conditional fetching”、“lazy queries” 和 “prefetching”,并且可以通过多种方式自定义查询定义。有关使用这些功能的更多详细信息,请参阅 RTK 查询使用指南文档:
- RTK 查询:Automated Re-Fetching
- RTK 查询:Conditional Fetching
- RTK 查询:Prefetching
- RTK 查询:Customizing Queries
- RTK 查询:useLazyQuery
更新 Toast 显示
当从分派 thunk 添加帖子切换到使用 RTK 查询 mutation 时,意外破坏了 “已添加新帖子” toast 消息行为,因为 addNewPost.fulfilled 操作不再被分派。
幸运的是,这很容易修复。RTK Query 实际上在内部使用 createAsyncThunk,已经看到它在发出请求时分派 Redux 操作。可以更新 toast 监听器,以监视 RTKQ 的内部操作是否被分派,并在发生这种情况时显示 toast 消息。
createApi 会自动为每个端点在内部生成 thunk。它还会自动生成 RTK “matcher” 函数,它接受一个操作对象,如果操作符合某些条件,则返回 true。这些适配器可用于任何需要检查操作是否符合给定条件的地方,例如 startAppListening 内部。它们还充当 TypeScript 类型保护,缩小 action 对象的 TS 类型,以便可以安全地访问其字段。
目前,在 asyncPostsSlice.js 中 toast 监听器正在监视具有 actionCreator: addNewPost.fulfilled 的单个特定操作类型。使用 matcher: apiSlice.endpoints.addNewPost.matchFulfilled 更新它以监听添加的帖子:
import { apiSlice } from "../../features/api/apiSlice";
export const addPostsListeners = (startListening) => {
startListening({
matcher: apiSlice.endpoints.addNewPost.matchFulfilled,
effect: async (action, listenerApi) => {
const {toast} = await import('react-tiny-toast')
console.log('toast: ', toast);
const toastId = toast.show('New post added!', {
variant: 'success',
position: 'bottom-right',
pause: true
})
await listenerApi.delay(5000)
toast.remove(toastId)
}
})
}
现在添加帖子时,提示框应该会再次正确显示。
管理用户数据
已经完成将帖子数据管理转换为使用 RTK 查询。接下来将转换用户列表。
由于已经了解了如何使用 RTK 查询钩子来获取和读取数据,因此这里将尝试不同的方法。与 Redux Toolkit 的其余部分一样,RTK Query 的核心逻辑与 UI 无关,可以与任何 UI 层一起使用,而不仅仅是 React。
通常应该使用 createApi 生成的 React 钩子,因为它们为你做了很多工作。但是,为了便于说明,将仅使用 RTK 查询核心 API 来处理用户数据,以便了解如何使用它。
手动获取用户
目前正在 asyncUsersSlice.js 中定义 fetchUsers 异步 thunk,并在 index.js 中手动调度该 thunk,以便用户列表尽快可用。可以使用 RTK 查询执行相同的过程。
首先在 apiSlice.js 中定义 getUsers 查询端点,类似于现有的端点。导出 useGetUsersQuery 钩子只是为了保持一致性,但现在并不打算使用它。
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({baseUrl: '/fakeApi'}),
tagTypes: ['Posts'],
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users'
})
……
})
})
export const { useGetUsersQuery…… } = apiSlice
如果检查 API 切片对象,它会包含一个 endpoints 字段,其中针对定义的每个端点都有一个端点对象。

每个端点对象包含:
- 从根 API slice对象导出的相同主
query/mutation钩子,但命名为 useQuery 或 useMutation - 对于查询端点,一组额外的查询钩子用于“lazy queries”或部分订阅等场景
- 一组 “matcher” 实用工具,用于检查此端点的请求所调度的
pending/fulfilled/rejected操作 - 触发对此端点请求的 initiate thunk
- 创建 memoized 选择器的 select 函数,可以检索该端点的缓存结果数据 + 状态条目
如果想获取 React 之外的用户列表,可以在 index 文件中调度 getUsers.initiate() thunk:
import { apiSlice } from "./features/api/apiSlice";
async function mockStart() {
await worker.start({ onUnhandledRequest: 'bypass' })
store.dispatch(apiSlice.endpoints.getUsers.initiate())
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<React.Suspense>
<Router><App /></Router>
</React.Suspense>
</Provider>
);
}
mockStart()
此调度在查询钩子内部自动发生,但可以通过调度 initiate thunk 手动启动它(如果需要)。
请注意,没有为 initiate() 提供参数。这是因为 getUsers 端点不需要特定的查询参数。从概念上讲,这等同于说“此缓存条目具有 undefined 的查询参数”。如果确实需要参数,会将它们传递给 thunk,例如 dispatch(apiSlice.endpoints.getPokemon.initiate('pikachu'))。
手动调度 thunk 以开始预取我们应用的设置函数中的数据。实际上,可能希望在 React-Router 的 “数据加载器” 中进行预取以在渲染组件之前启动请求。(有关React-Router加载器的一些想法,请参阅 RTK repo 线程的讨论。)
提醒:手动发送 RTKQ 请求 thunk 将创建一个订阅,但这取决于稍后取消订阅该数据 - 否则数据将永久保留在缓存中。这里总是需要用户数据,因此可以跳过取消订阅。
选择用户数据
目前有 selectAllUsers 和 selectUserById 这样的选择器,它们由 createEntityAdapter 用户适配器生成,并从 state.users 读取。如果重新加载页面,所有与用户相关的显示都会被破坏,因为 state.users 切片没有数据。现在正在为 RTK 查询的缓存获取数据,应该将这些选择器替换为从缓存中读取的等效选择器。
每次调用 API slice 端点中的 endpoint.select() 函数时,都会创建一个新的 memoized 选择器函数。select() 将缓存键作为其参数,并且该键必须与作为参数传递给查询钩子 或 initiate() thunk 的缓存键相同。生成的选择器使用该缓存键来确切地知道它应该从存储中的缓存状态返回哪个缓存结果。
这里的 getUsers 端点不需要任何参数 - 总是获取整个用户列表。因此可以创建一个没有参数的缓存选择器(这与传递 undefined 的缓存键相同)。
可以更新 asyncUsersSlice.js,使其选择器基于 RTKQ 查询缓存,而不是实际的 usersSlice 调用:
import { apiSlice } from "../api/apiSlice";
const selectUsersResult = apiSlice.endpoints.getUsers.select()
export const selectAllUsers = createSelector(
selectUsersResult,
(usersResult) => Array.isArray(usersResult.data) ? usersResult.data : []
)
export const selectUserById = createSelector(
selectAllUsers,
(state, userId) => userId,
(users, userId) => users.find((user) => user.id === userId)
)
首先创建一个知道如何检索正确缓存条目的特定 selectUsersResult 选择器实例。
一旦有了初始 selectUsersResult 选择器,就可以用从缓存结果中返回用户数组的选择器替换现有的 selectAllUsers 选择器。由于可能还没有有效的结果,返回空数组。还将用从该数组中找到正确用户替换 selectUserById。现在注释掉 usersAdapter 中的那些选择器。
组件已经导入了 selectAllUsers、selectUserById 和 selectCurrentUser,所以这个改变应该可以正常工作!尝试刷新页面并单击帖子列表和单个帖子视图。正确的用户名应出现在每个显示的帖子中以及 AsyncLoginPage.js 的下拉列表中。
注意: 这是使用选择器使代码更易于维护的一个很好的例子!组件已经调用了这些选择器,因此不关心数据是来自现有的 usersSlice 状态,还是来自 RTK 查询缓存条目,只要选择器返回预期的数据即可。能够更改选择器实现,而根本不需要更新 UI 组件。
由于 asyncUsersSlice 状态不再被使用,可以继续从此文件中删除 const usersSlice = createSlice() 调用和 fetchUsers thunk,并从的存储设置中删除 asyncUsers: asyncUsersReducer。但仍然有一些引用 postsSlice 的代码,所以还不能完全删除它 - 很快就会讨论这个问题。
拆分和注入端点
RTK 查询通常每个应用都有一个 “API Slice”,到目前为止,已经直接在 apiSlice.js 中定义了所有端点。但是,大型应用通常会将 “code-split” 功能分成单独的包,然后在第一次使用该功能时根据需要对它们进行 “懒加载”。如果想要对某些端点定义进行代码分割,或者将它们移动到另一个文件中以防止 API Slice 文件变得太大,会发生什么?
RTK 查询支持使用 apiSlice.injectEndpoints() 拆分端点定义。这样仍然可以拥有一个 API 切片实例,一个中间件和缓存 Reducer,但可以将一些端点的定义移动到其他文件。这可以实现代码分割场景,并在需要时将一些端点与功能文件夹共置。
为了说明这个过程,将 getUsers 端点切换为注入到 usersSlice.js 中,而不是在 apiSlice.js 中定义。已经将 apiSlice 导入到 asyncUsersSlice.js 中,以便可以访问 getUsers 端点,因此可以在此处切换为调用 apiSlice.injectEndpoints()。
import { apiSlice } from "../api/apiSlice";
export const apiSliceWithUsers = apiSlice.injectEndpoints({
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users'
})
})
})
export const { useGetUsersQuery } = apiSliceWithUsers
const selectUsersResult = apiSliceWithUsers.endpoints.getUsers.select()
injectEndpoints() 改变原始 API Slice 对象以添加其他端点定义,然后返回相同的 API 引用。此外,injectEndpoints 的返回值包含来自注入端点的其他 TS 类型。
因此应该将其保存为具有不同名称的新变量,以便可以使用更新的 TS 类型,让所有内容正确编译,并提醒自己正在使用哪个版本的 API 切片。在这里将其称为 apiSliceWithUsers,以区别于原始 apiSlice。
目前,引用 getUsers 端点的唯一文件是入口文件,它正在调度 initiate thunk。需要更新它以导入扩展的 API Slice:
import { apiSliceWithUsers } from "./features/users/asyncUsersSlice";
async function mockStart() {
await worker.start({ onUnhandledRequest: 'bypass' })
store.dispatch(apiSliceWithUsers.endpoints.getUsers.initiate())
……
}
mockStart()
操作响应数据
到目前为止,所有的查询端点都简单地存储了来自服务器的响应数据,与 body 中接收到的数据完全相同。getPosts 和 getUsers 都期望服务器返回一个数组,而 getPost 期望单个 Post 对象作为主体。
客户端通常需要从服务器响应中提取数据片段,或者在缓存数据之前以某种方式转换数据。例如,如果 /getPost 请求返回类似 {post: {id}} ,并且数据嵌套,该怎么办?
可以通过几种方法从概念上处理这个问题。一种选择是提取 responseData.post 字段并将其存储在缓存中,而不是整个 body。另一种方法是将整个响应数据存储在缓存中,但让组件仅指定它们需要的缓存数据的特定部分。
转换响应数据
端点可以定义一个 transformResponse 处理程序,该处理程序可以在缓存之前提取或修改从服务器接收的数据。例如,如果 getPost 返回 {post: {id}},可以有 transformResponse: (responseData) => responseData.post,它只会缓存实际的 Post 对象,而不是整个响应体。
在之前:性能和标准化 中,讨论了为什么以规范化存储数据结构有用的原因。特别是,它允许根据 ID 查找和更新项,而不必循环遍历数组来查找正确的项。
selectUserById 选择器当前必须循环缓存的用户数组才能找到正确的 User 对象。如果要使用标准化方法来转换要存储的响应数据,可以简化它以直接通过 ID 查找用户。
之前在 usersSlice 中使用 createEntityAdapter 来管理规范化的用户数据。可以将 createEntityAdapter 集成到 extendedApiSlice 中,并在缓存数据之前实际使用 createEntityAdapter 来转换数据。将取消注释最初的 usersAdapter 行,并再次使用其更新函数和选择器。
const usersAdapter = createEntityAdapter()
const initialState = usersAdapter.getInitialState()
export const apiSliceWithUsers = apiSlice.injectEndpoints({
endpoints: (builder) => ({
getUsers: builder.query({
query: () => '/users',
transformResponse(res) {
return usersAdapter.setAll(initialState, res)
}
})
})
})
export const { useGetUsersQuery } = apiSliceWithUsers
const selectUsersResult = apiSliceWithUsers.endpoints.getUsers.select()
const selectUsersData = createSelector(
selectUsersResult,
(result) => result.data ?? initialState
)
export const {
selectAll: selectAllUsers,
selectById: selectUserById,
selectIds: selectUserIds
} = usersAdapter.getSelectors(selectUsersData)
export const selectCurrentUser = (state) => {
const currentUsername = selectCurrentUsername(state)
if (currentUsername) {
return selectUserById(state, currentUsername)
}
}
向 getUsers 端点添加了 transformResponse 选项。它接收整个响应数据主体作为其参数(在本例中为 User[] 数组),并应返回要缓存的实际数据。通过调用 usersAdapter.setAll(initialState, responseData),它将返回包含所有接收到的项的标准规范化数据结构 {ids: [], entities: {}},作为缓存条目的 data 字段的实际内容返回。
需要为 adapter.getSelectors() 函数提供 “输入选择器”,以便它知道在哪里可以找到标准化数据。在这种情况下,数据嵌套在 RTK 查询缓存 reducer 内,因此从缓存状态中选择正确的字段。为了保持一致,可以编写一个 selectUsersData 选择器,如果尚未获取数据,则返回到初始的空规范化状态。
规范化与文档缓存
讨论一下刚刚做了什么以及它为什么重要。可能听说过与 Apollo 等其他数据获取库相关的术语 “标准化缓存”。重要的是要了解 RTK 查询使用 “文档缓存” 方法,而不是 “标准化缓存”。
完全规范化的缓存会尝试根据项目类型和 ID 在所有查询中删除重复的相似项目。举个例子,假设有一个带有 getTodos 和 getTodo 端点的 API 切片,并且组件进行以下查询:
getTodos()getTodos({filter: 'odd'})getTodo({id: 1})
每个查询结果都将包含一个类似于 {id: 1} 的 Todo 对象。
在完全规范化的数据去重缓存中,只会存储此 Todo 对象的单个副本。然而,RTK Query 将每个查询结果独立保存在缓存中。因此,这将导致该 Todo 的三个独立副本缓存在 Redux 存储中。但是,如果所有端点始终提供相同的标签(例如 {type: 'Todo', id: 1}),则使该标签无效将强制所有匹配的端点重新获取其数据以保持一致性。
RTK 查询故意不实现可跨多个请求删除重复项的缓存。有几个原因:
- 完全标准化的跨查询共享缓存是一个很难解决的问题
- 现在没有时间、资源或兴趣来尝试解决这个问题
- 在许多情况下,当数据失效时简单地重新获取数据效果很好并且更容易理解
- RTKQ 的主要目标是帮助解决 “获取一些数据” 的一般用例,这对很多人来说是一个很大的痛点
只是规范化了 getUsers 端点的响应数据,因为它被存储为 {[id]: value} 查找表。然而,这和 “标准化缓存” 不一样 - 只是改变了这一响应的存储方式,而不是跨端点或请求删除重复的结果。
从结果中选择值
从旧 asyncPostsSlice 读取的最后一个组件是 <AsyncUserPage>,它根据当前用户过滤帖子列表。可以使用 useGetPostsQuery() 获取整个帖子列表,然后在组件中对其进行转换,例如在 useMemo 内部进行排序。查询钩子还能够通过提供 selectFromResult 选项来选择缓存状态的片段,并且仅在所选片段发生更改时才重新渲染。
useQuery 钩子始终将缓存键参数作为第一个参数,如果需要提供钩子选项,则必须始终是第二个参数,如 useSomeQuery(cacheKey, options)。在这种情况下,getUsers 端点没有任何实际的缓存键参数。从语义上讲,这与 undefined 的缓存键相同。因此,为了向钩子提供选项,必须调用 useGetUsersQuery(undefined, options)。
可以使用 selectFromResult 让 <AsyncUserPage> 从缓存中读取经过过滤的帖子列表。然而,为了让 selectFromResult 避免不必要的重新渲染,需要确保提取的任何数据都被正确记忆。为此,应该创建一个新的选择器实例,<AsyncUserPage> 组件可以在每次渲染时重用该实例,以便选择器根据其输入记住结果。
import { createSelector } from "@reduxjs/toolkit";
import { useGetPostsQuery } from "../api/apiSlice";
const selectPostsForUser = createSelector(
(res) => {
console.log('selectPostsForUser : ', res);
return res.data
},
(res, userId) => userId,
(posts, userId) => posts.filter((post) => post.user === userId)
)
export default function AsyncUserPage() {
const {userId} = useParams()
const user = useSelector(state => selectUserById(state, userId))
const { postsForUser } = useGetPostsQuery(undefined, {
selectFromResult: result => ({
...result,
postsForUser: selectPostsForUser(result, userId)
})
})
……
}
在这里创建的记忆选择器函数有一个关键的区别。通常,选择器期望整个 Redux state 作为他们的第一个参数,并从 state 中提取或导出一个值。然而,在这种情况下,我们只处理保存在缓存中的 “result” 值。“result” 对象内部有一个 data 字段,其中包含我们需要的实际值,以及一些请求元数据字段。
因为这个选择器接收的不是通常的 RootState 类型作为其第一个参数,所以需要告诉 TS 该结果值是什么样的。RTK Query 包导出一个名为 TypedUseQueryStateResult 的 TS 类型,它表示“useQuery 钩子返回对象的类型”。可以使用它来声明期望结果包含 Post[] 数组,然后使用该类型定义我们的选择器。
选择器和记忆变化参数:从 RTK 2.x 和 Reselect 5.x 开始,记忆选择器具有 无限缓存大小,因此更改参数仍应保留早期记忆结果。如果使用的是 RTK 1.x 或 Reselect 4.x,请注意 memoized 选择器仅具有默认 1 的缓存大小。需要为每个组件创建唯一的选择器实例来确保选择器在传递不同的参数(如 ID)时始终保持记忆。
selectFromResult 回调从服务器接收包含原始请求元数据的 result 对象和 data,并且应该返回一些提取或派生的值。因为查询钩子为此处返回的任何内容添加了额外的 refetch 方法,所以 selectFromResult 应该始终返回一个包含你需要的字段的对象。
由于 result 保存在 Redux 存储中,因此无法更改它 - 需要返回一个新对象。查询钩子将对此返回的对象进行 “shallow” 比较,并且仅在其中一个字段发生更改时重新渲染组件。可以通过仅返回该组件所需的特定字段来优化重新渲染 - 如果不需要其余的元数据标志,可以完全省略它们。如果确实需要它们,可以扩展原始 result 值以将它们包含在输出中。
在本例中,将调用字段 postsForUser,并且可以从钩子结果中解构该新字段。通过每次调用 selectPostsForUser(result, userId),它都会记住过滤后的数组,并且只有在获取的数据或用户 ID 发生变化时才重新计算它。
比较转换方法
现在已经看到了管理转换响应数据的三种不同方法:
- 将原始响应数据保留在缓存中,组件中读取完整的结果并导出值
- 将原始响应数据保留在缓存中,使用 selectFromResult 读取派生结果
- 在存储到缓存之前转换响应数据
这些方法中的每一种都可以在不同的情况下发挥作用。以下是一些关于何时应考虑使用它们的建议:
- transformResponse:端点的所有使用者都需要特定的格式,例如标准化响应以实现按 ID 更快的查找
- selectFromResult:端点的某些消费者只需要部分数据,例如过滤后的列表
- 每个组件/useMemo:当只有某些特定组件需要转换缓存数据时
高级缓存更新
已经完成了帖子和用户数据的更新,所以剩下的就是处理反应和通知。将这些切换为使用 RTK 查询将使我们有机会尝试一些可用于处理 RTK 查询的缓存数据的高级技术,并使我们能够为用户提供更好的体验。
持续反馈
最初只跟踪客户端的反馈,并没有将它们持久化到服务器。添加一个新的 addReaction mutation,并在用户每次单击反馈按钮时使用它来更新服务器上相应的 Post。
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({baseUrl: '/fakeApi'}),
tagTypes: ['Posts'],
endpoints: (builder) => ({
……
addReaction: builder.mutation({
query: ({postId, reaction}) => ({
url: `posts/${postId}/reactions`,
method: 'POST',
body: { reaction }
}),
invalidatesTags: (result, error, arg) => [{ type: 'Post', id: arg.postId} ]
})
})
})
console.log('apiSlice: ', apiSlice);
export const {
// useGetUsersQuery,
useGetPostsQuery,
useGetPostQuery,
useAddNewPostMutation,
useEditPostMutation,
useAddReactionMutation
} = apiSlice
与其他 mutations 类似,采用一些参数并向服务器发出请求,并在请求 body 中包含一些数据。由于这个示例应用很小,将只给出反馈的名称,并让服务器在帖子中增加该反馈类型的计数器。
需要重新获取帖子才能看到客户端上的数据更改,因此可以根据其 ID 使该特定 Post 无效。完成后,更新 <AsyncReactionButtons> 来使用此 mutation。
import { useAddReactionMutation } from "../api/apiSlice";
const reactionEmoji = {
thumbsUp: '👍',
tada: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}
export default function AsyncReactionButtons({post}) {
const [addReaction] = useAddReactionMutation()
console.log('AsyncReactionButtons post: ', post);
return <div>
{Object.entries(reactionEmoji).map(([stringName, emoji]) => {
return <button key={stringName} onClick={() => addReaction({postId: post.id, reaction: stringName})}>
{`${emoji}${post.reactions[stringName]}`}
</button>
})}
</div>
}
看看实际效果!回到 <PostsList>,然后单击其中一个反馈,看看会发生什么?
重新获取了整个帖子列表以更新一篇帖子的反馈。模拟的 API 服务器设置为在响应之前有 2 秒的延迟,但即使响应速度更快,这仍然不是一个良好的用户体验。
optimistic update 反馈
对于添加反馈之类的小更新,可能不需要重新获取整个帖子列表。相反,可以尝试只更新客户端上已缓存的数据,以匹配期望在服务器上发生的情况。此外,如果立即更新缓存,用户在单击按钮时会得到即时反馈,而不必等待响应返回。这种立即更新客户端状态的方法称为 "optimistic update,它是 Web 应用中的常见模式。
RTK Query 包含用于直接更新客户端缓存的实用程序。这可以与 RTK Query 的 “请求生命周期” 方法结合使用以实现 optimistic update。
缓存更新实用程序
在 api.util 中 API slices 有一些附加的其他方法。这包括用于修改缓存的 thunk:upsertQueryData 用于添加或替换缓存条目,updateQueryData 用于修改缓存条目。由于这些是 thunk,它们可以在任何可以访问 dispatch 的地方使用。
updateQueryData util thunk 需要三个参数:要更新的端点的名称、用于标识要更新的特定缓存条目的相同缓存键参数以及更新缓存数据的回调。updateQueryData 使用 Immer,因此可以像在 createSlice 中一样 “mutate” 的缓存数据:
dispatch(
apiSlice.util.updateQueryData(endpointName, queryArg, draft => {
// mutate `draft` here like you would in a reducer
draft.value = 123
})
)
updateQueryData 生成一个操作对象,其中包含我们所做更改的补丁差异。当分派该操作时,来自 dispatch 的返回值是一个 patchResult 对象。如果调用 patchResult.undo(),它会自动调度一个操作来反转补丁差异更改。
onQueryStarted 生命周期
要研究的第一个生命周期方法是 onQueryStarted。此选项可用于 queries 和 mutations。
如果提供,每次发出新请求时都会调用 onQueryStarted。这为我们提供了一个运行其他逻辑以响应请求的地方。
与异步 thunk 和监听器效果类似,onQueryStarted 回调从请求中接收查询 arg 值作为其第一个参数,并接收 lifecycleApi 对象作为第二个参数。lifecycleApi 包含与 createAsyncThunk 相同的 {dispatch, getState, extra, requestId} 值。它还有几个此生命周期独有的附加字段。最重要的是 lifecycleApi.queryFulfilled,这是一个 Promise,它将在请求返回时解析,并根据请求 fulfill 或 reject。
实现 Optimistic Updates
可以使用 onQueryStarted 生命周期内的更新实用程序来实现 “optimistic” 更新(在请求完成之前更新缓存)或 “pessimistic” 更新(在请求完成后更新缓存)。
可以通过在 getPosts 缓存中查找特定的 Post 条目来实现 optimistic 更新,并对其进行 “mutating” 以增加反应计数器。对于该帖子 ID,可能在 getPost 缓存中还有相同概念的单个 Post 对象的第二个副本,因此如果该缓存条目也存在,需要更新它。
默认情况下,预计请求会成功。如果请求失败,可以 await lifecycleApi.queryFulfilled 捕获失败并撤消补丁更改以恢复乐观更新。
export const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({baseUrl: '/fakeApi'}),
tagTypes: ['Posts'],
endpoints: (builder) => ({
……
addReaction: builder.mutation({
query: ({postId, reaction}) => ({
url: `posts/${postId}/reactions`,
method: 'POST',
body: { reaction }
}),
// invalidatesTags: (result, error, arg) => [{ type: 'Post', id: arg.postId} ],
async onQueryStarted({postId, reaction}, lifecycleApi) {
const getPostsPatchResult = lifecycleApi.dispatch(
apiSlice.util.updateQueryData('getPosts', undefined, draft => {
const post = draft.find(post => post.id === postId)
if (post) {
post.reactions[reaction]++
}
})
)
const getPostPatchResult = lifecycleApi.dispatch(
apiSlice.util.updateQueryData('getPost', undefined, draft => {
draft.reactions[reaction]++
})
)
try {
await lifecycleApi.queryFulfilled
} catch {
getPostsPatchResult.undo()
getPostPatchResult.undo()
}
}
})
})
})
对于这种情况,还删除了刚刚添加的 invalidatesTags 行,因为不想在单击反馈按钮时重新获取帖子。
现在,如果快速单击反馈按钮几次,应该每次都会在 UI 中看到数字增量。如果查看“网络”选项卡,还会看到每个单独的请求也发送到服务器。
有时,修改请求会在服务器响应中返回有意义的数据,例如应替换临时客户端 ID 的最终项目 ID 或其他相关数据。如果先执行 const res = await lifecycleApi.queryFulfilled,然后可以使用响应中的数据将缓存更新应用为 “pessimistic” 更新。
通知的流式更新
最后一个功能是通知选项卡。最初在构建此功能时,说 “在真实的应用中,每次发生事情时服务器都会向我们的客户端推送更新”。最初通过添加 “刷新通知” 按钮来伪造该功能,并让它发出 HTTP GET 请求以获取更多通知条目。
应用通常会发出初始请求以从服务器获取数据,然后打开 Websocket 连接以随着时间的推移接收其他更新。RTK Query 的生命周期方法提供了实现这种 “流式更新” 缓存数据的空间。
已经看到了 onQueryStarted 生命周期,它能够实现 optimistic(或 pessimistic)更新。此外,RTK Query 提供了一个 onCacheEntryAdded 端点生命周期处理程序,这是实现流式更新的好地方。将使用该方法来实现更现实的通知管理方法。
onCacheEntryAdded 生命周期
与 onQueryStarted 一样,onCacheEntryAdded 生命周期方法可用于 queries 和 mutations。
每当有新的缓存条目(端点 + 序列化查询参数)添加到缓存时,都会调用 onCacheEntryAdded。这意味着它运行的频率将低于 onQueryStarted,后者在请求发生时运行。
与 onQueryStarted 类似,onCacheEntryAdded 接收两个参数。第一个是通常的查询 args 值。第二个是略有不同的 lifecycleApi,它具有 {dispatch, getState, extra, requestId},以及 updateCachedData 实用程序,这是 api.util.updateCachedData 的另一种形式,它已经知道要使用的正确端点名称和查询参数并为你执行分派。
还有两个可以等待的额外 Promises:
- cacheDataLoaded:使用收到的第一个缓存值解析,通常用于等待实际值在缓存中,然后再执行更多逻辑
- cacheEntryRemoved :当此缓存条目被删除时解析(即,没有更多订阅者并且缓存条目已被垃圾收集)
只要数据的 1+ 个订阅者仍然处于活动状态,缓存条目就会保持活动状态。当订阅者数量变为 0 并且缓存生存期计时器到期时,缓存条目将被删除,并且 cacheEntryRemoved 将执行。通常,使用模式是:
- 执行 await cacheDataLoaded
- 创建像 Websocket 一样的服务器端数据订阅
- 当收到更新时,根据更新使用 updateCachedData 到 “mutate” 的缓存值
- 最后是 await cacheEntryRemoved
- 之后清理订阅
这使得 onCacheEntryAdded 成为放置长期运行逻辑的好地方,只要 UI 需要此特定数据,该逻辑就应该继续运行。一个很好的例子可能是聊天应用需要获取聊天通道的初始消息,使用 Websocket 订阅随时间接收其他消息,并在用户关闭通道时断开 Websocket 连接。
获取通知
需要将这项工作分为几个步骤:首先将设置一个新的通知端点,并添加 fetchNotificationsWebsocket thunk,它将触发模拟后端通过 websocket 而不是 HTTP 请求发回通知。
像对 getUsers 所做的那样,在 notificationsSlice 中注入 getNotifications 端点。
import { apiSlice } from "../api/apiSlice";
export const apiSliceWithNotifications = apiSlice.injectEndpoints({
endpoints: builder => {
getNotifications: builder.query({
query: () => '/notifications'
})
}
})
export const { useGetNotificationsQuery } = apiSliceWithNotifications
getNotifications 是一个标准查询端点,它将存储从服务器收到的 ServerNotification 对象。
然后在 <NotificationsList> 中,可以使用新的查询钩子自动获取一些通知。当这样做时,只会返回 ServerNotification 对象,而不是带有我们添加的额外 {read, isNew} 字段的 ClientNotification 对象,因此必须暂时禁用对 notification.new 的检查:
import { useGetNotificationsQuery } from "./notificationsSlice";
export default function NotificationsList() {
const notificationIds = useSelector(selectNotificationIds)
const dispatch = useDispatch()
useLayoutEffect(() => {
if (notificationIds.length > 0) {
dispatch(allNotificationsRead())
}
}, [dispatch, notificationIds.length])
const { data: notifications = [] } = useGetNotificationsQuery()
return <section>
<h2>Notifications</h2>
{notifications.map((notification) => {
return <div className={ 'notification' }>
<div>
<b><AsyncPostAuthor userId={notification.user} showPrefix={false}></AsyncPostAuthor></b>{' '}
<span>{notification.message}</span>
</div>
<TimeAgo timestamp={notification.date}></TimeAgo>
</div>
})}
</section>
}
如果进入 “通知” 选项卡,应该会看到一些条目出现,但它们都不会被着色以表明它们是新的。同时,如果我们单击 “刷新通知” 按钮,我们将看到 “未读通知” 计数器不断增加。这是因为两件事。按钮仍会触发将条目存储在 state.notifications 切片中的原始 fetchNotifications thunk。此外,<NotificationsList> 组件甚至没有重新渲染(它依赖于来自 useGetNotificationsQuery 钩子的缓存数据,而不是 state.notifications 切片),因此 useLayoutEffect 没有运行或分派 allNotificationsRead。
跟踪客户端状态
下一步是重新考虑如何跟踪 “read” 通知状态。
以前,从 fetchNotifications thunk 中获取的 ServerNotification 对象,在 Reducer 中添加 {read, isNew} 字段,然后保存这些对象。现在将 ServerNotification 对象保存在 RTK 查询缓存中。
可以做更多手动缓存更新。可以使用 transformResponse 添加其他字段,然后在用户查看通知时进行一些工作来修改缓存本身。
相反,将尝试已经在做的事情的不同形式:跟踪 notificationsSlice 内部的读取状态。
从概念上讲,真正想要做的是跟踪每个通知项的 {read, isNew} 状态。如果有办法知道查询钩子何时获取通知并可以访问通知 ID,就可以在切片中执行此操作并为收到的每个通知保留相应的 “metadata” 条目。
幸运的是,可以做到这一点!由于 RTK Query 是基于标准 Redux Toolkit 片段(如 createAsyncThunk)构建的,因此每次请求完成时,它都会调度带有结果的 fulfilled 操作。只需要一种方法来在 notificationsSlice 中监听它,而 createSlice.extraReducers 是需要处理该操作的地方。
但该监听什么呢?因为这是一个 RTKQ 端点,无法访问 asyncThunk.fulfilled/pending 动作创建者,所以不能直接将它们传递给 builder.addCase()。
RTK Query 端点公开一个 matchFulfilled 匹配器函数,可以在 extraReducers 内部使用它来监听该端点的 fulfilled 操作。(请注意,需要从 builder.addCase() 更改为 builder.addMatcher())。
因此,将把 ClientNotification 更改为新的 NotificationMetadata 类型,监听 getNotifications 查询操作,并将 “仅元数据” 对象存储在切片中,而不是整个通知中。
作为其中的一部分,我们将把 notificationsAdapter 重命名为 metadataAdapter,并将所有提及的 notification 变量替换为 metadata,以便更清晰。这看起来可能有很多变化,但它主要只是重命名变量。
还将实体适配器 selectEntities 选择器导出为 selectMetadataEntities。需要在 UI 中通过 ID 查找这些元数据对象,如果组件中有可用的查找表,那么查找起来会更容易。
export const fetchNotifications = createAsyncThunk('notifications/fetchNotifications', async (_unuse, thunkApi) => {
// 删除 时间戳
const response = await client.get(`/fakeApi/notifications`)
return response.data
})
const metadataAdapter = createEntityAdapter()
const initialState = metadataAdapter.getInitialState()
const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
// 重命名 `metadata`
Object.values(state.entities).forEach(metadata => {
metadata.read = true
});
}
},
extraReducers: (builder) => {
// 使用 addMatcher 监听端点 matchcompleted 操作
builder.addMatcher(apiSliceWithNotifications.endpoints.getNotifications.matchFulfilled, (state, action) => {
console.log('fetchNotifications payload: ', action.payload);
// 添加用于跟踪新通知的客户端元数据
const notificationsWithMetadata = action.payload.map(notification => ({
id: notification.id,
read: false,
isNew: true
}))
// 重命名 `metadata`
Object.values(state.entities).forEach(metadata => {
metadata.isNew = !metadata.read
})
metadataAdapter.upsertMany(state, notificationsWithMetadata)
})
}
})
export const {allNotificationsRead} = notificationsSlice.actions
export default notificationsSlice.reducer
// 重命名 选择器
export const {
selectAll: selectAllNotificationsMetadata,
selectEntities: selectMetadataEntities
} = metadataAdapter.getSelectors((state) => {
return state.notifications
})
export const selectUnreadNotificationsCount = (state) => {
const allMetadata = selectAllNotificationsMetadata(state)
const unreadNotifications = allMetadata.filter(metadata => !metadata.read)
return unreadNotifications.length
}
然后可以将元数据查找表读入 <NotificationsList>,并为正在渲染的每个通知查找正确的元数据对象,然后重新启用 isNew 检查以显示正确的样式:
import { allNotificationsRead, useGetNotificationsQuery, selectMetadataEntities } from "./notificationsSlice";
export default function NotificationsList() {
const dispatch = useDispatch()
const { data: notifications = [] } = useGetNotificationsQuery()
const notificationsMetadata = useSelector(selectMetadataEntities)
useLayoutEffect(() => {
dispatch(allNotificationsRead())
})
return <section>
<h2>Notifications</h2>
{notifications.map((notification) => {
const metadata = notificationsMetadata[notification.id]
const notificationClassname = metadata.isNew ? 'notification new' : 'notification'
// 渲染
……
})}
</section>
}
现在如果查看 “通知” 选项卡,新通知的样式是正确的……但仍然没有收到任何通知,也没有将这些通知标记为已读。
通过 Websocket 推送通知
还有几个步骤要做才能完成切换到通过服务器推送获取更多通知。
下一步是将 “刷新通知” 按钮从调度异步 thunk 切换到通过 HTTP 请求获取,强制模拟后端通过 websocket 发送通知。
src/api/server.js 文件已经配置了一个模拟 Websocket 服务器,类似于模拟 HTTP 服务器。由于没有真正的后端(或其他用户!),我们仍然需要手动告诉模拟服务器何时发送新通知,因此我们将继续伪造它,方法是单击一个按钮以强制更新。为此,server.js 导出一个名为 forceGenerateNotifications 的函数,它将强制后端通过该 websocket 推出一些通知条目。
将用 fetchNotificationsWebsocket thunk 替换 fetchNotifications 异步 thunk。fetchNotificationsWebsocket 正在执行与现有 fetchNotifications 异步 thunk 相同的工作。但是,在这种情况下,并没有发出实际的 HTTP 请求,因此没有 await 调用,也没有要返回的有效负载。只是调用 server.ts 专门导出的函数来伪造服务器端推送通知。
因此,fetchNotificationsWebsocket 甚至不需要使用 createAsyncThunk。它只是一个普通的手写 thunk,因此可以使用 AppThunk 类型来描述 thunk 函数的类型,并为 (dispatch, getState) 提供正确的类型。
为了实现 “最新时间戳” 检查,我们确实需要添加选择器,以便我们也可以从通知缓存条目中读取。将使用与 users slice 相同的模式。
import { forceGenerateNotifications } from "../../api/server";
export const apiSliceWithNotifications = apiSlice.injectEndpoints({
endpoints: builder => ({
getNotifications: builder.query({
query: () => '/notifications'
})
})
})
export const { useGetNotificationsQuery } = apiSliceWithNotifications
export const selectNotificationsResult = apiSliceWithNotifications.endpoints.getNotifications.select
const selectNotificationsData = createSelector(selectNotificationsResult, notificationsResult => notificationsResult.data ?? [])
export const fetchNotificationsWebsocket = () => (dispatch, getState) => {
const allNotifications = selectNotificationsData(getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification.date ?? ''
// 硬编码调用模拟服务器,模拟 websockets 服务器推送场景
forceGenerateNotifications(latestTimestamp)
}
……
然后可以更改 <AsyncNavbar> 以分派 fetchNotificationsWebsocket:
import {
fetchNotificationsWebsocket,
selectUnreadNotificationsCount,
useGetNotificationsQuery
} from "../features/notifications/notificationsSlice";
export default function AsyncNavbar({children}) {
……
<button onClick={() => dispatch(fetchNotificationsWebsocket())}>Refresh Notifications</button>
}
目前我们正在通过 RTK 查询获取初始通知,在客户端跟踪读取状态,并且已经设置了基础架构以强制通过 websocket 发送新通知。但是,如果我们现在单击 “刷新通知”,它将引发错误 - 我们还没有实现 websocket 处理!接下来实现实际的流式更新逻辑。
实现流式更新
对于此应用,从概念上讲,希望在用户登录后立即查询通知,并立即开始监听所有未来传入的通知更新。如果用户注销,应该停止监听。
我们知道 <AsyncNavbar> 仅在用户登录后渲染,并且它会一直渲染。因此,这将是保持缓存订阅有效的好地方。我们可以通过在该组件中渲染 useGetNotificationsQuery() 钩子来做到这一点。
import {
fetchNotificationsWebsocket,
selectUnreadNotificationsCount,
useGetNotificationsQuery
} from "../features/notifications/notificationsSlice";
export default function AsyncNavbar({children}) {
// 触发获取初始通知,并保持websocket打开以接收更新
useGetNotificationsQuery()
……
}
最后一步是将 onCacheEntryAdded 生命周期处理程序实际添加到 getNotifications 端点,并添加使用 websocket 的逻辑。
在这种情况下,我们将创建一个新的 websocket,订阅来自 socket 的传入消息,从这些消息中读取通知,并使用附加数据更新 RTKQ 缓存条目。这在概念上类似于在 onQueryStarted 中对 optimistic updates 所做的操作。
在这里会遇到另一个问题。如果通过 websocket 接收传入通知,则没有显式 “请求成功” 操作被分派,但仍然需要为所有传入通知创建新的通知元数据条目。
将通过创建一个特定的新 Redux 操作类型来解决这个问题,该操作类型将仅用于发出 “收到了更多通知” 信号,并从 websocket 处理程序中分派它。然后可以使用 isAnyOf 匹配器实用程序更新 notificationsSlice 以监听端点操作和此其他操作,并在两种情况下执行相同的元数据逻辑。
import {
createSlice,
createAsyncThunk,
createEntityAdapter,
createSelector,
createAction,
isAnyOf
} from "@reduxjs/toolkit";
import { client } from "../../api/client";
const notificationsReceived = createAction('notifications/notificationsReceived')
export const apiSliceWithNotifications = apiSlice.injectEndpoints({
endpoints: builder => ({
getNotifications: builder.query({
query: () => '/notifications',
async onCacheEntryAdded(arg, lifecycleApi) {
// 当开始订阅缓存时创建一个websocket连接
const ws = new WebSocket('ws://localhost')
try {
// 等待初始查询解析后再继续
await lifecycleApi.cacheDataLoaded
// 当从 socket 连接到服务器接收数据时,使用接收到的消息更新查询结果
const listener = (event) => {
const message = JSON.parse(event.data)
switch (message.type) {
case 'notifications':
// 插入所有从websocket接收到的通知到现有的RTKQ缓存数组
lifecycleApi.updateCachedData(draft => {
draft.push(...message.payload)
draft.sort((a, b) => b.date.localeCompare(a.date))
})
// 调度一个额外的动作,这样就可以跟踪“read”状态
lifecycleApi.dispatch(notificationsReceived(message.payload))
break;
default:
break;
}
}
ws.addEventListener('message', listener)
} catch {
//如果‘ cacheEntryRemoved ’在‘ cacheDataLoaded ’之前被解析,在这种情况下 cacheDataLoaded 将抛出
}
// 当缓存订阅不再激活时,cacheEntryRemoved将解析
await lifecycleApi.cacheEntryRemoved
// 一旦 cacheEntryRemoved 承诺被解析,执行清理步骤
ws.close()
}
})
})
})
export const { useGetNotificationsQuery } = apiSliceWithNotifications
const matchNotificationsReceived = isAnyOf(notificationsReceived, apiSlice.endpoints.getNotifications.matchFulfilled)
……
const notificationsSlice = createSlice({
name: 'notifications',
……
extraReducers: (builder) => {
builder.addMatcher(matchNotificationsReceived, (state, action) => {
……
})
}
})
添加缓存条目后,将创建一个新的 WebSocket 实例,该实例将连接到模拟服务器后端。
等待 lifecycleApi.cacheDataLoaded Promise 解析,此时我们知道请求已完成并且有实际可用的数据。
需要订阅来自 websocket 的传入消息。我们的回调将收到一个 websocket MessageEvent,我们知道 event.data 将是一个包含来自后端的 JSON 序列化通知数据的字符串。
当收到该消息时,我们会解析内容,并确认解析的对象与我们正在寻找的消息类型匹配。如果是这样,我们调用 lifecycleApi.updateCachedData(),将所有新通知添加到现有缓存条目,并重新排序以确保它们的顺序正确。
最后,还可以等待 lifecycleApi.cacheEntryRemoved promise 来知道何时需要关闭 websocket 并进行清理。
请注意,不必须在此处的生命周期方法中创建 websocket。根据应用结构,可以在应用设置过程中较早创建了它,它可能位于另一个模块文件中或其自己的 Redux 中间件中。这里真正重要的是,使用 onCacheEntryAdded 生命周期来了解何时开始监听传入数据、将结果插入缓存条目以及在缓存条目消失时进行清理。
就是这样!现在,当单击 “刷新通知” 时,应该看到未读通知数量增加,单击 “通知” 选项卡应该会适当地高亮已读和未读通知。
清理
最后一步,可以进行一些额外的清理。postsSlice.js 中的实际 createSlice 调用不再使用,因此可以删除切片对象及其关联的选择器 + 类型,然后从 Redux 存储中删除 postsReducer。将保留 addPostsListeners 函数和类型,因为那是该代码的合理位置。
总结
特定的缓存标签可更精细地控制缓存的失效:
- 缓存标签可以是 ‘Post’ 或 {type: ‘Post’, id}
- 端点可以根据结果和参数缓存键提供或无效缓存标签
RTK Query 的 API 与 UI 无关,可以在 React 之外使用:
- 端点对象包括发起请求、生成结果选择器、匹配请求动作对象的功能
响应可以根据需要以不同的方式进行转换:
- 端点可以定义 transformResponse 回调来在缓存之前修改数据
- 可以为 Hooks 提供 selectFromResult 选项来提取/转换数据
- 组件可以读取整个值并使用 useMemo 进行转换
RTK Query 具有用于操作缓存数据的高级选项,以获得更好的用户体验:
- onQueryStarted 生命周期可用于 optimistic updates,方法是在请求返回之前立即更新缓存
- onCacheEntryAdded 生命周期可用于通过基于服务器推送连接随时间更新缓存来进行流式更新
- RTKQ 端点有一个 matchFulfilled 匹配器,可以在内部使用它来监听 RTKQ 端点操作并运行其他逻辑,例如更新切片的状态

944

被折叠的 条评论
为什么被折叠?



