Skip to main content

Redux 要点,第 6 部分:性能、规范化数据和反应逻辑

你将学到什么
  • 如何使用 createSelector 创建记忆选择器函数

    ¥How to create memoized selector functions with createSelector

  • 优化组件渲染性能的模式

    ¥Patterns for optimizing component rendering performance

  • 如何使用 createEntityAdapter 存储和更新标准化数据

    ¥How to use createEntityAdapter to store and update normalized data

  • 如何使用 createListenerMiddleware 实现反应逻辑

    ¥How to use createListenerMiddleware for reactive logic

先决条件
  • 完成 第五部分,了解数据获取流程

    ¥Completion of Part 5 to understand data fetching flow

介绍

¥Introduction

第 5 部分:异步逻辑和数据获取 中,我们了解了如何编写异步 thunk 来从服务器 API 获取数据,以及处理异步请求加载状态的模式。

¥In Part 5: Async Logic and Data Fetching, we saw how to write async thunks to fetch data from a server API, and patterns for handling async request loading state.

在本节中,我们将研究确保应用良好性能的优化模式,以及自动处理存储中常见数据更新的技术。我们还将研究如何编写响应分派操作的反应逻辑。

¥In this section, we'll look at optimized patterns for ensuring good performance in our application, and techniques for automatically handling common updates of data in the store. We'll also look at how to write reactive logic that responds to dispatched actions.

到目前为止,我们的大部分功能都以 posts 功能为中心。我们将添加应用的几个新部分。添加完这些之后,我们将了解我们如何构建的一些具体细节,并讨论我们迄今为止构建的一些弱点以及我们如何改进实现。

¥So far, most of our functionality has been centered around the posts feature. We're going to add a couple new sections of the app. After those are added, we'll look at some specific details of how we've built things, and talk about some weaknesses with what we've built so far and how we can improve the implementation.

添加更多用户功能

¥Adding More User Features

添加用户页面

¥Adding User Pages

我们从假 API 中获取用户列表,并且在添加新帖子时可以选择一个用户作为作者。但是,社交媒体应用需要能够查看特定用户的页面并查看他们发布的所有帖子。让我们添加一个页面来显示所有用户的列表,另一个页面来显示特定用户的所有帖子。

¥We're fetching a list of users from our fake API, and we can choose a user as the author when we add a new post. But, a social media app needs the ability to look at the page for a specific user and see all the posts they've made. Let's add a page to show the list of all users, and another to show all posts by a specific user.

我们首先添加一个新的 <UsersList> 组件。它遵循通常的模式,使用 useSelector 从存储中读取一些数据,并映射数组以显示用户列表及其各个页面的链接:

¥We'll start by adding a new <UsersList> component. It follows the usual pattern of reading some data from the store with useSelector, and mapping over the array to show a list of users with links to their individual pages:

features/users/UsersList.tsx
import { Link } from 'react-router-dom'

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

import { selectAllUsers } from './usersSlice'

export const UsersList = () => {
const users = useAppSelector(selectAllUsers)

const renderedUsers = users.map(user => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))

return (
<section>
<h2>Users</h2>

<ul>{renderedUsers}</ul>
</section>
)
}

我们将添加一个 <UserPage>,它类似于我们的 <SinglePostPage>,从路由中获取 userId 参数。然后,它会渲染该特定用户的所有帖子列表。按照我们通常的模式,我们首先在 postsSlice.ts 中添加一个 selectPostsByUser 选择器:

¥And we'll add a <UserPage>, which is similar to our <SinglePostPage> in taking a userId parameter from the router. It then renders a list of all of the posts for that particular user. Following our usual pattern, we'll first add a selectPostsByUser selector in postsSlice.ts:

features/posts/postsSlice.ts
// omit rest of the file
export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)

export const selectPostsByUser = (state: RootState, userId: string) => {
const allPosts = selectAllPosts(state)
// ❌ This seems suspicious! See more details below
return allPosts.filter(post => post.user === userId)
}

export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error
features/users/UserPage.tsx
import { Link, useParams } from 'react-router-dom'

import { useAppSelector } from '@/app/hooks'
import { selectPostsByUser } from '@/features/posts/postsSlice'

import { selectUserById } from './usersSlice'

export const UserPage = () => {
const { userId } = useParams()

const user = useAppSelector(state => selectUserById(state, userId!))

const postsForUser = useAppSelector(state =>
selectPostsByUser(state, userId!)
)

if (!user) {
return (
<section>
<h2>User not found!</h2>
</section>
)
}

const postTitles = postsForUser.map(post => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))

return (
<section>
<h2>{user.name}</h2>

<ul>{postTitles}</ul>
</section>
)
}
提醒

请注意,我们在 selectPostsByUser 中使用 allPosts.filter()。这实际上是一个错误的模式!我们将在一分钟内看到原因。

¥Note that we're using allPosts.filter() inside of selectPostsByUser. This is actually a broken pattern! We'll see why in just a minute.

我们的 usersSlice 中已经有了 selectAllUsersselectUserById 选择器,因此我们可以直接导入并在组件中使用它们。

¥We already have the selectAllUsers and selectUserById selectors available in our usersSlice, so we can just import and use those in the components.

正如我们之前所看到的,我们可以从一个 useSelector 调用或 props 中获取数据,并使用它来帮助决定在另一个 useSelector 调用中从存储中读取什么内容。

¥As we've seen before, we can take data from one useSelector call, or from props, and use that to help decide what to read from the store in another useSelector call.

像往常一样,我们将在 <App> 中添加这些组件的路由:

¥As usual, we will add routes for these components in <App>:

App.tsx
          <Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
<Route path="/users" element={<UsersList />} />
<Route path="/users/:userId" element={<UserPage />} />
<Redirect to="/" />

我们还将在 <Navbar> 中添加另一个链接到 /users 的选项卡,以便我们可以单击并转到 <UsersList>

¥We'll also add another tab in <Navbar> that links to /users so that we can click and go to <UsersList>:

app/Navbar.tsx
export const Navbar = () => {
// omit other logic

navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
</div>
<div className="userDetails">
<UserIcon size={32} />
{user.name}
<button className="button small" onClick={onLogoutClicked}>
Log Out
</button>
</div>
</div>
)

// omit other rendering
}

现在我们可以浏览每个用户的页面并查看他们的帖子列表。

¥Now we can actually browse to each user's page and see a list of just their posts.

向服务器发送登录请求

¥Sending Login Requests to the Server

目前,我们的 <LoginPage>authSlice 只是分派客户端 Redux 操作来跟踪当前用户名。实际上,我们确实需要向服务器发送登录请求。就像我们对帖子和用户所做的那样,我们将把登录和注销处理转换为异步 thunk。

¥Right now our <LoginPage> and authSlice are just dispatching client-side Redux actions to track the current username. In practice, we really need to send a login request to the server. Like we've done with posts and users, we'll convert the login and logout handling to async thunks instead.

features/auth/authSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

import { client } from '@/api/client'

import type { RootState } from '@/app/store'

import { createAppAsyncThunk } from '@/app/withTypes'

interface AuthState {
username: string | null
}

export const login = createAppAsyncThunk(
'auth/login',
async (username: string) => {
await client.post('/fakeApi/login', { username })
return username
}
)

export const logout = createAppAsyncThunk('auth/logout', async () => {
await client.post('/fakeApi/logout', {})
})

const initialState: AuthState = {
// Note: a real app would probably have more complex auth state,
// but for this example we'll keep things simple
username: null
}

const authSlice = createSlice({
name: 'auth',
initialState,
// Remove the reducer definitions
reducers: {},
extraReducers: builder => {
// and handle the thunk actions instead
builder
.addCase(login.fulfilled, (state, action) => {
state.username = action.payload
})
.addCase(logout.fulfilled, state => {
state.username = null
})
}
})

// Removed the exported actions

export default authSlice.reducer

除此之外,我们将更新 <Navbar><LoginPage> 以导入和分派新的 thunk,而不是之前的动作创建者:

¥Along with that, we'll update <Navbar> and <LoginPage> to import and dispatch the new thunks instead of the previous action creators:

components/Navbar.tsx
import { Link } from 'react-router-dom'

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

import { logout } from '@/features/auth/authSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'

import { UserIcon } from './UserIcon'

export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)

const isLoggedIn = !!user

let navContent: React.ReactNode = null

if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}
features/auth/LoginPage.tsx
import React from 'react'
import { useNavigate } from 'react-router-dom'

import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectAllUsers } from '@/features/users/usersSlice'

import { login } from './authSlice'

// omit types

export const LoginPage = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const navigate = useNavigate()

const handleSubmit = async (e: React.FormEvent<LoginPageFormElements>) => {
e.preventDefault()

const username = e.currentTarget.elements.username.value
await dispatch(login(username))
navigate('/posts')
}

由于 postsSlice 正在使用 userLoggedOut 动作创建器,我们可以将其更新为监听 logout.fulfilled

¥Since the userLoggedOut action creator was being used by the postsSlice, we can update that to listen to logout.fulfilled instead:

features/posts/postsSlice.ts
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
import { client } from '@/api/client'

import type { RootState } from '@/app/store'

// Import this thunk instead
import { logout } from '@/features/auth/authSlice'

// omit types and setup

const postsSlice = createSlice({
name,
initialState,
reducers: {
/* omitted */
},
extraReducers: builder => {
builder
// switch to handle the thunk fulfilled action
.addCase(logout.fulfilled, state => {
// Clear out the list of posts whenever the user logs out
return initialState
})
// omit other cases
}
})

添加通知

¥Adding Notifications

如果没有弹出一些通知来告诉我们有人发送了消息、发表了评论或对我们的帖子做出了反应,任何社交媒体应用都是不完整的。

¥No social media app would be complete without some notifications popping up to tell us that someone has sent a message, left a comment, or reacted to one of our posts.

在真实的应用中,我们的应用客户端将与后端服务器持续通信,每次发生事件时服务器都会向客户端推送更新。由于这是一个小型示例应用,我们将通过添加一个按钮来模拟该过程,以实际从我们的假 API 中获取一些通知条目。我们也没有任何其他真实用户发送消息或对帖子做出反应,因此每次我们发出请求时,假 API 只会创建一些随机通知条目。(请记住,这里的目标是了解如何使用 Redux 本身。)

¥In a real application, our app client would be in constant communication with the backend server, and the server would push an update to the client every time something happens. Since this is a small example app, we're going to mimic that process by adding a button to actually fetch some notification entries from our fake API. We also don't have any other real users sending messages or reacting to posts, so the fake API will just create some random notification entries every time we make a request. (Remember, the goal here is to see how to use Redux itself.)

通知切片

¥Notifications Slice

由于这是我们应用的新部分,因此第一步是为我们的通知创建一个新切片,并创建一个异步 thunk 以从 API 获取一些通知条目。为了创建一些真实的通知,我们将包含状态中最新通知的时间戳。这将使我们的模拟服务器生成比该时间戳更新的通知。

¥Since this is a new part of our app, the first step is to create a new slice for our notifications, and an async thunk to fetch some notification entries from the API. In order to create some realistic notifications, we'll include the timestamp of the latest notification we have in state. That will let our mock server generate notifications newer than that timestamp.

features/notifications/notificationsSlice.ts
import { createSlice } from '@reduxjs/toolkit'

import { client } from '@/api/client'

import type { RootState } from '@/app/store'
import { createAppAsyncThunk } from '@/app/withTypes'

export interface ServerNotification {
id: string
date: string
message: string
user: string
}

export const fetchNotifications = createAppAsyncThunk(
'notifications/fetchNotifications',
async (_unused, thunkApi) => {
const allNotifications = selectAllNotifications(thunkApi.getState())
const [latestNotification] = allNotifications
const latestTimestamp = latestNotification ? latestNotification.date : ''
const response = await client.get<ServerNotification[]>(
`/fakeApi/notifications?since=${latestTimestamp}`
)
return response.data
}
)

const initialState: ServerNotification[] = []

const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.push(...action.payload)
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})

export default notificationsSlice.reducer

export const selectAllNotifications = (state: RootState) => state.notifications

与其他切片一样,然后我们将 notificationsReducer 导入 store.ts 并将其添加到 configureStore() 调用中。

¥As with the other slices, we then import notificationsReducer into store.ts and add it to the configureStore() call.

我们编写了一个名为 fetchNotifications 的异步 thunk,它将从服务器检索新通知的列表。作为其中的一部分,我们希望使用最新通知的创建时间戳作为请求的一部分,以便服务器知道它应该只发回实际上是新的通知。

¥We've written an async thunk called fetchNotifications, which will retrieve a list of new notifications from the server. As part of that, we want to use the creation timestamp of the most recent notification as part of our request, so that the server knows it should only send back notifications that are actually new.

我们知道我们将返回一组通知,因此我们可以将它们作为单独的参数传递给 state.push(),并且该数组将添加每个项目。我们还希望确保它们已排序,以便最新的通知位于数组中的第一个,以防服务器乱序发送它们。(提醒一下,array.sort() 总是会改变现有的数组 - 这只是安全的,因为我们在里面使用 createSlice 和 Immer。)

¥We know that we will be getting back an array of notifications, so we can pass them as separate arguments to state.push(), and the array will add each item. We also want to make sure that they're sorted so that the most recent notification is first in the array, just in case the server were to send them out of order. (As a reminder, array.sort() always mutates the existing array - this is only safe because we're using createSlice and Immer inside.)

块参数

¥Thunk Arguments

如果你看看我们的 fetchNotifications thunk,你会发现它有一些我们以前从未见过的新东西。让我们讨论一下 thunk 参数。

¥If you look at our fetchNotifications thunk, it has something new that we haven't seen before. Let's talk about thunk arguments for a minute.

我们已经看到,当我们调度它时,我们可以将参数传递给 thunk 动作创建者,例如 dispatch(addPost(newPost))。特别是对于 createAsyncThunk,你只能传入一个参数,并且我们传入的任何内容都将成为有效负载创建回调的第一个参数。如果我们实际上没有传入任何内容,那么该参数将变为 undefined

¥We've already seen that we can pass an argument into a thunk action creator when we dispatch it, like dispatch(addPost(newPost)). For createAsyncThunk specifically, you can only pass in one argument, and whatever we pass in becomes the first argument of the payload creation callback. If we don't actually pass anything in, then that argument becomes undefined.

我们的有效负载创建者的第二个参数是一个 thunkAPI 对象,其中包含几个有用的函数和信息:

¥The second argument to our payload creator is a thunkAPI object containing several useful functions and pieces of information:

  • dispatchgetState:来自我们 Redux 存储的实际 dispatchgetState 方法。你可以在 thunk 中使用这些来分派更多操作,或获取最新的 Redux 存储状态(例如在分派另一个操作后读取更新的值)。

    ¥dispatch and getState: the actual dispatch and getState methods from our Redux store. You can use these inside the thunk to dispatch more actions, or get the latest Redux store state (such as reading an updated value after another action is dispatched).

  • extra:创建 store 时可以传递到 thunk 中间件的 "额外的参数"。这通常是某种 API 封装器,例如一组知道如何对应用服务器进行 API 调用并返回数据的函数,这样你的 thunk 就不必直接在其中包含所有 URL 和查询逻辑。

    ¥extra: the "extra argument" that can be passed into the thunk middleware when creating the store. This is typically some kind of API wrapper, such as a set of functions that know how to make API calls to your application's server and return data, so that your thunks don't have to have all the URLs and query logic directly inside.

  • requestId:此 thunk 调用的唯一随机 ID 值。对于跟踪单个请求的状态很有用。

    ¥requestId: a unique random ID value for this thunk call. Useful for tracking status of an individual request.

  • signal:可用于取消正在进行的请求的 AbortController.signal 函数。

    ¥signal: An AbortController.signal function that can be used to cancel an in-progress request.

  • rejectWithValue:一个实用程序,可在 thunk 收到错误时帮助自定义 rejected 操作的内容。

    ¥rejectWithValue: a utility that helps customize the contents of a rejected action if the thunk receives an error.

(如果你手动编写 thunk 而不是使用 createAsyncThunk,则 thunk 函数将 get(dispatch, getState) 作为单独的参数,而不是将它们放在一个对象中。)

¥(If you're writing a thunk by hand instead of using createAsyncThunk, the thunk function will get(dispatch, getState) as separate arguments, instead of putting them together in one object.)

信息

有关这些参数以及如何处理取消 thunk 和请求的更多详细信息,请参阅 createAsyncThunk API 参考页面

¥For more details on these arguments and how to handle canceling thunks and requests, see the createAsyncThunk API reference page.

在这种情况下,我们需要访问 thunkApi 参数,它始终是第二个参数。这意味着我们需要为第一个参数提供一些变量名,即使我们在调度 thunk 时没有传入任何东西,也不需要在有效负载回调中使用它。因此,我们只需给它一个名字 _unused

¥In this case, we need access to the thunkApi argument, which is always the second argument. That means we need to provide some variable name for the first argument, even though we don't pass anything in when we dispatch the thunk, and we don't need to use it inside the payload callback. So, we'll just give it a name of _unused.

从那里,我们知道通知列表处于我们的 Redux 存储状态,并且最新通知应该位于数组中的第一个。我们可以调用 thunkApi.getState() 来读取状态值,并使用 selectAllNotifications 选择器为我们提供通知数组。由于通知数组是最新排序的,我们可以使用数组解构来获取最新的通知。

¥From there, we know that the list of notifications is in our Redux store state, and that the latest notification should be first in the array. We can call thunkApi.getState() to read the state value, and use the selectAllNotifications selector to give us just the array of notifications. Since the array of notifications is sorted newest first, we can grab the latest one using array destructuring.

添加通知列表

¥Adding the Notifications List

现在我们已经创建了 notificationsSlice,我们可以添加一个 <NotificationsList> 组件。它需要从存储读取通知列表并对其进行格式化,包括显示每个通知的最近程度以及发送者。我们已经有了可以进行这种格式化的 <PostAuthor><TimeAgo> 组件,因此我们可以在这里重用它们。也就是说,<PostAuthor> 包含一个 "by" 前缀,这在这里没有意义 - 我们将对其进行修改以添加默认为 trueshowPrefix 属性,并且特别不在此处显示前缀。

¥Now that we've got the notificationsSlice created, we can add a <NotificationsList> component. It needs to read the list of notifications from the store and format them, including showing how recent each notification was, and who sent it. We already have the <PostAuthor> and <TimeAgo> components that can do that formatting, so we can reuse them here. That said, <PostAuthor> includes a "by " prefix which doesn't make sense here - we'll modify it to add a showPrefix prop that defaults to true, and specifically not show prefixes here.

features/posts/PostAuthor.tsx
interface PostAuthorProps {
userId: string
showPrefix?: boolean
}

export const PostAuthor = ({ userId, showPrefix = true }: PostAuthorProps) => {
const author = useAppSelector(state => selectUserById(state, userId))

return (
<span>
{showPrefix ? 'by ' : null}
{author?.name ?? 'Unknown author'}
</span>
)
}
features/notifications/NotificationsList.tsx
import { useAppSelector } from '@/app/hooks'

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

import { PostAuthor } from '@/features/posts/PostAuthor'

import { selectAllNotifications } from './notificationsSlice'

export const NotificationsList = () => {
const notifications = useAppSelector(selectAllNotifications)

const renderedNotifications = notifications.map(notification => {
return (
<div key={notification.id} className="notification">
<div>
<b>
<PostAuthor userId={notification.user} showPrefix={false} />
</b>{' '}
{notification.message}
</div>
<TimeAgo timestamp={notification.date} />
</div>
)
})

return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}

我们还需要更新 <Navbar> 以添加 "通知" 选项卡和一个新按钮来获取一些通知:

¥We also need to update the <Navbar> to add a "Notifications" tab, and a new button to fetch some notifications:

app/Navbar.tsx
// omit several imports

import { logout } from '@/features/auth/authSlice'
import { fetchNotifications } from '@/features/notifications/notificationsSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'

export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)

const isLoggedIn = !!user

let navContent: React.ReactNode = null

if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}

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

navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">Notifications</Link>
<button className="button small" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
{/* omit user details */}
</div>
)
}

// omit other rendering
}

最后,我们需要使用 "通知" 路由更新 App.ts,以便我们可以导航到它:

¥Lastly, we need to update App.ts with the "Notifications" route so we can navigate to it:

App.tsx
// omit imports
import { NotificationsList } from './features/notifications/NotificationsList'

function App() {
return (
// omit all the outer router setup
<Routes>
<Route path="/posts" element={<PostsMainPage />} />
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
<Route path="/users" element={<UsersList />} />
<Route path="/users/:userId" element={<UserPage />} />
<Route path="/notifications" element={<NotificationsList />} />
</Routes>
)
}

到目前为止,"通知" 选项卡的外观如下:

¥Here's what the "Notifications" tab looks like so far:

Initial Notifications tab

显示新通知

¥Showing New Notifications

每次我们单击 "刷新通知" 时,都会将更多通知条目添加到我们的列表中。在真实的应用中,当我们查看 UI 的其他部分时,这些可能来自服务器。我们可以通过在查看 <PostsList><UserPage> 时单击 "刷新通知" 来执行类似的操作。

¥Each time we click "Refresh Notifications", a few more notification entries will be added to our list. In a real app, those could be coming from the server while we're looking at other parts of the UI. We can do something similar by clicking "Refresh Notifications" while we're looking at the <PostsList> or <UserPage>.

但是,现在我们不知道有多少通知刚刚到达,如果我们继续单击按钮,可能会有许多通知我们尚未阅读。让我们添加一些逻辑来跟踪哪些通知已被读取以及哪些是 "new"。这将使我们能够在导航栏中的 "通知" 选项卡上将 "未读" 通知的计数显示为徽章,并以不同的颜色显示新通知。

¥But, right now we have no idea how many notifications just arrived, and if we keep clicking the button, there could be many notifications we haven't read yet. Let's add some logic to keep track of which notifications have been read and which of them are "new". That will let us show the count of "Unread" notifications as a badge on our "Notifications" tab in the navbar, and display new notifications in a different color.

跟踪通知状态

¥Tracking Notification Status

我们的伪造 API 发回的 Notification 对象看起来像 {id, date, message, user}。"new" 或 "unread" 的想法只存在于客户端。鉴于此,让我们重新设计 notificationsSlice 以支持这一点。

¥The Notification objects that our fake API is sending back look like {id, date, message, user}. The idea of "new" or "unread" will only exist on the client. Given that, let's rework the notificationsSlice to support that.

首先,我们将创建一个扩展 ServerNotification 的新 ClientNotification 类型以添加​​这两个字段。然后,当我们从服务器收到一批新通知时,我们总是会添加具有默认值的字段。

¥First, we'll create a new ClientNotification type that extends ServerNotification to add those two fields. Then, when we receive a new batch of notifications from the server, we'll always add those fields with default values.

接下来,我们将添加一个将所有通知标记为已读的 Reducer,以及一些处理将现有通知标记为 "不是新的" 的逻辑。

¥Next, we'll add a reducer that marks all notifications as read, and some logic to handle marking existing notifications as "not new".

最后,我们还可以添加一个选择器来计算存储中有多少未读通知:

¥Finally, we can also add a selector that counts how many unread notifications are in the store:

features/notifications/notificationsSlice.ts
// omit imports

export interface ServerNotification {
id: string
date: string
message: string
user: string
}

export interface ClientNotification extends ServerNotification {
read: boolean
isNew: boolean
}

// omit thunk

const initialState: ClientNotification[] = []

const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
state.forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsWithMetadata: ClientNotification[] =
action.payload.map(notification => ({
...notification,
read: false,
isNew: true
}))

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

state.push(...notificationsWithMetadata)
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}

将通知标记为已读

¥Marking Notifications as Read

我们希望在 <NotificationsList> 组件渲染时将这些通知标记为已读,要么是因为我们单击了选项卡来查看通知,要么是因为我们已经打开了它并且刚刚收到了一些附加通知。我们可以通过在该组件重新渲染时调度 allNotificationsRead 来实现此目的。为了避免更新时旧数据闪烁,我们将在 useLayoutEffect 钩子中分派该操作。我们还想向页面中的任何通知列表条目添加一个额外的类名,以高亮它们:

¥We want to mark these notifications as read whenever our <NotificationsList> component renders, either because we clicked on the tab to view the notifications, or because we already have it open and we just received some additional notifications. We can do this by dispatching allNotificationsRead any time this component re-renders. In order to avoid flashing of old data as this updates, we'll dispatch the action in a useLayoutEffect hook. We also want to add an additional classname to any notification list entries in the page, to highlight them:

features/notifications/NotificationsList.tsx
import { useLayoutEffect } from 'react'
import classnames from 'classnames'
import { useAppSelector, useAppDispatch } from '@/app/hooks'

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

import { PostAuthor } from '@/features/posts/PostAuthor'

import {
allNotificationsRead,
selectAllNotifications
} from './notificationsSlice'

export const NotificationsList = () => {
const dispatch = useAppDispatch()
const notifications = useAppSelector(selectAllNotifications)

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

const renderedNotifications = notifications.map(notification => {
const notificationClassname = classnames('notification', {
new: notification.isNew
})

return (
<div key={notification.id} className={notificationClassname}>
<div>
<b>
<PostAuthor userId={notification.user} showPrefix={false} />
</b>{' '}
{notification.message}
</div>
<TimeAgo timestamp={notification.date} />
</div>
)
})

return (
<section className="notificationsList">
<h2>Notifications</h2>
{renderedNotifications}
</section>
)
}

这可行,但实际上有一些令人惊讶的行为。每当有新通知时(无论是因为我们刚刚切换到此选项卡,还是因为我们从 API 获取了一些新通知),你实际上都会看到调度了两个 "notifications/allNotificationsRead" 操作。这是为什么?

¥This works, but actually has a slightly surprising bit of behavior. Any time there are new notifications (either because we've just switched to this tab, or we've fetched some new notifications from the API), you'll actually see two "notifications/allNotificationsRead" actions dispatched. Why is that?

假设我们在查看 <PostsList> 时获取了一些通知,然后单击 "通知" 选项卡。<NotificationsList> 组件将挂载,并且 useLayoutEffect 回调将在第一次渲染和分派 allNotificationsRead 之后运行。我们的 notificationsSlice 将通过更新存储中的通知条目来处理该问题。这将创建一个包含不可变更新条目的新 state.notifications 数组,这会强制我们的组件再次渲染,因为它会看到从 useSelector 返回的新数组。

¥Let's say we have fetched some notifications while looking at the <PostsList>, and then click the "Notifications" tab. The <NotificationsList> component will mount, and the useLayoutEffect callback will run after that first render and dispatch allNotificationsRead. Our notificationsSlice will handle that by updating the notification entries in the store. This creates a new state.notifications array containing the immutably-updated entries, which forces our component to render again because it sees a new array returned from the useSelector.

当组件第二次渲染时,useLayoutEffect 钩子再次运行并再次分派 allNotificationsRead。Reducer 也会再次运行,但这次没有数据发生变化,因此切片状态和根状态保持不变,并且组件不会重新渲染。

¥When the component renders the second time, useLayoutEffect hook runs again and dispatches allNotificationsReadagain. The reducer runs again too, but this time no data changes, so the slice state and root state remain the same, and the component doesn't re-render.

有几种方法可以避免第二次调度,例如拆分逻辑以在组件安装时调度一次,并且仅在通知数组的大小发生变化时再次调度。但是,这实际上并没有伤害任何东西,所以我们可以不管它。

¥There's a couple ways we could potentially avoid that second dispatch, like splitting the logic to dispatch once when the component mounts, and only dispatch again if the size of the notifications array changes. But, this isn't actually hurting anything, so we can leave it alone.

这实际上表明可以分派一个操作而不发生任何状态更改。请记住,始终由你的 reducer 决定是否确实需要更新任何状态,并且 "什么都不需要发生" 是 reducer 做出的有效决定。

¥This does actually show that it's possible to dispatch an action and not have any state changes happen at all. Remember, it's always up to your reducers to decide if any state actually needs to be updated, and "nothing needs to happen" is a valid decision for a reducer to make.

现在我们已经让 "新/已读" 行为发挥作用,通知选项卡的外观如下:

¥Here's how the notifications tab looks now that we've got the "new/read" behavior working:

New notifications

显示未读通知

¥Showing Unread Notifications

在继续之前我们需要做的最后一件事是在导航栏中的 "通知" 选项卡上添加徽章。当我们在其他选项卡中时,这将向我们显示 "未读" 通知的计数:

¥The last thing we need to do before we move on is to add the badge on our "Notifications" tab in the navbar. This will show us the count of "Unread" notifications when we are in other tabs:

app/Navbar.tsx
// omit other imports

import {
fetchNotifications,
selectUnreadNotificationsCount
} from '@/features/notifications/notificationsSlice'

export const Navbar = () => {
const dispatch = useAppDispatch()
const username = useAppSelector(selectCurrentUsername)
const user = useAppSelector(selectCurrentUser)

const numUnreadNotifications = useAppSelector(selectUnreadNotificationsCount)


const isLoggedIn = !!user

let navContent: React.ReactNode = null

if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(logout())
}

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

let unreadNotificationsBadge: React.ReactNode | undefined

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

navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">
Notifications {unreadNotificationsBadge}
</Link>
<button className="button small" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
{/* omit button */}
</div>
)
}

// omit other rendering
}

提高渲染性能

¥Improving Render Performance

我们的应用看起来很有用,但实际上我们在组件重新渲染的时间和方式方面存在一些缺陷。让我们看看这些问题,并讨论一些提高性能的方法。

¥Our application is looking useful, but we've actually got a couple flaws in when and how our components re-render. Let's look at those problems, and talk about some ways to improve the performance.

研究渲染行为

¥Investigating Render Behavior

我们可以使用 React DevTools Profiler 来查看状态更新时哪些组件重新渲染的一些图表。尝试单击单个用户的 <UserPage>。打开浏览器的 DevTools,然后在 React "分析器" 选项卡中,单击左上角的圆圈 "记录" 按钮。然后,单击应用中的 "刷新通知" 按钮,并停止 React DevTools Profiler 中的记录。你应该看到如下所示的图表:

¥We can use the React DevTools Profiler to view some graphs of what components re-render when state is updated. Try clicking over to the <UserPage> for a single user. Open up your browser's DevTools, and in the React "Profiler" tab, click the circle "Record" button in the upper-left. Then, click the "Refresh Notifications" button in our app, and stop the recording in the React DevTools Profiler. You should see a chart that looks like this:

React DevTools Profiler render capture - &lt;UserPage&gt;

我们可以看到 <Navbar> 重新渲染,这是有道理的,因为它必须在选项卡中显示更新的 "未读通知" 徽章。但是,为什么我们的 <UserPage> 会重新渲染呢?

¥We can see that the <Navbar> re-rendered, which makes sense because it had to show the updated "unread notifications" badge in the tab. But, why did our <UserPage> re-render?

如果我们检查 Redux DevTools 中最后几个调度的操作,我们可以看到只有通知状态已更新。由于 <UserPage> 不读取任何通知,因此它不应该重新渲染。组件或其使用的选择器之一一定出了问题。

¥If we inspect the last couple dispatched actions in the Redux DevTools, we can see that only the notifications state updated. Since the <UserPage> doesn't read any notifications, it shouldn't have re-rendered. Something must be wrong with the component or one of the selectors it's using.

<UserPage> 正在通过 selectPostsByUser 从存储读取帖子列表。如果我们仔细观察 selectPostsByUser,就会发现一个具体问题:

¥<UserPage> is reading the list of posts from the store via selectPostsByUser. If we look at selectPostsByUser carefully, there's a specific problem:

features/posts/postsSlice.ts
export const selectPostsByUser = (state: RootState, userId: string) => {
const allPosts = selectAllPosts(state)
// ❌ WRONG - this _always_ creates a new array reference!
return allPosts.filter(post => post.user === userId)
}

我们知道每次分派动作时 useSelector 都会重新运行,并且如果我们返回新的引用值,它会强制组件重新渲染。

¥We know that useSelector will re-run every time an action is dispatched, and that it forces the component to re-render if we return a new reference value.

我们在选择器函数内部调用 filter(),以便我们仅返回属于此用户的帖子列表。

¥We're calling filter() inside of a selector function, so that we only return the list of posts that belong to this user.

不幸的是,这意味着 useSelector 始终为该选择器返回一个新的数组引用,因此即使帖子数据没有改变,我们的组件也会在每次操作后重新渲染!。

¥Unfortunately, this means that useSelector always returns a new array reference for this selector, and so our component will re-render after every action even if the posts data hasn't changed!.

这是 Redux 应用中常见的错误。因此,React-Redux 实际上会在开发模式下检查选择器是否意外地总是返回新的引用。如果你打开浏览器的 DevTools 并转到控制台,你应该会看到一条警告,内容为:

¥This is a common mistake in Redux applications. Because of that, React-Redux actually does checks in development mode for selectors that accidentally always return new references. If you open up your browser devtools and go to the console, you should see a warning that says:

Selector unknown returned a different result when called with the same parameters.
This can lead to unnecessary rerenders.
Selectors that return a new reference (such as an object or an array) should be memoized:
at UserPage (http://localhost:5173/src/features/users/UserPage.tsx)

在大多数情况下,错误会告诉我们选择器的实际变量名称。在这种情况下,错误消息没有选择器的特定名称,因为我们实际上在 useAppSelector 内部使用了一个匿名函数。但是,知道它在 <UserPage> 中会为我们缩小范围。

¥In most cases, the error would tell us the actual variable name of the selector. In this case, the error message doesn't have a specific name for the selector, because we're actually using an anonymous function inside of useAppSelector. But, knowing it's in <UserPage> narrows it down for us.

现在,实际上这不是这个特定示例应用中有意义的性能问题。<UserPage> 组件很小,应用中没有太多动作被分派。然而,在实际应用中,这可能是一个非常重大的性能问题,其影响因应用结构而异。鉴于此,额外的组件在不需要时重新渲染是一个常见的性能问题,我们应该尝试修复它。

¥Now, realistically this isn't a meaningful perf issue in this particular example app. The <UserPage> component is small, and there's not many actions being dispatched in the app. However, this can be a very major perf issue in real-world apps, with the impact varying based on app structure. Given that, extra components re-rendering when they didn't need to is a common perf issue and something we should try to fix.

记忆选择器功能

¥Memoizing Selector Functions

我们真正需要的是一种仅在 state.postsuserId 发生更改时才计算新的过滤数组的方法。如果它们没有改变,我们希望返回与上次相同的过滤数组引用。

¥What we really need is a way to only calculate the new filtered array if either state.posts or userId have changed. If they haven't changed, we want to return the same filtered array reference as the last time.

这个想法被称为 "memoization"。我们想要保存之前的一组输入和计算结果,如果输入相同,则返回之前的结果,而不是再次重新计算。

¥This idea is called "memoization". We want to save a previous set of inputs and the calculated result, and if the inputs are the same, return the previous result instead of recalculating it again.

到目前为止,我们一直在自己编写选择器作为普通函数,并且主要使用它们,这样我们就不必复制和粘贴从存储中读取数据的代码。如果有一种方法可以使我们的选择器函数记忆化,以便我们能够提高性能,那就太好了。

¥So far, we've been writing selectors by ourselves as plain functions, and mostly using them so that we don't have to copy and paste the code for reading data from the store. It would be great if there was a way to make our selector functions memoized so that we could improve performance.

重新选择 是一个用于创建记忆选择器函数的库,专门设计用于与 Redux 一起使用。它有一个 createSelector 函数,可以生成记忆选择器,仅在输入更改时重新计算结果。Redux Toolkit 导出 createSelector 函数,所以我们已经可以使用它了。

¥Reselect is a library for creating memoized selector functions, and was specifically designed to be used with Redux. It has a createSelector function that generates memoized selectors that will only recalculate results when the inputs change. Redux Toolkit exports the createSelector function, so we already have it available.

让我们用 createSelectorselectPostsByUser 重写为记忆函数:

¥Let's rewrite selectPostsByUser to be a memoized function with createSelector:

features/posts/postsSlice.ts
import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit'

// omit slice logic

export const selectAllPosts = (state: RootState) => state.posts.posts

export const selectPostById = (state: RootState, postId: string) =>
state.posts.posts.find(post => post.id === postId)

export const selectPostsByUser = createSelector(
// Pass in one or more "input selectors"
[
// we can pass in an existing selector function that
// reads something from the root `state` and returns it
selectAllPosts,
// and another function that extracts one of the arguments
// and passes that onward
(state: RootState, userId: string) => userId
],
// the output function gets those values as its arguments,
// and will run when either input value changes
(posts, userId) => posts.filter(post => post.user === userId)
)

createSelector 首先需要一个或多个 "输入选择器" 函数(要么一起放在一个数组内,要么作为单独的参数)。你还需要传入一个 "输出函数",它会计算结果。

¥createSelector first needs one or more "input selector" functions (either together inside of a single array, or as separate arguments). You also need to pass in an "output function", which calculates the result.

当我们调用 selectPostsByUser(state, userId) 时,createSelector 会将所有参数传递到每个输入选择器中。无论这些输入选择器返回什么,都会成为输出选择器的参数。(我们已经在 selectCurrentUser 中做了类似的事情,我们首先调用 const currentUsername = selectCurrentUsername(state)。)

¥When we call selectPostsByUser(state, userId), createSelector will pass all of the arguments into each of our input selectors. Whatever those input selectors return becomes the arguments for the output selector. (We've already done something similar in selectCurrentUser, where we first call const currentUsername = selectCurrentUsername(state).)

在本例中,我们知道需要所有帖子的数组和用户 ID 作为输出选择器的两个参数。我们可以重用现有的 selectAllPosts 选择器来提取 posts 数组。由于用户 ID 是我们传递给 selectPostsByUser 的第二个参数,因此我们可以编写一个仅返回 userId 的小选择器。

¥In this case, we know that we need the array of all posts and the user ID as the two arguments for our output selector. We can reuse our existing selectAllPosts selector to extract the posts array. Since the user ID is the second argument we're passing into selectPostsByUser, we can write a small selector that just returns userId.

然后,我们的输出函数将获取 postsuserId 作为其参数,并返回仅针对该用户的筛选后的帖子数组。

¥Our output function then gets posts and userId as its arguments, and returns the filtered array of posts for just that user.

如果我们尝试多次调用 selectPostsByUser,只有当 postsuserId 发生更改时,它才会重新运行输出选择器:

¥If we try calling selectPostsByUser multiple times, it will only re-run the output selector if either posts or userId has changed:

const state1 = getState()
// Output selector runs, because it's the first call
selectPostsByUser(state1, 'user1')
// Output selector does _not_ run, because the arguments haven't changed
selectPostsByUser(state1, 'user1')
// Output selector runs, because `userId` changed
selectPostsByUser(state1, 'user2')

dispatch(reactionAdded())
const state2 = getState()
// Output selector does not run, because `posts` and `userId` are the same
selectPostsByUser(state2, 'user2')

// Add some more posts
dispatch(addNewPost())
const state3 = getState()
// Output selector runs, because `posts` has changed
selectPostsByUser(state3, 'user2')

现在我们已经记住了 selectPostsByUser,我们可以尝试在打开 <UserPage> 的情况下重复 React 分析器,同时获取通知。这次我们应该看到 <UserPage> 不会重新渲染:

¥Now that we've memoized selectPostsByUser, we can try repeating the React profiler with <UserPage> open while fetching notifications. This time we should see that <UserPage> doesn't re-render:

React DevTools Profiler optimized render capture - &lt;UserPage&gt;

平衡选择器使用

¥Balancing Selector Usage

记忆选择器是提高 React+Redux 应用性能的宝贵工具,因为它们可以帮助我们避免不必要的重新渲染,并且在输入数据未更改的情况下避免进行潜在的复杂或昂贵的计算。

¥Memoized selectors are a valuable tool for improving performance in a React+Redux application, because they can help us avoid unnecessary re-renders, and also avoid doing potentially complex or expensive calculations if the input data hasn't changed.

请注意,并非应用中的所有选择器都需要记忆!我们编写的其余选择器仍然只是普通函数,它们工作正常。只有当选择器创建并返回新对象或数组引用,或者计算逻辑是 "expensive" 时,才需要记忆。

¥Note that not all selectors in an application need to be memoized! The rest of the selectors we've written are still just plain functions, and those work fine. Selectors only need to be memoized if they create and return new object or array references, or if the calculation logic is "expensive".

例如,让我们回顾一下 selectUnreadNotificationsCount

¥As an example, let's look back at selectUnreadNotificationsCount:

export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}

此选择器是一个在内部执行 .filter() 调用的普通函数。但是,请注意,它没有返回新的数组引用。相反,它只是返回一个数字。这样更安全 - 即使我们更新通知数组,实际返回值也不会一直变化。

¥This selector is a plain function that's doing a .filter() call inside. However, notice that it's not returning that new array reference. Instead, it's just returning a number. That's safer - even if we update the notifications array, the actual return value isn't going to be changing all the time.

现在,每次运行此选择器时重新过滤通知数组有点浪费。将其转换为记忆化选择器也是合理的,这可能会节省一些 CPU 周期。但是,如果选择器实际上每次都返回一个新引用,那么它就没有必要了。

¥Now, re-filtering the notifications array every time this selector runs is a bit wasteful. It would be reasonable to also convert this to a memoized selector, and that might save a few CPU cycles. But, it's not as necessary as it would be if the selector was actually returning a new reference each time.

信息

有关我们为什么使用选择器函数以及如何使用 Reselect 编写记忆选择器的更多详细信息,请参阅:

¥For more details on why we use selector functions and how to write memoized selectors with Reselect, see:

调查帖子列表

¥Investigating the Posts List

如果我们回到 <PostsList> 并尝试在捕获 React Profiler 跟踪时单击其中一篇文章上的反应按钮,我们将看到不仅 <PostsList> 和更新的 <PostExcerpt> 实例渲染,所有 <PostExcerpt> 组件也渲染:

¥If we go back to our <PostsList> and try clicking a reaction button on one of the posts while capturing a React profiler trace, we'll see that not only did the <PostsList> and the updated <PostExcerpt> instance render, all of the <PostExcerpt> components rendered:

React DevTools Profiler render capture - &lt;PostsList&gt;

这是为什么?其他帖子都没有改变,那么为什么他们需要重新渲染呢?

¥Why is that? None of the other posts changed, so why would they need to re-render?

React 的默认行为是,当父组件渲染时,React 将递归渲染其中的所有子组件!。一个帖子对象的不可变更新还创建了一个新的 posts 数组。我们的 <PostsList> 必须重新渲染,因为 posts 数组是一个新的引用,所以渲染后,React 继续向下并重新渲染所有 <PostExcerpt> 组件。

¥React's default behavior is that when a parent component renders, React will recursively render all child components inside of it!. The immutable update of one post object also created a new posts array. Our <PostsList> had to re-render because the posts array was a new reference, so after it rendered, React continued downwards and re-rendered all of the <PostExcerpt> components too.

对于我们的小型示例应用来说,这不是一个严重的问题,但在较大的实际应用中,我们可能有一些非常长的列表或非常大的组件树,并且重新渲染所有这些额外的组件可能会减慢速度。

¥This isn't a serious problem for our small example app, but in a larger real-world app, we might have some very long lists or very large component trees, and having all those extra components re-render might slow things down.

优化列表渲染的选项

¥Options for Optimizing List Rendering

我们可以通过几种不同的方式来优化 <PostsList> 中的这种行为。

¥There's a few different ways we could optimize this behavior in <PostsList>.

首先,我们可以将 <PostExcerpt> 组件封装在 React.memo() 中,这将确保其中的组件仅在 props 实际更改时才重新渲染。这实际上会很好地工作 - 尝试一下,看看会发生什么:

¥First, we could wrap the <PostExcerpt> component in React.memo(), which will ensure that the component inside of it only re-renders if the props have actually changed. This will actually work quite well - try it out and see what happens:

features/posts/PostsList.tsx
let PostExcerpt = ({ post }: PostExcerptProps) => {
// omit logic
}

PostExcerpt = React.memo(PostExcerpt)

另一种选择是重写 <PostsList>,以便它只从存储中选择帖子 ID 列表,而不是整个 posts 数组,并重写 <PostExcerpt>,以便它接收 postId 属性并调用 useSelector 来读取它需要的帖子对象。如果 <PostsList> 获得与之前相同的 ID 列表,则不需要重新渲染,因此只需要渲染我们更改的一个 <PostExcerpt> 组件。

¥Another option is to rewrite <PostsList> so that it only selects a list of post IDs from the store instead of the entire posts array, and rewrite <PostExcerpt> so that it receives a postId prop and calls useSelector to read the post object it needs. If <PostsList> gets the same list of IDs as before, it won't need to re-render, and so only our one changed <PostExcerpt> component should have to render.

不幸的是,这变得很棘手,因为我们还需要将所有帖子按日期排序并以正确的顺序渲染。我们可以更新 postsSlice 以始终保持数组排序,这样我们就不必在组件中对其进行排序,并使用记忆选择器仅提取帖子 ID 列表。我们也可以像 useSelector(selectPostIds, shallowEqual) 一样使用 自定义 useSelector 运行的比较函数来检查结果,这样如果 ID 数组的内容没有改变,就会跳过重新渲染。

¥Unfortunately, this gets tricky because we also need to have all our posts sorted by date and rendered in the right order. We could update our postsSlice to keep the array sorted at all times, so we don't have to sort it in the component, and use a memoized selector to extract just the list of post IDs. We could also customize the comparison function that useSelector runs to check the results, like useSelector(selectPostIds, shallowEqual), so that will skip re-rendering if the contents of the IDs array haven't changed.

最后一个选项是找到某种方法让我们的 reducer 为所有帖子保留一个单独的 ID 数组,并且仅在添加或删除帖子时修改该数组,并对 <PostsList><PostExcerpt> 进行相同的重写。这样,<PostsList> 只需要在 ID 数组发生变化时重新渲染。

¥The last option is to find some way to have our reducer keep a separate array of IDs for all the posts, and only modify that array when posts are added or removed, and do the same rewrite of <PostsList> and <PostExcerpt>. This way, <PostsList> only needs to re-render when that IDs array changes.

方便的是,Redux Toolkit 有一个 createEntityAdapter 函数可以帮助我们做到这一点。

¥Conveniently, Redux Toolkit has a createEntityAdapter function that will help us do just that.

标准化数据

¥Normalizing Data

你已经看到,我们的很多逻辑都是通过 ID 字段查找项目。由于我们一直将数据存储在数组中,这意味着我们必须使用 array.find() 循环数组中的所有项目,直到找到具有我们要查找的 ID 的项目。

¥You've seen that a lot of our logic has been looking up items by their ID field. Since we've been storing our data in arrays, that means we have to loop over all the items in the array using array.find() until we find the item with the ID we're looking for.

实际上,这不会花费很长时间,但如果我们的数组内部包含数百或数千个项目,则遍历整个数组来查找一项就变得浪费精力。我们需要的是一种根据 ID 直接查找单个项目的方法,而无需检查所有其他项目。这个过程被称为 "normalization"。

¥Realistically, this doesn't take very long, but if we had arrays with hundreds or thousands of items inside, looking through the entire array to find one item becomes wasted effort. What we need is a way to look up a single item based on its ID, directly, without having to check all the other items. This process is known as "normalization".

规范化的状态结构

¥Normalized State Structure

"归一化状态" 意味着:

¥"Normalized state" means that:

  • 我们的状态每条特定数据只有一份副本,因此不存在重复

    ¥We only have one copy of each particular piece of data in our state, so there's no duplication

  • 已标准化的数据保存在查找表中,其中项目 ID 是键,项目本身是值。这通常只是一个简单的 JS 对象。

    ¥Data that has been normalized is kept in a lookup table, where the item IDs are the keys, and the items themselves are the values. This is typically just a plain JS object.

  • 还可能存在特定项目类型的所有 ID 的数组

    ¥There may also be an array of all of the IDs for a particular item type

JavaScript 对象可以用作查找表,类似于其他语言中的 "maps" 或 "dictionaries"。一组 user 对象的标准化状态可能如下所示:

¥JavaScript objects can be used as lookup tables, similar to "maps" or "dictionaries" in other languages. Here's what the normalized state for a group of user objects might look like:

{
users: {
ids: ["user1", "user2", "user3"],
entities: {
"user1": {id: "user1", firstName, lastName},
"user2": {id: "user2", firstName, lastName},
"user3": {id: "user3", firstName, lastName},
}
}
}

这样可以轻松地通过 ID 找到特定的 user 对象,而无需循环遍历数组中的所有其他用户对象:

¥This makes it easy to find a particular user object by its ID, without having to loop through all the other user objects in an array:

const userId = 'user2'
const userObject = state.users.entities[userId]
信息

有关规范化状态为何有用的更多详细信息,请参阅 规范化状态形状管理标准化数据 上的 Redux Toolkit 使用指南部分。

¥For more details on why normalizing state is useful, see Normalizing State Shape and the Redux Toolkit Usage Guide section on Managing Normalized Data.

使用 createEntityAdapter 管理规范化状态

¥Managing Normalized State with createEntityAdapter

Redux Toolkit 的 createEntityAdapter API 提供了一种标准化方法,通过获取项目集合并将它们放入 { ids: [], entities: {} } 的形状,将数据存储在切片中。除了这个预定义的状态形状之外,它还生成一组知道如何处理该数据的化简器函数和选择器。

¥Redux Toolkit's createEntityAdapter API provides a standardized way to store your data in a slice by taking a collection of items and putting them into the shape of { ids: [], entities: {} }. Along with this predefined state shape, it generates a set of reducer functions and selectors that know how to work with that data.

这样做有几个好处:

¥This has several benefits:

  • 我们不必自己编写代码来管理规范化

    ¥We don't have to write the code to manage the normalization ourselves

  • createEntityAdapter 的预构建 reducer 函数可处理 "添加所有这些项目"、"更新一项" 或 "删除多个项目" 等常见情况

    ¥createEntityAdapter's pre-built reducer functions handle common cases like "add all these items", "update one item", or "remove multiple items"

  • createEntityAdapter 可以选择根据项目的内容按排序顺序保存 ID 数组,并且仅在添加/删除项目或排序顺序发生变化时更新该数组。

    ¥createEntityAdapter can optionally keep the ID array in a sorted order based on the contents of the items, and will only update that array if items are added / removed or the sorting order changes.

createEntityAdapter 接受一个可能包含 sortComparer 函数的选项对象,该函数将用于通过比较两个项目来保持项目 ID 数组的排序顺序(其工作方式与 Array.sort() 相同)。

¥createEntityAdapter accepts an options object that may include a sortComparer function, which will be used to keep the item IDs array in sorted order by comparing two items (and works the same way as Array.sort()).

它返回一个包含 一组生成的 reducer 函数,用于从实体状态对象中添加、更新和删除项目 的对象。这些 reducer 函数既可以用作特定操作类型的 case reducer,也可以用作 createSlice 中另一个 reducer 中的 "mutating" 实用程序函数。

¥It returns an object that contains a set of generated reducer functions for adding, updating, and removing items from an entity state object. These reducer functions can either be used as a case reducer for a specific action type, or as a "mutating" utility function within another reducer in createSlice.

适配器对象还具有 getSelectors 函数。你可以传入一个选择器,该选择器从 Redux 根状态返回这个特定的状态片,它将生成像 selectAllselectById 这样的选择器。

¥The adapter object also has a getSelectors function. You can pass in a selector that returns this particular slice of state from the Redux root state, and it will generate selectors like selectAll and selectById.

最后,适配器对象有一个 getInitialState 函数,它生成一个空的 {ids: [], entities: {}} 对象。你可以向 getInitialState 传递更多字段,这些字段将被合并。

¥Finally, the adapter object has a getInitialState function that generates an empty {ids: [], entities: {}} object. You can pass in more fields to getInitialState, and those will be merged in.

规范化帖子切片

¥Normalizing the Posts Slice

考虑到这一点,让我们更新我们的 postsSlice 以使用 createEntityAdapter。我们需要进行一些更改。

¥With that in mind, let's update our postsSlice to use createEntityAdapter. We'll need to make several changes.

我们的 PostsState 结构将会改变。现在,它不再将 posts: Post[] 作为数组,而是包含 {ids: string[], entities: Record<string, Post>}。Redux Toolkit 已经有一个描述该 {ids, entities} 结构的 EntityState 类型,因此我们将导入它并将其用作 PostsState 的基础。我们仍然需要 statuserror 字段,因此我们将包括它们。

¥Our PostsState structure is going to change. Instead of having posts: Post[] as an array, it's now going to include {ids: string[], entities: Record<string, Post>}. Redux Toolkit already has an EntityState type that describes that {ids, entities} structure, so we'll import that and use it as the base for PostsState. We also still need the status and error fields too, so we'll include those.

我们需要导入 createEntityAdapter,创建一个应用了正确 Post 类型的实例,并知道如何以正确的顺序对帖子进行排序。

¥We're going to need to import createEntityAdapter, create an instance that has the right Post type applied, and knows how to sort posts in the right order.

features/posts/postsSlice.ts
import {
createEntityAdapter,
EntityState
// omit other imports
} from '@reduxjs/toolkit'

// omit thunks

interface PostsState extends EntityState<Post, string> {
status: 'idle' | 'pending' | 'succeeded' | 'rejected'
error: string | null
}

const postsAdapter = createEntityAdapter<Post>({
// Sort in descending date order
sortComparer: (a, b) => b.date.localeCompare(a.date)
})

const initialState: PostsState = postsAdapter.getInitialState({
status: 'idle',
error: null
})


// omit thunks

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload

const existingPost = state.entities[id]

if (existingPost) {
existingPost.title = title
existingPost.content = content
}
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
}
},
extraReducers(builder) {
builder
// omit other cases
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Save the fetched posts into state
postsAdapter.setAll(state, action.payload)
})
.addCase(addNewPost.fulfilled, postsAdapter.addOne)
}
})

export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions

export default postsSlice.reducer

// Export the customized selectors for this adapter using `getSelectors`
export const {
selectAll: selectAllPosts,
selectById: selectPostById,
selectIds: selectPostIds
// Pass in a selector that returns the posts slice of state
} = postsAdapter.getSelectors((state: RootState) => state.posts)

export const selectPostsByUser = createSelector(
[selectAllPosts, (state: RootState, userId: string) => userId],
(posts, userId) => posts.filter(post => post.user === userId)
)

那里发生了很多事情!让我们来分解一下。

¥There's a lot going on there! Let's break it down.

首先,我们导入 createEntityAdapter,并调用它来创建 postsAdapter 对象。我们知道我们希望保留一个包含所有帖子 ID 的数组,其中最新帖子首先排序,因此我们传入一个 sortComparer 函数,该函数将根据 post.date 字段将较新的项目排序到前面。

¥First, we import createEntityAdapter, and call it to create our postsAdapter object. We know that we want to keep an array of all post IDs sorted with the newest post first, so we pass in a sortComparer function that will sort newer items to the front based on the post.date field.

getInitialState() 返回一个空的 {ids: [], entities: {}} 标准化状态对象。我们的 postsSlice 也需要保留 statuserror 字段以用于加载状态,因此我们将它们传递给 getInitialState()

¥getInitialState() returns an empty {ids: [], entities: {}} normalized state object. Our postsSlice needs to keep the status and error fields for loading state too, so we pass those in to getInitialState().

现在我们的帖子在 state.entities 中被保存为查找表,我们可以更改我们的 reactionAddedpostUpdated reducer以通过 state.entities[postId] 直接通过它们的 ID 查找正确的帖子,而不必循环遍历旧的 posts 数组。

¥Now that our posts are being kept as a lookup table in state.entities, we can change our reactionAdded and postUpdated reducers to directly look up the right posts by their IDs via state.entities[postId], instead of having to loop over the old posts array.

当我们收到 fetchPosts.fulfilled 操作时,我们可以使用 postsAdapter.setAll 函数通过传入草稿 stateaction.payload 中的帖子数组来将所有传入帖子添加到状态中。这是在 createSlice Reducer 中使用适配器方法作为 "mutating" 辅助函数的示例。

¥When we receive the fetchPosts.fulfilled action, we can use the postsAdapter.setAll function to add all of the incoming posts to the state, by passing in the draft state and the array of posts in action.payload. This is an example of using the adapter methods as "mutating" helper functions inside of a createSlice reducer.

当我们收到 addNewPost.fulfilled 操作时,我们知道需要将一个新的帖子对象添加到我们的状态中。我们可以直接使用适配器函数作为 reducer,因此我们将传递 postsAdapter.addOne 作为 reducer 函数来处理该操作。在这种情况下,我们使用适配器方法作为此操作的实际reducer。

¥When we receive the addNewPost.fulfilled action, we know we need to add that one new post object to our state. We can use the adapter functions as reducers directly, so we'll pass postsAdapter.addOne as the reducer function to handle that action. In this case, we use the adapter method as the actual reducer for this action.

最后,我们可以用 postsAdapter.getSelectors 生成的选择器函数替换旧的手写 selectAllPostsselectPostById 选择器函数。由于选择器是使用根 Redux 状态对象调用的,因此它们需要知道在 Redux 状态中哪里可以找到我们的帖子数据,因此我们传入一个返回 state.posts 的小选择器。生成的选择器函数始终称为 selectAllselectById,因此我们可以在导出它们时使用解构语法重命名它们并匹配旧的选择器名称。我们还将以相同的方式导出 selectPostIds,因为我们想要读取 <PostsList> 组件中已排序的帖子 ID 列表。

¥Finally, we can replace the old hand-written selectAllPosts and selectPostById selector functions with the ones generated by postsAdapter.getSelectors. Since the selectors are called with the root Redux state object, they need to know where to find our posts data in the Redux state, so we pass in a small selector that returns state.posts. The generated selector functions are always called selectAll and selectById, so we can use destructuring syntax to rename them as we export them and match the old selector names. We'll also export selectPostIds the same way, since we want to read the list of sorted post IDs in our <PostsList> component.

我们甚至可以通过将 postUpdated 更改为使用 postsAdapter.updateOne 方法来减少几行。这需要一个看起来像 {id, changes} 的对象,其中 changes 是一个带有要覆盖的字段的对象:

¥We could even cut out a couple more lines by changing postUpdated to use the postsAdapter.updateOne method. This takes an object that looks like{id, changes}, where changes is an object with fields to overwrite:

features/posts/postsSlice.ts
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
postsAdapter.updateOne(state, { id, changes: { title, content } })
},
reactionAdded(
state,
action: PayloadAction<{ postId: string; reaction: ReactionName }>
) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
}
}
// omit `extraReducers`
})

请注意,我们不能将 postsAdapter.updateOnereactionAdded reducer一起使用,因为它有点复杂。我们不仅需要替换帖子对象中的字段,还需要增加嵌套在其中一个字段内的计数器。在这种情况下,查找对象并像我们之前一样执行 "mutating" 更新是可以的。

¥Note that we can't quite use postsAdapter.updateOne with the reactionAdded reducer, because it's a bit more complicated. Rather than just replacing a field in the post object, we need to increment a counter nested inside one of the fields. In that case, it's fine to look up the object and do a "mutating" update as we have been.

优化帖子列表

¥Optimizing the Posts List

现在我们的帖子切片正在使用 createEntityAdapter,我们可以更新 <PostsList> 以优化其渲染行为。

¥Now that our posts slice is using createEntityAdapter, we can update <PostsList> to optimize its rendering behavior.

我们将更新 <PostsList> 以仅读取帖子 ID 的排序数组,并将 postId 传递给每个 <PostExcerpt>

¥We'll update <PostsList> to read just the sorted array of post IDs, and pass postId to each <PostExcerpt>:

features/posts/PostsList.tsx
// omit other imports

import {
fetchPosts,
selectPostById,
selectPostIds,
selectPostsStatus,
selectPostsError
} from './postsSlice'

interface PostExcerptProps {
postId: string
}

function PostExcerpt({ postId }: PostExcerptProps) {
const post = useAppSelector(state => selectPostById(state, postId))
// omit rendering logic
}

export const PostsList = () => {
const dispatch = useDispatch()
const orderedPostIds = useSelector(selectPostIds)

// omit other selections and effects

if (postStatus === 'pending') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
content = orderedPostIds.map(postId => (
<PostExcerpt key={postId} postId={postId} />
))
} else if (postStatus === 'rejected') {
content = <div>{postsError}</div>
}

// omit other rendering
}

现在,如果我们在捕获 React 组件性能配置文件时尝试单击其中一篇文章上的反应按钮,我们应该会看到只有该组件重新渲染:

¥Now, if we try clicking a reaction button on one of the posts while capturing a React component performance profile, we should see that only that one component re-rendered:

React DevTools Profiler render capture - optimized &lt;PostsList&gt;

规范化用户切片

¥Normalizing the Users Slice

我们也可以转换其他切片以使用 createEntityAdapter

¥We can convert other slices to use createEntityAdapter as well.

usersSlice 相当小,因此我们只需更改一些内容:

¥The usersSlice is fairly small, so we've only got a few things to change:

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

import { client } from '@/api/client'
import { createAppAsyncThunk } from '@/app/withTypes'

const usersAdapter = createEntityAdapter<User>()

const initialState = usersAdapter.getInitialState()

export const fetchUsers = createAppAsyncThunk('users/fetchUsers', async () => {
const response = await client.get('/fakeApi/users')
return response.users
})

const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, usersAdapter.setAll)
}
})

export default usersSlice.reducer

export const { selectAll: selectAllUsers, selectById: selectUserById } =
usersAdapter.getSelectors((state: RootState) => state.users)

export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
if (!currentUsername) {
return
}
return selectUserById(state, currentUsername)
}

我们在这里处理的唯一操作总是用我们从服务器获取的数组替换整个用户列表。我们可以使用 usersAdapter.setAll 来实现。

¥The only action we're handling here always replaces the entire list of users with the array we fetched from the server. We can use usersAdapter.setAll to implement that instead.

我们已经导出了我们手写的 selectAllUsersselectUserById 选择器。我们可以用 usersAdapter.getSelectors() 生成的版本替换它们。

¥We were already exporting the selectAllUsers and selectUserById selectors we'd written by hand. We can replace those with the versions generated by usersAdapter.getSelectors().

我们现在与 selectUserById 的类型略有不匹配 - 根据类型,我们的 currentUsername 可以是 null,但生成的 selectUserById 不会接受这一点。一个简单的解决方法是检查是否存在,如果不存在则提前返回。

¥We do now have a slight types mismatch with selectUserById - our currentUsername can be null according to the types, but the generated selectUserById won't accept that. A simple fix is to check if exists and just return early if it doesn't.

规范化通知切片

¥Normalizing the Notifications Slice

最后但并非最不重要的一点是,我们还将更新 notificationsSlice

¥Last but not least, we'll update notificationsSlice as well:

features/notifications/notificationsSlice.ts
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'

import { client } from '@/api/client'

// omit types and fetchNotifications thunk

const notificationsAdapter = createEntityAdapter<ClientNotification>({
// Sort with newest first
sortComparer: (a, b) => b.date.localeCompare(a.date)
})

const initialState = notificationsAdapter.getInitialState()

const notificationsSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
allNotificationsRead(state) {
Object.values(state.entities).forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
// Add client-side metadata for tracking new notifications
const notificationsWithMetadata: ClientNotification[] =
action.payload.map(notification => ({
...notification,
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, notificationsWithMetadata)
})
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

export const { selectAll: selectAllNotifications } =
notificationsAdapter.getSelectors((state: RootState) => state.notifications)

export const selectUnreadNotificationsCount = (state: RootState) => {
const allNotifications = selectAllNotifications(state)
const unreadNotifications = allNotifications.filter(
notification => !notification.read
)
return unreadNotifications.length
}

我们再次导入 createEntityAdapter,调用它,并调用 notificationsAdapter.getInitialState() 来帮助设置切片。

¥We again import createEntityAdapter, call it, and call notificationsAdapter.getInitialState() to help set up the slice.

讽刺的是,我们确实有几个地方需要循环所有通知对象并更新它们。由于这些通知不再保存在数组中,因此我们必须使用 Object.values(state.entities) 来获取这些通知的数组并对其进行循环。另一方面,我们可以用 notificationsAdapter.upsertMany 替换之前的 fetch 更新逻辑。

¥Ironically, we do have a couple places in here where we need to loop over all notification objects and update them. Since those are no longer being kept in an array, we have to use Object.values(state.entities) to get an array of those notifications and loop over that. On the other hand, we can replace the previous fetch update logic with notificationsAdapter.upsertMany.

编写反应逻辑

¥Writing Reactive Logic

到目前为止,我们所有的应用行为都是相对必要的。用户执行某些操作(添加帖子、获取通知),我们会在响应中分派点击处理程序或组件 useEffect 钩子中的操作。其中包括数据获取 thunk,如 fetchPostslogin

¥Thus far, all of our application behavior has been relatively imperative. The user does something (adding a post, fetching notifications), and we dispatch actions in either a click handler or a component useEffect hook in response. That includes the data fetching thunks like fetchPosts and login.

但是,有时我们需要编写更多逻辑来响应应用中发生的事情,例如分派某些操作。

¥However, sometimes we need to write more logic that runs in response to things that happened in the app, such as certain actions being dispatched.

我们已经展示了一些加载指示器,用于获取帖子等。当用户添加新帖子时,最好有某种视觉确认,例如弹出一个 toast 消息。

¥We've shown some loading indicators for things like fetching posts. It would be nice to have some kind of a visual confirmation for the user when they add a new post, like popping up a toast message.

我们已经看到我们可以拥有 许多 Reducer 响应相同的调度操作。这对于仅为 "更新更多状态部分" 的逻辑非常有效,但如果我们需要编写异步或具有其他副作用的逻辑怎么办?我们不能把它放在 Reducer 中 - Reducer 必须是 "pure" 并且不能有任何副作用

¥We've already seen that we can have many reducers respond to the same dispatched action. That works great for logic that is just "update more parts of the state", but what if we need to write logic that is async or has other side effects? We can't put that in the reducers - reducers must be "pure" and must not have any side effects.

如果我们不能将这种具有副作用的逻辑放在 Reducer 中,我们可以把它放在哪里?

¥If we can't put this logic with side effects in reducers, where can we put it?

答案在 Redux 中间件,因为中间件旨在启用副作用 内部。

¥The answer is inside of Redux middleware, because middleware is designed to enable side effects.

createListenerMiddleware 的反应逻辑

¥Reactive Logic with createListenerMiddleware

我们已经使用了 thunk 中间件来实现必须运行 "现在" 的异步逻辑。然而,thunk 只是函数。我们需要一种不同类型的中间件,让我们说 "当分派特定操作时,在响应中运行此附加逻辑"。

¥We've already used the thunk middleware for async logic that has to run "right now". However, thunks are just functions. We need a different kind of middleware that lets us say "when a specific action is dispatched, go run this additional logic in response".

Redux Toolkit 包含 createListenerMiddleware API,让我们可以编写响应正在调度的特定操作而运行的逻辑。它允许我们添加 "listener" 条目来定义要查找的操作,并有一个 effect 回调,只要它与操作匹配,它就会运行。

¥Redux Toolkit includes the createListenerMiddleware API to let us write logic that runs in response to specific actions being dispatched. It lets us add "listener" entries that define what actions to look for and have an effect callback that will run whenever it matches against an action.

从概念上讲,你可以将 createListenerMiddleware 视为与 React 的 useEffect 钩子 类似,只是它们被定义为 Redux 逻辑的一部分,而不是在 React 组件内部,并且它们响应分派的操作和 Redux 状态更新而运行,而不是作为 React 渲染生命周期的一部分。

¥Conceptually, you can think of createListenerMiddleware as being similar to React's useEffect hook, except that they are defined as part of your Redux logic instead of inside a React component, and they run in response to dispatched actions and Redux state updates instead of as part of React's rendering lifecycle.

设置监听器中间件

¥Setting Up the Listener Middleware

我们不必专门设置或定义 thunk 中间件,因为 Redux Toolkit 的 configureStore 会自动将 thunk 中间件添加到存储设置中。对于监听器中间件,我们必须做一些设置工作来创建它并将其添加到存储中。

¥We didn't have to specifically set up or define the thunk middleware, because Redux Toolkit's configureStore automatically adds the thunk middleware to the store setup. For the listener middleware, we'll have to do a bit of setup work to create it and add it to the store.

我们将创建一个新的 app/listenerMiddleware.ts 文件并在那里创建一个监听器中间件的实例。与 createAsyncThunk 类似,我们将传递正确的 dispatchstate 类型,以便我们可以安全地访问状态字段并分派操作。

¥We'll create a new app/listenerMiddleware.ts file and create an instance of the listener middleware there. Similar to createAsyncThunk, we'll pass through the correct dispatch and state types so that we can safely access state fields and dispatch actions.

app/listenerMiddleware.ts
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'

export const listenerMiddleware = createListenerMiddleware()

export const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>()
export type AppStartListening = typeof startAppListening

export const addAppListener = addListener.withTypes<RootState, AppDispatch>()
export type AppAddListener = typeof addAppListener

createSlice 一样,createListenerMiddleware 返回一个包含多个字段的对象:

¥Like createSlice, createListenerMiddleware returns an object that contains multiple fields:

  • listenerMiddleware.middleware:需要添加到存储的实际 Redux 中间件实例

    ¥listenerMiddleware.middleware: the actual Redux middleware instance that needs to be added to the store

  • listenerMiddleware.startListening:直接向中间件添加新的监听器条目

    ¥listenerMiddleware.startListening: adds a new listener entry to the middleware directly

  • listenerMiddleware.addListener:一个动作创建器,可以分派它从代码库中的任何位置添加可以访问 dispatch 的监听器条目,即使你没有导入 listenerMiddleware 对象

    ¥listenerMiddleware.addListener: an action creator that can be dispatched to add a listener entry from anywhere in the codebase that has access to dispatch, even if you didn't import the listenerMiddleware object

与异步 thunk 和 hooks 一样,我们可以使用 .withTypes() 方法定义预类型化的 startAppListeningaddAppListener 函数,并内置正确的类型。

¥As with async thunks and hooks, we can use the .withTypes() methods to define pre-typed startAppListening and addAppListener functions with the right types built in.

然后,我们需要将其添加到存储:

¥Then, we need to add it to the store:

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

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

import { listenerMiddleware } from './listenerMiddleware'

export const store = configureStore({
reducer: {
auth: authReducer,
posts: postsReducer,
users: usersReducer,
notifications: notificationsReducer
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware().prepend(listenerMiddleware.middleware)
})

configureStore 已默认将 redux-thunk 中间件添加到存储设置中,同时还开发了一些用于添加安全检查的其他中间件。我们希望保留这些,但也添加监听器中间件。

¥configureStore already adds the redux-thunk middleware to the store setup by default, along with some additional middleware in development that add safety checks. We want to preserve those, but also add the listener middleware as well.

设置中间件时顺序很重要,因为它们形成了一个管道:m1 -> m2 -> m3 -> store.dispatch().在这种情况下,监听器中间件需要位于管道的开始处,以便它可以首先拦截一些操作并处理它们。

¥Order can matter when setting up middleware, because they form a pipeline: m1 -> m2 -> m3 -> store.dispatch(). In this case, the listener middleware needs to be at the start of the pipeline, so that it can intercept some actions first and process them.

getDefaultMiddleware() 返回已配置中间件的数组。由于它是一个数组,它已经有一个 .concat() 方法,该方法返回一个副本,其中包含数组末尾的新项,但 configureStore 还添加了一个等效的 .prepend() 方法,该方法在数组的开头制作一个副本,其中包含新项。

¥getDefaultMiddleware() returns an array of the configured middleware. Since it's an array, it already has a .concat() method that returns a copy with the new items at the end of the array, but configureStore also adds an equivalent .prepend() method that makes a copy with the new items at the start of the array.

因此,我们将调用 getDefaultMiddleware().prepend(listenerMiddleware.middleware) 将其添加到列表的前面。

¥So, we'll call getDefaultMiddleware().prepend(listenerMiddleware.middleware) to add this to the front of the list.

显示新帖子的 Toast

¥Showing Toasts for New Posts

现在我们已经配置了监听器中间件,我们可以添加一个新的监听器条目,该条目将在成功添加新帖子时显示一条提示消息。

¥Now that we have the listener middleware configured, we can add a new listener entry that will show a toast message any time a new post successfully gets added.

我们将使用 react-tiny-toast 库来管理以正确的外观显示 toast。它已包含在项目存储库中,因此我们无需安装它。

¥We're going to use the react-tiny-toast library to manage showing toasts with the right appearance. It's already included in the project repo, so we don't have to install it.

我们确实需要在我们的 <App> 中导入并渲染其 <ToastContainer> 组件:

¥We do need to import and render its <ToastContainer> component in our <App>:

App.tsx
import React from 'react'
import {
BrowserRouter as Router,
Route,
Routes,
Navigate
} from 'react-router-dom'
import { ToastContainer } from 'react-tiny-toast'

// omit other imports and ProtectedRoute definition

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>{/* omit routes content */}</Routes>
<ToastContainer />
</div>
</Router>
)
}

现在我们可以添加一个监听器来监视 addNewPost.fulfilled 操作,显示一个显示 "帖子已添加" 的提示框,并在延迟后将其删除。

¥Now we can go add a listener that will watch for the addNewPost.fulfilled action, show a toast that says "Post Added", and remove it after a delay.

我们可以使用多种方法在代码库中定义监听器。也就是说,在与我们要添加的逻辑最相关的任何切片文件中定义监听器通常是一种很好的做法。在这种情况下,我们希望在添加帖子时显示提示,因此让我们在 postsSlice 文件中添加此监听器:

¥There's multiple approaches we can use for defining listeners in our codebase. That said, it's usually a good practice to define listeners in whatever slice file seems most related to the logic we want to add. In this case, we want to show a toast when a post gets added, so let's add this listener in the postsSlice file:

features/posts/postsSlice.ts
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'

// omit types, initial state, slice definition, and selectors

export const selectPostsStatus = (state: RootState) => state.posts.status
export const selectPostsError = (state: RootState) => state.posts.error

export const addPostsListeners = (startAppListening: AppStartListening) => {
startAppListening({
actionCreator: addNewPost.fulfilled,
effect: async (action, listenerApi) => {
const { toast } = await import('react-tiny-toast')

const toastId = toast.show('New post added!', {
variant: 'success',
position: 'bottom-right',
pause: true
})

await listenerApi.delay(5000)
toast.remove(toastId)
}
})
}

要添加监听器,我们需要调用在 app/listenerMiddleware.ts 中定义的 startAppListening 函数。但是,最好不要将 startAppListening 直接导入切片文件,以帮助保持导入链更加一致。相反,我们可以导出一个接受 startAppListening 作为参数的函数。这样,app/listenerMiddleware.ts 文件可以导入此函数,类似于 app/store.ts 从每个切片文件导入切片 Reducer 的方式。

¥To add a listener, we need to call the startAppListening function that was defined in app/listenerMiddleware.ts. However, it's better if we don't import startAppListening directly into the slice file, to help keep the import chains more consistent. Instead, we can export a function that accepts startAppListening as an argument. That way, the app/listenerMiddleware.ts file can import this function, similar to the way app/store.ts imports the slice reducers from each slice file.

要添加监听器条目,请调用 startAppListening 并传入一个带有 effect 回调函数的对象,以及以下选项之一来定义何时运行效果回调:

¥To add a listener entry, call startAppListening and pass in an object with an effect callback function, and one of these options to define when the effect callback will run:

  • actionCreator: ActionCreator:任何 RTK 动作创建器函数,如 reactionAddedaddNewPost.fulfilled。这将在分派该特定操作时运行效果。

    ¥actionCreator: ActionCreator: any RTK action creator function, like reactionAdded or addNewPost.fulfilled. This will run the effect when that one specific action is dispatched.

  • matcher: (action: UnknownAction) => boolean:任何 RTK "matcher" 功能,如 isAnyOf(reactionAdded, addNewPost.fulfilled)。这将在匹配器返回 true 时运行效果。

    ¥matcher: (action: UnknownAction) => boolean: Any RTK "matcher" function, like isAnyOf(reactionAdded, addNewPost.fulfilled). This will run the effect any time the matcher returns true.

  • predicate: (action: UnknownAction, currState: RootState, prevState: RootState) => boolean:一个更通用的匹配函数,可以访问 currStateprevState。这可用于对操作或状态值进行任何检查,包括查看某个状态是否已更改(例如 currState.counter.value !== prevState.counter.value

    ¥predicate: (action: UnknownAction, currState: RootState, prevState: RootState) => boolean: a more general matching function that has access to currState and prevState. This can be used to make any check you want against the action or state values, including seeing if a piece of state has changed (such as currState.counter.value !== prevState.counter.value)

在这种情况下,我们特别希望在 addNewPost thunk 成功时显示我们的 toast,因此我们将指定效果应与 actionCreator: addNewPost.fulfilled 一起运行。

¥In this case, we specifically want to show our toast any time the addNewPost thunk succeeds, so we'll specify the effect should run with actionCreator: addNewPost.fulfilled.

effect 回调本身很像一个异步 thunk。它将匹配的 action 作为第一个参数,将 listenerApi 对象作为第二个参数。

¥The effect callback itself is much like an async thunk. It gets the matched action as the first argument, and a listenerApi object as the second argument.

listenerApi 包括常见的 dispatchgetState 方法,但也包括 可用于实现复杂异步逻辑和工作流的其他几个函数。其中包括 condition() 等方法,用于暂停直到调度其他操作或状态值发生变化,unsubscribe()/subscribe() 用于更改此监听器条目是否处于活动状态,fork() 用于启动子任务,等等。

¥The listenerApi includes the usual dispatch and getState methods, but also several other functions that can be used to implement complex async logic and workflows. That includes methods like condition() to pause until some other action is dispatched or state value changes, unsubscribe()/subscribe() to change whether this listener entry is active, fork() to kick off a child task, and more.

在这种情况下,我们希望动态导入实际的 react-tiny-toast 库,显示成功提示,等待几秒钟,然后删除提示。

¥In this case, we want to import the actual react-tiny-toast library dynamically, show the success toast, wait a few seconds, and then remove the toast.

最后,我们需要在某处实际导入并调用 addPostsListeners。在这种情况下,我们将它导入到 app/listenerMiddleware.ts 中:

¥Finally, we need to actually import and call addPostsListeners somewhere. In this case, we'll import it into app/listenerMiddleware.ts:

app/listenerMiddleware.ts
import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'
import type { RootState, AppDispatch } from './store'

import { addPostsListeners } from '@/features/posts/postsSlice'

export const listenerMiddleware = createListenerMiddleware()

export const startAppListening = listenerMiddleware.startListening.withTypes<
RootState,
AppDispatch
>()
export type AppStartListening = typeof startAppListening

export const addAppListener = addListener.withTypes<RootState, AppDispatch>()
export type AppAddListener = typeof addAppListener

// Call this and pass in `startAppListening` to let the
// posts slice set up its listeners
addPostsListeners(startAppListening)

现在,当我们添加新帖子时,我们应该看到页面右下角弹出一个小的绿色提示框,并在 5 秒后消失。这是因为 Redux 存储中的监听器中间件在分派操作后检查并运行效果回调,即使我们没有特别向 React 组件本身添加任何其他逻辑。

¥Now when we add a new post, we should see a small green toast pop up in the lower right-hand corner of the page, and disappear after 5 seconds. This works because the listener middleware in the Redux store checks and runs the effect callback after the action was dispatched, even though we didn't specifically add any more logic to the React components themselves.

你学到了什么

¥What You've Learned

我们在本节中构建了许多新行为。让我们看看应用经过所有这些更改后的外观:

¥We've built a lot of new behavior in this section. Let's see what how the app looks with all those changes:

这是我们在本节中介绍的内容:

¥Here's what we covered in this section:

概括
  • 记忆选择器函数可用于优化性能

    ¥Memoized selector functions can be used to optimize performance

    • Redux Toolkit 从 Reselect 重新导出 createSelector 函数,生成记忆选择器

      ¥Redux Toolkit re-exports the createSelector function from Reselect, which generates memoized selectors

    • 仅当输入选择器返回新值时,记忆选择器才会重新计算结果

      ¥Memoized selectors will only recalculate the results if the input selectors return new values

    • 记忆化可以跳过昂贵的计算,并确保返回相同的结果引用

      ¥Memoization can skip expensive calculations, and ensure the same result references are returned

  • 你可以使用多种模式来通过 Redux 优化 React 组件渲染

    ¥There are multiple patterns you can use to optimize React component rendering with Redux

    • 避免在 useSelector 内部创建新的对象/数组引用 - 这些会导致不必要的重新渲染

      ¥Avoid creating new object/array references inside of useSelector - those will cause unnecessary re-renders

    • 记忆选择器函数可以传递给 useSelector 以优化渲染

      ¥Memoized selector functions can be passed to useSelector to optimize rendering

    • useSelector 可以接受像 shallowEqual 这样的替代比较函数,而不是引用相等

      ¥useSelector can accept an alternate comparison function like shallowEqual instead of reference equality

    • 组件可以封装在 React.memo() 中,以便仅在其 props 发生变化时重新渲染

      ¥Components can be wrapped in React.memo() to only re-render if their props change

    • 可以通过以下方式优化列表渲染:让列表父组件仅读取项目 ID 数组,将 ID 传递给列表项目子组件,然后按子组件中的 ID 检索项目

      ¥List rendering can be optimized by having list parent components read just an array of item IDs, passing the IDs to list item children, and retrieving items by ID in the children

  • 标准化状态结构是存储项目的推荐方法

    ¥Normalized state structure is a recommended approach for storing items

    • "正常化" 表示不重复数据,并按项目 ID 将项目存储在查找表中

      ¥"Normalization" means no duplication of data, and keeping items stored in a lookup table by item ID

    • 标准化状态形状通常看起来像 {ids: [], entities: {}}

      ¥Normalized state shape usually looks like {ids: [], entities: {}}

  • Redux Toolkit 的 createEntityAdapter API 有助于管理切片中的规范化数据

    ¥Redux Toolkit's createEntityAdapter API helps manage normalized data in a slice

    • 可以通过传入 sortComparer 选项来保持项目 ID 的排序顺序

      ¥Item IDs can be kept in sorted order by passing in a sortComparer option

    • 适配器对象包括:

      ¥The adapter object includes:

      • adapter.getInitialState,可以接受附加状态字段,例如加载状态

        ¥adapter.getInitialState, which can accept additional state fields like loading state

      • 适用于常见情况的预构建 reducer,例如 setAlladdManyupsertOneremoveMany

        ¥Prebuilt reducers for common cases, like setAll, addMany, upsertOne, and removeMany

      • adapter.getSelectors,它生成像 selectAllselectById 这样的选择器

        ¥adapter.getSelectors, which generates selectors like selectAll and selectById

  • Redux Toolkit 的 createListenerMiddleware API 用于运行响应分派操作的反应逻辑

    ¥Redux Toolkit's createListenerMiddleware API is used to run reactive logic in response to dispatched actions

    • 应将监听器中间件添加到存储设置中,并附加正确的存储类型

      ¥The listener middleware should be added to the store setup, with the right store types attached

    • 监听器通常在切片文件中定义,但也可以采用其他方式构造

      ¥Listeners are typically defined in slice files, but may be structured other ways as well

    • 监听器可以匹配单个操作、多个操作或使用自定义比较

      ¥Listeners can match against individual actions, many actions, or use custom comparisons

    • 监听器效果回调可以包含任何同步或异步逻辑

      ¥Listener effect callbacks can contain any sync or async logic

    • listenerApi 对象提供了许多用于管理异步工作流和行为的方法

      ¥The listenerApi object provides many methods for managing async workflows and behavior

下一步是什么?

¥What's Next?

Redux Toolkit 还包括一个强大的数据获取和缓存 API,称为 "RTK 查询"。RTK 查询是一个可选插件,可以完全消除你自己编写任何数据获取逻辑的需要。在 第 7 部分:RTK 查询基础知识 中,你将了解 RTK 查询是什么、它解决什么问题以及如何使用它在应用中获取和使用缓存数据。

¥Redux Toolkit also includes a powerful data fetching and caching API called "RTK Query". RTK Query is an optional addon that can completely eliminate the need to write any data fetching logic yourself. In Part 7: RTK Query Basics, you'll learn what RTK Query is, what problems it solves, and how to use it to fetch and use cached data in your application.