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

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

    ¥Completion of Part 5 to understand data fetching flow

介绍

¥Introduction

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

¥In Part 5: Async Logic and Data Fetching, we saw how to write async thunks to fetch data from a server API, patterns for handling async request loading state, and use of selector functions for encapsulating lookups of data from the Redux 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.

到目前为止,我们的大部分功能都以 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 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.js
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
import { selectAllUsers } from './usersSlice'

export const UsersList = () => {
const users = useSelector(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>
)
}

我们还没有 selectAllUsers 选择器,因此我们需要将其与 selectUserById 选择器一起添加到 usersSlice.js

¥We don't yet have a selectAllUsers selector, so we'll need to add that to usersSlice.js along with a selectUserById selector:

features/users/usersSlice.js
export default usersSlice.reducer

export const selectAllUsers = state => state.users

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

我们将添加一个 <UserPage>,它与我们的 <SinglePostPage> 类似,从路由获取 userId 参数:

¥And we'll add a <UserPage>, which is similar to our <SinglePostPage> in taking a userId parameter from the router:

features/users/UserPage.js
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'

import { selectUserById } from '../users/usersSlice'
import { selectAllPosts } from '../posts/postsSlice'

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

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

const postsForUser = useSelector(state => {
const allPosts = selectAllPosts(state)
return allPosts.filter(post => post.user === userId)
})

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>
)
}

正如我们之前所看到的,我们可以从一个 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.js
          <Route exact path="/posts/:postId" component={SinglePostPage} />
<Route exact path="/editPost/:postId" component={EditPostForm} />
<Route exact path="/users" component={UsersList} />
<Route exact path="/users/:userId" component={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.js
export const Navbar = () => {
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>

<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
<Link to="/users">Users</Link>
</div>
</div>
</section>
</nav>
)
}

添加通知

¥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.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'

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

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

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 => state.notifications

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

¥As with the other slices, import notificationsReducer into store.js 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,你只能传入一个参数,并且我们传入的任何内容都将成为有效负载创建回调的第一个参数。

¥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.

我们的有效负载创建者的第二个参数是一个 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.

在这种情况下,我们知道通知列表处于 Redux 存储状态,并且最新的通知应该位于数组中的第一个。我们可以从 thunkAPI 对象中解构 getState 函数,调用它来读取状态值,并使用 selectAllNotifications 选择器为我们提供通知数组。由于通知数组是最新排序的,我们可以使用数组解构来获取最新的通知。

¥In this case, 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 destructure the getState function out of the thunkAPI object, call it 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

创建该切片后,我们可以添加 <NotificationsList> 组件:

¥With that slice created, we can add a <NotificationsList> component:

features/notifications/NotificationsList.js
import React from 'react'
import { useSelector } from 'react-redux'
import { formatDistanceToNow, parseISO } from 'date-fns'

import { selectAllUsers } from '../users/usersSlice'

import { selectAllNotifications } from './notificationsSlice'

export const NotificationsList = () => {
const notifications = useSelector(selectAllNotifications)
const users = useSelector(selectAllUsers)

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

return (
<div key={notification.id} className="notification">
<div>
<b>{user.name}</b> {notification.message}
</div>
<div title={notification.date}>
<i>{timeAgo} ago</i>
</div>
</div>
)
})

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

我们再次从 Redux 状态读取项目列表,对它们进行映射,并渲染每个项目的内容。

¥Once again, we're reading a list of items from the Redux state, mapping over them, and rendering content for each item.

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

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

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

import { fetchNotifications } from '../features/notifications/notificationsSlice'

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

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

return (
<nav>
<section>
<h1>Redux Essentials Example</h1>

<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">Notifications</Link>
</div>
<button className="button" onClick={fetchNewNotifications}>
Refresh Notifications
</button>
</div>
</section>
</nav>
)
}

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

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

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

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Switch>
<Route exact path="/notifications" component={NotificationsList} />
// omit existing routes
<Redirect to="/" />
</Switch>
</div>
</Router>
)
}

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

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

Initial Notifications tab

显示新通知

¥Showing New Notifications

每次我们单击 "刷新通知" 时,都会将更多通知条目添加到我们的列表中。在真实的应用中,当我们查看 UI 的其他部分时,这些可能来自服务器。我们可以通过在查看 <PostsList><UserPage> 时单击 "刷新通知" 来执行类似的操作。但是,现在我们不知道有多少通知刚刚到达,如果我们继续单击按钮,可能会有许多通知我们尚未阅读。让我们添加一些逻辑来跟踪哪些通知已被读取以及哪些是 "new"。这将使我们能够在导航栏中的 "通知" 选项卡上将 "未读" 通知的计数显示为徽章,并以不同的颜色显示新通知。

¥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>. 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.

我们的假 API 已经发送回带有 isNewread 字段的通知条目,因此我们可以在代码中使用它们。

¥Our fake API is already sending back the notification entries with isNew and read fields, so we can use those in our code.

首先,我们将更新 notificationsSlice 以拥有一个将所有通知标记为已读的 reducer,以及一些处理将现有通知标记为 "不是新的" 的逻辑:

¥First, we'll update notificationsSlice to have a reducer that marks all notifications as read, and some logic to handle marking existing notifications as "not new":

features/notifications/notificationsSlice.js
const notificationsSlice = createSlice({
name: 'notifications',
initialState: [],
reducers: {
allNotificationsRead(state, action) {
state.forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
state.push(...action.payload)
state.forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})
// Sort with newest first
state.sort((a, b) => b.date.localeCompare(a.date))
})
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

我们希望在 <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.js
import React, { useLayoutEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { formatDistanceToNow, parseISO } from 'date-fns'
import classnames from 'classnames'

import { selectAllUsers } from '../users/usersSlice'

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

export const NotificationsList = () => {
const dispatch = useDispatch()
const notifications = useSelector(selectAllNotifications)
const users = useSelector(selectAllUsers)

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

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

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

return (
<div key={notification.id} className={notificationClassname}>
<div>
<b>{user.name}</b> {notification.message}
</div>
<div title={notification.date}>
<i>{timeAgo} ago</i>
</div>
</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 返回的新数组,并且 useLayoutEffect 钩子再次运行并第二次分派 allNotificationsRead。reducer 再次运行,但这次没有数据更改,因此组件不会重新渲染。

¥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, and the useLayoutEffect hook runs again and dispatches allNotificationsRead a second time. The reducer runs again, but this time no data changes, so 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

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

¥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.js
// omit imports
import { useDispatch, useSelector } from 'react-redux'

import {
fetchNotifications,
selectAllNotifications
} from '../features/notifications/notificationsSlice'

export const Navbar = () => {
const dispatch = useDispatch()
const notifications = useSelector(selectAllNotifications)
const numUnreadNotifications = notifications.filter(n => !n.read).length
// omit component contents
let unreadNotificationsBadge

if (numUnreadNotifications > 0) {
unreadNotificationsBadge = (
<span className="badge">{numUnreadNotifications}</span>
)
}
return (
<nav>
// omit component contents
<div className="navLinks">
<Link to="/">Posts</Link>
<Link to="/users">Users</Link>
<Link to="/notifications">
Notifications {unreadNotificationsBadge}
</Link>
</div>
// omit component contents
</nav>
)
}

提高渲染性能

¥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.

如果我们仔细观察 <UserPage>,就会发现一个具体问题:

¥If we look at <UserPage> carefully, there's a specific problem:

"features/UserPage.js
export const UserPage = ({ match }) => {
const { userId } = match.params

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

const postsForUser = useSelector(state => {
const allPosts = selectAllPosts(state)
return allPosts.filter(post => post.user === userId)
})

// omit rendering logic
}

我们知道每次分派动作时 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.

我们在 useSelector 钩子内调用 filter(),以便我们只返回属于该用户的帖子列表。不幸的是,这意味着 useSelector 总是返回一个新的数组引用,因此即使帖子数据没有改变,我们的组件也会在每次操作后重新渲染!

¥We're calling filter() inside of our useSelector hook, so that we only return the list of posts that belong to this user. Unfortunately, this means that useSelector always returns a new array reference, and so our component will re-render after every action even if the posts data hasn't changed!.

记忆选择器功能

¥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 selector functions by ourselves, and just 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.

重新选择 是一个用于创建记忆选择器函数的库,专门设计用于与 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.

让我们使用 Reselect 创建一个新的 selectPostsByUser 选择器函数,并在此处使用它。

¥Let's make a new selectPostsByUser selector function, using Reselect, and use it here.

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

// omit slice logic

export const selectAllPosts = state => state.posts.posts

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

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

createSelector 采用一个或多个 "输入选择器" 函数作为参数,再加上一个 "输出选择器" 函数。当我们调用 selectPostsByUser(state, userId) 时,createSelector 会将所有参数传递到每个输入选择器中。无论这些输入选择器返回什么,都会成为输出选择器的参数。

¥createSelector takes one or more "input selector" functions as argument, plus an "output selector" function. 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.

在本例中,我们知道需要所有帖子的数组和用户 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 selector then takes posts and userId, 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')

如果我们在 <UserPage> 中调用这个选择器并在获取通知时重新运行 React profiler,我们应该会看到 <UserPage> 这次不会重新渲染:

¥If we call this selector in <UserPage> and re-run the React profiler while fetching notifications, we should see that <UserPage> doesn't re-render this time:

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

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

const postsForUser = useSelector(state => selectPostsByUser(state, userId))

// omit rendering logic
}

记忆选择器是提高 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.

信息

有关我们为什么使用选择器函数以及如何使用 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.

我们可以通过几种不同的方式来优化 <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.js
let PostExcerpt = ({ post }) => {
// 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 是键,项目本身是值。

    ¥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.

  • 还可能存在特定项目类型的所有 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 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.

更新帖子切片

¥Updating the Posts Slice

考虑到这一点,让我们更新 postsSlice 以使用 createEntityAdapter

¥With that in mind, let's update our postsSlice to use createEntityAdapter:

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

const postsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
})

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

// omit thunks

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.entities[postId]
if (existingPost) {
existingPost.reactions[reaction]++
}
},
postUpdated(state, action) {
const { id, title, content } = action.payload
const existingPost = state.entities[id]
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
},
extraReducers(builder) {
// omit other reducers

builder
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Add any fetched posts to the array
// Use the `upsertMany` reducer as a mutating update utility
postsAdapter.upsertMany(state, action.payload)
})
// Use the `addOne` reducer for the fulfilled case
.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 => state.posts)

export const selectPostsByUser = createSelector(
[selectAllPosts, (state, userId) => 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 以直接通过 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, instead of having to loop over the old posts array.

当我们收到 fetchPosts.fulfilled 操作时,我们可以使用 postsAdapter.upsertMany 函数通过传入草稿 stateaction.payload 中的帖子数组来将所有传入帖子添加到状态中。如果 action.payload 中的任何项目已存在于我们的状态中,则 upsertMany 函数将根据匹配的 ID 将它们合并在一起。

¥When we receive the fetchPosts.fulfilled action, we can use the postsAdapter.upsertMany 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. If there's any items in action.payload that already existing in our state, the upsertMany function will merge them together based on matching IDs.

当我们收到 addNewPost.fulfilled 操作时,我们知道需要将一个新的帖子对象添加到我们的状态中。我们可以直接使用适配器函数作为 reducer,因此我们将传递 postsAdapter.addOne 作为 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.

最后,我们可以用 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.

优化帖子列表

¥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.js
// omit other imports

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

let PostExcerpt = ({ postId }) => {
const post = useSelector(state => selectPostById(state, postId))
// omit rendering logic
}

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

// omit other selections and effects

if (postStatus === 'loading') {
content = <Spinner text="Loading..." />
} else if (postStatus === 'succeeded') {
content = orderedPostIds.map(postId => (
<PostExcerpt key={postId} postId={postId} />
))
} else if (postStatus === 'error') {
content = <div>{error}</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;

转换其他切片

¥Converting Other Slices

我们快完成了。作为最后的清理步骤,我们也将更新其他两个切片以使用 createEntityAdapter

¥We're almost done. As a final cleanup step, we'll update our other two slices to use createEntityAdapter as well.

转换用户切片

¥Converting the Users Slice

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

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

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

const usersAdapter = createEntityAdapter()

const initialState = usersAdapter.getInitialState()

export const fetchUsers = createAsyncThunk('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 => state.users)

我们在这里处理的唯一操作总是用我们从服务器获取的数组替换整个用户列表。我们可以使用 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.

我们的 <AddPostForm> 仍在尝试将 state.users 作为数组读取,<PostAuthor> 也是如此。将它们更新为分别使用 selectAllUsersselectUserById

¥Our <AddPostForm> is still trying to read state.users as an array, as is <PostAuthor>. Update them to use selectAllUsers and selectUserById, respectively.

转换通知切片

¥Converting the Notifications Slice

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

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

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

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

const notificationsAdapter = createEntityAdapter({
sortComparer: (a, b) => b.date.localeCompare(a.date)
})

// omit fetchNotifications thunk

const notificationsSlice = createSlice({
name: 'notifications',
initialState: notificationsAdapter.getInitialState(),
reducers: {
allNotificationsRead(state, action) {
Object.values(state.entities).forEach(notification => {
notification.read = true
})
}
},
extraReducers(builder) {
builder.addCase(fetchNotifications.fulfilled, (state, action) => {
notificationsAdapter.upsertMany(state, action.payload)
Object.values(state.entities).forEach(notification => {
// Any notifications we've read are no longer new
notification.isNew = !notification.read
})
})
}
})

export const { allNotificationsRead } = notificationsSlice.actions

export default notificationsSlice.reducer

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

我们再次导入 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.

随之而来的是...我们已经学习完 Redux Toolkit 的核心概念和功能了!

¥And with that... we're done learning the core concepts and functionality of Redux Toolkit!

你学到了什么

¥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

下一步是什么?

¥What's Next?

Redux Essentials 教程中还有其他几个部分,但这是一个暂停并将你所学到的知识付诸实践的好地方。

¥There's a couple more sections in the Redux Essentials tutorial, but this is a good spot to pause and put what you've learned into practice.

到目前为止,我们在本教程中介绍的概念应该足以让你开始使用 React 和 Redux 构建自己的应用。现在是尝试自己开展项目以巩固这些概念并了解它们在实践中如何发挥作用的好时机。如果你不确定要构建什么样的项目,请参阅 这个应用项目创意列表 以获得一些灵感。

¥The concepts we've covered in this tutorial so far should be enough to get you started building your own applications using React and Redux. Now's a great time to try working on a project yourself to solidify these concepts and see how they work in practice. If you're not sure what kind of a project to build, see this list of app project ideas for some inspiration.

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.

Redux Essentials 教程重点关注 "如何正确使用 Redux",而不是 "怎么运行的" 或 "为什么会这样"。特别是,Redux Toolkit 是一组更高级别的抽象和实用程序,它有助于了解 RTK 中的抽象实际上为你做什么。通读 "Redux 基础知识" 教程 将帮助你了解如何编写 Redux 代码 "用手",以及为什么我们推荐 Redux Toolkit 作为编写 Redux 逻辑的默认方式。

¥The Redux Essentials tutorial is focused on "how to use Redux correctly", rather than "how it works" or "why it works this way". In particular, Redux Toolkit is a higher-level set of abstractions and utilities, and it's helpful to understand what the abstractions in RTK are actually doing for you. Reading through the "Redux Fundamentals" tutorial will help you understand how to write Redux code "by hand", and why we recommend Redux Toolkit as the default way to write Redux logic.

使用 Redux 部分提供了有关许多重要概念的信息,例如 如何构造你的 reducer,而 我们的风格指南页面 部分提供了有关我们推荐的模式和最佳实践的重要信息。

¥The Using Redux section has information on a number of important concepts, like how to structure your reducers, and our Style Guide page has important information on our recommended patterns and best practices.

如果你想更多地了解 Redux 存在的原因、它试图解决哪些问题以及如何使用它,请参阅 Redux 维护者 Mark Erikson 在 Redux 之道,第 1 部分:实现和意图Redux 之道,第 2 部分:实践与理念 上的帖子。

¥If you'd like to know more about why Redux exists, what problems it tries to solve, and how it's meant to be used, see Redux maintainer Mark Erikson's posts on The Tao of Redux, Part 1: Implementation and Intent and The Tao of Redux, Part 2: Practice and Philosophy.

如果你正在寻求有关 Redux 问题的帮助,请加入 Discord 上 Reactiflux 服务器中的 #redux 通道

¥If you're looking for help with Redux questions, come join the #redux channel in the Reactiflux server on Discord.

感谢你阅读本教程,我们希望你喜欢使用 Redux 构建应用!

¥Thanks for reading through this tutorial, and we hope you enjoy building applications with Redux!