跳至主要内容

Redux Essentials,第 6 部分:性能和数据规范化

您将学到什么
  • 如何使用 createSelector 创建记忆化的选择器函数
  • 优化组件渲染性能的模式
  • 如何使用 createEntityAdapter 存储和更新规范化数据
先决条件

简介

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

在本节中,我们将探讨确保应用程序性能良好的优化模式,以及自动处理存储中常见数据更新的技术。

到目前为止,我们的大部分功能都集中在 posts 功能上。我们将添加应用程序的几个新部分。添加完这些部分后,我们将查看构建方式的一些具体细节,并讨论我们迄今为止构建的内容中的一些弱点,以及如何改进实现。

添加用户页面

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

我们将从添加一个新的 <UsersList> 组件开始。它遵循使用 useSelector 从存储中读取一些数据的通常模式,并映射数组以显示用户列表,其中包含指向其个人页面的链接

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 选择器,因此我们需要将其添加到 usersSlice.js 中,以及一个 selectUserById 选择器

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 参数

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 调用。

像往常一样,我们将在 <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>

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

添加通知

没有社交媒体应用程序会没有一些通知弹出,告诉我们有人发送了消息、留下了评论或对我们的帖子做出了反应。

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

通知切片

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

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()调用中。

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

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

Thunk 参数

如果你看一下我们的 fetchNotifications thunk,你会发现它有一些我们之前没见过的东西。让我们花点时间谈谈 thunk 的参数。

我们已经知道,在分发 thunk action creator 时,可以向它传递参数,例如 dispatch(addPost(newPost))。对于 createAsyncThunk 来说,你只能传递一个参数,并且我们传递的任何内容都会成为 payload 创建回调的第一个参数。

payload 创建器的第二个参数是一个 thunkAPI 对象,其中包含几个有用的函数和信息。

  • dispatchgetState:来自我们 Redux 存储的实际 dispatchgetState 方法。你可以在 thunk 内部使用它们来分发更多 action,或者获取最新的 Redux 存储状态(例如,在另一个 action 分发后读取更新的值)。
  • extra:可以在创建存储时传递给 thunk 中间件的“额外参数”。这通常是某种 API 包装器,例如一组知道如何向应用程序服务器发出 API 调用并返回数据的函数,这样你的 thunk 就不用直接包含所有 URL 和查询逻辑。
  • requestId:此 thunk 调用的唯一随机 ID 值。用于跟踪单个请求的状态。
  • signal:一个 AbortController.signal 函数,可用于取消正在进行的请求。
  • rejectWithValue:一个帮助自定义 rejected action 内容的工具,如果 thunk 收到错误。

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

信息

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

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

添加通知列表

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

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 状态中读取项目列表,遍历它们,并为每个项目渲染内容。

我们还需要更新 <Navbar> 以添加一个“通知”选项卡,以及一个新的按钮来获取一些通知

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,以便我们可以导航到它

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

以下是“通知”选项卡目前的样子

Initial Notifications tab

显示新通知

每次我们点击“刷新通知”时,都会在我们的列表中添加一些新的通知条目。在一个真实的应用程序中,这些通知可能来自服务器,而我们正在查看 UI 的其他部分。我们可以通过在查看 <PostsList><UserPage> 时点击“刷新通知”来做类似的事情。但是,现在我们不知道有多少通知刚刚到达,如果我们一直点击按钮,可能会有很多我们还没有读过的通知。让我们添加一些逻辑来跟踪哪些通知已被阅读,哪些通知是“新的”。这将使我们能够在导航栏的“通知”选项卡上显示“未读”通知的数量作为徽章,并在不同的颜色中显示新通知。

我们的假 API 已经开始返回带有 isNewread 字段的通知条目,所以我们可以在代码中使用它们。

首先,我们将更新 notificationsSlice,使其包含一个将所有通知标记为已读的 reducer,以及一些用于处理将现有通知标记为“非新”的逻辑。

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 钩子中调度该操作。我们还希望在页面中的任何通知列表条目中添加一个额外的类名,以突出显示它们。

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" 操作。这是为什么呢?

假设我们在查看 <PostsList> 时获取了一些通知,然后点击了“通知”选项卡。<NotificationsList> 组件将挂载,并且 useLayoutEffect 回调将在第一次渲染后运行并调度 allNotificationsRead。我们的 notificationsSlice 将通过更新存储中的通知条目来处理它。这将创建一个新的 state.notifications 数组,其中包含不可变更新的条目,这将迫使我们的组件再次渲染,因为它看到 useSelector 返回了一个新数组,并且 useLayoutEffect 钩子再次运行并调度 allNotificationsRead 第二次。reducer 再次运行,但这次没有数据更改,因此组件不会重新渲染。

我们可以通过几种方法来避免第二次调度,例如将逻辑拆分为在组件挂载时调度一次,并且仅在通知数组的大小发生变化时再次调度。但是,这实际上并没有造成任何伤害,所以我们可以将其保留。

这实际上表明,有可能调度一个操作,而没有任何状态更改发生。请记住,始终由你的 reducer 来决定是否需要更新任何状态,而“什么都不需要发生”是 reducer 可以做出的一个有效决定

以下是我们实现“新/已读”行为后通知选项卡的外观。

New notifications

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

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

提高渲染性能

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

调查渲染行为

我们可以使用 React DevTools Profiler 查看一些图表,这些图表显示了在状态更新时哪些组件重新渲染。尝试点击到单个用户的 <UserPage>。打开浏览器的 DevTools,在 React "Profiler" 选项卡中,点击左上角的圆形 "Record" 按钮。然后,点击我们应用程序中的 "Refresh Notifications" 按钮,并在 React DevTools Profiler 中停止录制。你应该会看到一个类似这样的图表

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

我们可以看到 <Navbar> 重新渲染了,这是有道理的,因为它必须显示更新的 "unread notifications" 徽章。但是,为什么我们的 <UserPage> 重新渲染了?

如果我们检查 Redux DevTools 中的最后几个分派的操作,我们可以看到只有通知状态更新了。由于 <UserPage> 没有读取任何通知,它不应该重新渲染。组件肯定出了问题。

如果我们仔细查看 <UserPage>,就会发现一个具体的问题

"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 会在每次分派操作时重新运行,并且如果我们返回一个新的引用值,它会强制组件重新渲染。

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

记忆选择器函数

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

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

到目前为止,我们一直在自己编写选择器函数,只是为了避免复制粘贴从存储中读取数据的代码。如果有一种方法可以使我们的选择器函数记忆化,那就太好了。

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

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

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 会将所有参数传递到每个输入选择器中。这些输入选择器返回的内容将成为输出选择器的参数。

在这种情况下,我们知道我们需要所有帖子的数组和用户 ID 作为输出选择器的两个参数。我们可以重用现有的 selectAllPosts 选择器来提取帖子数组。由于用户 ID 是我们传递给 selectPostsByUser 的第二个参数,我们可以编写一个小的选择器,它只返回 userId

然后,我们的输出选择器接收 postsuserId,并返回仅针对该用户的过滤后的帖子数组。

如果我们尝试多次调用 selectPostsByUser,它只会重新运行输出选择器,如果 postsuserId 发生了变化。

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 分析器,我们应该会看到 <UserPage> 这次没有重新渲染。

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 应用程序性能的宝贵工具,因为它们可以帮助我们避免不必要的重新渲染,还可以避免在输入数据没有改变的情况下进行可能很复杂或很昂贵的计算。

信息

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

调查帖子列表

如果我们回到我们的 <PostsList> 并尝试点击其中一篇帖子的反应按钮,同时捕获 React Profiler 跟踪,我们会发现不仅 <PostsList> 和更新后的 <PostExcerpt> 实例渲染了,所有<PostExcerpt> 组件都渲染了。

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

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

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

这对我们的小示例应用程序来说不是一个严重的问题,但在一个更大的现实世界应用程序中,我们可能有一些非常长的列表或非常大的组件树,让所有这些额外的组件重新渲染可能会降低速度。

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

首先,我们可以用 React.memo() 包装 <PostExcerpt> 组件,这将确保它内部的组件只有在 props 实际发生变化时才会重新渲染。这实际上会非常有效 - 试试看,看看会发生什么。

"features/posts/PostsList.js
let PostExcerpt = ({ post }) => {
// omit logic
}

PostExcerpt = React.memo(PostExcerpt)

另一个选择是重写 <PostsList>,使其只从 store 中选择一个帖子 ID 列表,而不是整个 posts 数组,并重写 <PostExcerpt>,使其接收一个 postId prop 并调用 useSelector 来读取它需要的帖子对象。如果 <PostsList> 获取到与之前相同的 ID 列表,它就不需要重新渲染,因此只有我们更改的 <PostExcerpt> 组件需要渲染。

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

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

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

数据规范化

您已经看到,我们的大部分逻辑都是通过 ID 字段查找项目。由于我们一直将数据存储在数组中,这意味着我们必须使用 array.find() 遍历数组中的所有项目,直到找到具有我们正在查找的 ID 的项目。

实际上,这并不需要很长时间,但是如果我们有包含数百或数千个项目的数组,那么遍历整个数组以查找一个项目就成了浪费的努力。我们需要一种方法来直接根据 ID 查找单个项目,而无需检查所有其他项目。这个过程被称为“规范化”。

规范化状态结构

“规范化状态”意味着

  • 我们只在状态中保留每个特定数据片段的一个副本,因此没有重复
  • 已规范化的数据保存在一个查找表中,其中项目 ID 是键,项目本身是值。
  • 还可能存在特定项目类型的所有 ID 的数组

JavaScript 对象可以用作查找表,类似于其他语言中的“映射”或“字典”。以下是 user 对象组的规范化状态可能的样子

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

这使得通过 ID 轻松找到特定的 user 对象,而无需遍历数组中的所有其他用户对象

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

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

使用 createEntityAdapter 管理规范化状态

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

这有几个好处

  • 我们不必自己编写代码来管理规范化
  • createEntityAdapter 的预构建 reducer 函数处理常见的用例,例如“添加所有这些项目”、“更新一个项目”或“删除多个项目”。
  • createEntityAdapter 可以根据项目的內容以排序顺序保持 ID 数组,并且只有在添加/删除项目或排序顺序更改时才会更新该数组。

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

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

适配器对象还具有一个 getSelectors 函数。您可以传入一个选择器,该选择器从 Redux 根状态返回此特定状态切片,它将生成诸如 selectAllselectById 之类的选择器。

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

更新帖子切片

考虑到这一点,让我们更新我们的 postsSlice 以使用 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)
)

那里有很多事情!让我们分解一下。

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

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

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

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

当我们收到 addNewPost.fulfilled 动作时,我们知道需要将那个新的帖子对象添加到我们的状态中。我们可以直接使用适配器函数作为 reducer,因此我们将 postsAdapter.addOne 作为 reducer 函数来处理该动作。

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

优化帖子列表

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

我们将更新 <PostsList> 以仅读取排序后的帖子 ID 数组,并将 postId 传递给每个 <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 组件性能配置文件时点击其中一个帖子的反应按钮,我们应该看到只有那个组件重新渲染了。

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

转换其他切片

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

转换用户切片

usersSlice 相当小,所以我们只需要更改几件事。

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 来实现它。

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

转换通知切片

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

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()来帮助设置切片。

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

就这样... 我们完成了对 Redux Toolkit 的核心概念和功能的学习!

你学到了什么

我们在本节中构建了许多新行为。让我们看看应用在所有这些更改后的样子

以下是本节中涵盖的内容

总结
  • 记忆化选择器函数可用于优化性能
    • Redux Toolkit 从 Reselect 重新导出createSelector函数,该函数生成记忆化选择器
    • 记忆化选择器仅在输入选择器返回新值时才会重新计算结果
    • 记忆化可以跳过昂贵的计算,并确保返回相同的结果引用
  • 您可以使用多种模式来优化 React 组件使用 Redux 的渲染
    • 避免在useSelector中创建新的对象/数组引用 - 这些会导致不必要的重新渲染
    • 记忆化选择器函数可以传递给useSelector以优化渲染
    • useSelector可以接受一个替代比较函数,如shallowEqual,而不是引用相等
    • 组件可以用React.memo()包装,以便仅在它们的道具更改时重新渲染
    • 列表渲染可以通过让列表父组件只读取项目 ID 数组,将 ID 传递给列表项目子组件,并在子组件中按 ID 检索项目来优化
  • 规范化状态结构是存储项目的推荐方法
    • “规范化”意味着没有数据重复,并将项目存储在按项目 ID 查找的表中
    • 规范化状态形状通常看起来像{ids: [], entities: {}}
  • Redux Toolkit 的 createEntityAdapter API 有助于管理切片中的规范化数据
    • 可以通过传递 sortComparer 选项来保持项目 ID 的排序
    • 适配器对象包括
      • adapter.getInitialState,它可以接受其他状态字段,例如加载状态
      • 针对常见情况的预构建 reducer,例如 setAlladdManyupsertOneremoveMany
      • adapter.getSelectors,它生成选择器,例如 selectAllselectById

下一步?

Redux Essentials 教程中还有几个部分,但这是一个暂停并实践所学内容的好地方。

到目前为止,我们在本教程中介绍的概念足以让你开始使用 React 和 Redux 构建自己的应用程序。现在是尝试自己进行项目的好时机,以巩固这些概念并了解它们在实践中的工作方式。如果你不确定要构建什么类型的项目,请查看 这个应用程序项目想法列表 以获得一些灵感。

Redux Toolkit 还包含一个强大的数据获取和缓存 API,称为“RTK Query”。RTK Query 是一个可选的附加组件,可以完全消除编写任何数据获取逻辑的需要。在 第 7 部分:RTK Query 基础 中,你将了解什么是 RTK Query、它解决了什么问题以及如何使用它在应用程序中获取和使用缓存数据。

Redux Essentials 教程侧重于“如何正确使用 Redux”,而不是“它是如何工作的”或“为什么它以这种方式工作”。特别是,Redux Toolkit 是一组更高级的抽象和实用程序,了解 RTK 中的抽象实际上为你做了什么将很有帮助。阅读 "Redux 基础"教程 将帮助你了解如何“手动”编写 Redux 代码,以及为什么我们推荐 Redux Toolkit 作为编写 Redux 逻辑的默认方式。

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之道,第一部分:实现与意图Redux之道,第二部分:实践与哲学 中的文章。

如果您在使用 Redux 时遇到问题,欢迎加入 Discord 上 Reactiflux 服务器的 #redux 频道

感谢您阅读本教程,我们希望您在使用 Redux 构建应用程序时玩得开心!