跳至主要内容

Redux Essentials,第 4 部分:使用 Redux 数据

您将学到什么
  • 在多个 React 组件中使用 Redux 数据
  • 组织分派操作的逻辑
  • 在 reducer 中编写更复杂的更新逻辑
先决条件

介绍

第 3 部分:基本 Redux 数据流 中,我们了解了如何从一个空的 Redux+React 项目设置开始,添加一个新的状态切片,并创建可以从 Redux store 读取数据和分派操作来更新该数据的 React 组件。我们还查看了数据如何在应用程序中流动,组件分派操作,reducer 处理操作并返回新的状态,以及组件读取新的状态并重新渲染 UI。

现在您已经了解了编写 Redux 逻辑的核心步骤,我们将使用相同的步骤为我们的社交媒体提要添加一些新功能,使其更加实用:查看单个帖子、编辑现有帖子、显示帖子作者详细信息、帖子时间戳和反应按钮。

信息

提醒一下,代码示例侧重于每个部分的关键概念和更改。有关应用程序中的完整更改,请参阅 CodeSandbox 项目和项目仓库中的 tutorial-steps 分支

显示单个帖子

由于我们能够将新帖子添加到 Redux 存储中,我们可以添加更多使用帖子数据以不同方式的功能。

目前,我们的帖子条目显示在主提要页面中,但如果文本过长,我们只会显示内容的摘录。能够在单独的页面上查看单个帖子条目将非常有用。

创建单个帖子页面

首先,我们需要在 posts 功能文件夹中添加一个新的 SinglePostPage 组件。我们将使用 React Router 在页面 URL 类似于 /posts/123 时显示此组件,其中 123 部分应该是我们要显示的帖子的 ID。

features/posts/SinglePostPage.js
import React from 'react'
import { useSelector } from 'react-redux'

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

const post = useSelector(state =>
state.posts.find(post => post.id === postId)
)

if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}

return (
<section>
<article className="post">
<h2>{post.title}</h2>
<p className="post-content">{post.content}</p>
</article>
</section>
)
}

React Router 将传递一个 match 对象作为道具,其中包含我们正在查找的 URL 信息。当我们设置路由以呈现此组件时,我们将告诉它将 URL 的第二部分解析为名为 postId 的变量,并且我们可以从 match.params 中读取该值。

获得 postId 值后,我们可以在选择器函数中使用它来从 Redux 存储中找到正确的帖子对象。我们知道 state.posts 应该是一个包含所有帖子对象的数组,因此我们可以使用 Array.find() 函数遍历数组并返回具有我们正在查找的 ID 的帖子条目。

重要的是要注意,只要从 useSelector 返回的值更改为新的引用,组件就会重新渲染。组件应始终尝试从存储中选择尽可能少的所需数据,这将有助于确保它仅在真正需要时才渲染。

我们可能在商店中没有匹配的帖子条目——也许用户尝试直接输入 URL,或者我们没有加载正确的数据。如果发生这种情况,find() 函数将返回 undefined 而不是实际的帖子对象。我们的组件需要检查这种情况并通过在页面中显示“帖子未找到!”消息来处理它。

假设我们在商店中拥有正确的帖子对象,useSelector 将返回该对象,我们可以使用它在页面中渲染帖子的标题和内容。

您可能会注意到,这看起来与我们在 <PostsList> 组件主体中使用的逻辑非常相似,我们在其中循环遍历整个 posts 数组以在主提要中显示帖子摘要。我们可以尝试提取一个可以在两个地方使用的 Post 组件,但我们显示帖子摘要和整个帖子之间已经存在一些差异。通常最好将内容分开编写一段时间,即使存在一些重复,然后我们可以在稍后决定代码的不同部分是否足够相似,以便我们可以真正提取一个可重用的组件。

添加单个帖子路由

现在我们有了 <SinglePostPage> 组件,我们可以定义一个路由来显示它,并在首页提要中添加指向每个帖子的链接。

我们将在 App.js 中导入 SinglePostPage,并添加路由

App.js
import { PostsList } from './features/posts/PostsList'
import { AddPostForm } from './features/posts/AddPostForm'
import { SinglePostPage } from './features/posts/SinglePostPage'

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Switch>
<Route
exact
path="/"
render={() => (
<React.Fragment>
<AddPostForm />
<PostsList />
</React.Fragment>
)}
/>
<Route exact path="/posts/:postId" component={SinglePostPage} />
<Redirect to="/" />
</Switch>
</div>
</Router>
)
}

然后,在 <PostsList> 中,我们将更新列表渲染逻辑以包含一个路由到该特定帖子的 <Link>

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

export const PostsList = () => {
const posts = useSelector(state => state.posts)

const renderedPosts = posts.map(post => (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<p className="post-content">{post.content.substring(0, 100)}</p>
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
</article>
))

return (
<section className="posts-list">
<h2>Posts</h2>
{renderedPosts}
</section>
)
}

由于我们现在可以点击进入不同的页面,因此在 <Navbar> 组件中也提供一个返回主帖子页面的链接将很有帮助

app/Navbar.js
import React from 'react'

import { Link } from 'react-router-dom'

export const Navbar = () => {
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>

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

编辑帖子

作为用户,在写完一篇帖子后保存,却发现某个地方写错了,这真的很烦人。如果能够在创建帖子后编辑帖子,那就太方便了。

让我们添加一个新的 <EditPostForm> 组件,它能够接收一个已有的帖子 ID,从 store 中读取该帖子,允许用户编辑标题和帖子内容,然后保存更改以更新 store 中的帖子。

更新帖子条目

首先,我们需要更新我们的 postsSlice,创建一个新的 reducer 函数和一个 action,以便 store 知道如何实际更新帖子。

createSlice() 调用中,我们应该在 reducers 对象中添加一个新的函数。请记住,这个 reducer 的名称应该能够很好地描述正在发生的事情,因为我们将在 Redux DevTools 中看到 reducer 名称作为 action 类型字符串的一部分,每当这个 action 被分派时。我们的第一个 reducer 被称为 postAdded,所以让我们将这个 reducer 命名为 postUpdated

为了更新一个帖子对象,我们需要知道

  • 正在更新的帖子的 ID,以便我们能够在状态中找到正确的帖子对象
  • 用户输入的新的 titlecontent 字段

Redux action 对象需要有一个 type 字段,它通常是一个描述性的字符串,并且可能还包含其他字段,其中包含有关发生事件的更多信息。按照惯例,我们通常将附加信息放在名为 action.payload 的字段中,但由我们决定 payload 字段包含什么 - 它可以是字符串、数字、对象、数组或其他东西。在这种情况下,由于我们有三个需要的信息,让我们计划让 payload 字段成为一个包含三个字段的对象。这意味着 action 对象将看起来像 {type: 'posts/postUpdated', payload: {id, title, content}}

默认情况下,由 createSlice 生成的 action 创建者期望你传入一个参数,并且该值将作为 action.payload 放入 action 对象中。因此,我们可以将包含这些字段的对象作为参数传递给 postUpdated action 创建者。

我们也知道,reducer 负责在分发动作时确定状态应该如何更新。鉴于此,我们应该让 reducer 根据 ID 找到正确的帖子对象,并专门更新该帖子的 `title` 和 `content` 字段。

最后,我们需要导出 `createSlice` 为我们生成的 action creator 函数,以便 UI 在用户保存帖子时可以分发新的 `postUpdated` 动作。

考虑到所有这些要求,以下是完成后的 `postsSlice` 定义:

features/posts/postsSlice.js
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded(state, action) {
state.push(action.payload)
},
postUpdated(state, action) {
const { id, title, content } = action.payload
const existingPost = state.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})

export const { postAdded, postUpdated } = postsSlice.actions

export default postsSlice.reducer

创建编辑帖子表单

我们新的 `` 组件看起来类似于 ``,但逻辑需要稍微不同。我们需要从 store 中检索正确的 `post` 对象,然后使用它来初始化组件中的状态字段,以便用户可以进行更改。用户完成更改后,我们将更改后的标题和内容值保存回 store。我们还将使用 React Router 的历史记录 API 切换到单个帖子页面并显示该帖子。

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

import { postUpdated } from './postsSlice'

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

const post = useSelector(state =>
state.posts.find(post => post.id === postId)
)

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

const dispatch = useDispatch()
const history = useHistory()

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

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

return (
<section>
<h2>Edit Post</h2>
<form>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
placeholder="What's on your mind?"
value={title}
onChange={onTitleChanged}
/>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
value={content}
onChange={onContentChanged}
/>
</form>
<button type="button" onClick={onSavePostClicked}>
Save Post
</button>
</section>
)
}

与 `SinglePostPage` 一样,我们需要将其导入到 `App.js` 中,并添加一个路由,该路由将使用 `postId` 作为路由参数来渲染此组件。

App.js
import { PostsList } from './features/posts/PostsList'
import { AddPostForm } from './features/posts/AddPostForm'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Switch>
<Route
exact
path="/"
render={() => (
<React.Fragment>
<AddPostForm />
<PostsList />
</React.Fragment>
)}
/>
<Route exact path="/posts/:postId" component={SinglePostPage} />
<Route exact path="/editPost/:postId" component={EditPostForm} />
<Redirect to="/" />
</Switch>
</div>
</Router>
)
}

我们还应该在 `SinglePostPage` 中添加一个新的链接,该链接将路由到 `EditPostForm`,例如

features/post/SinglePostPage.js
import { Link } from 'react-router-dom'

export const SinglePostPage = ({ match }) => {

// omit other contents

<p className="post-content">{post.content}</p>
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>

准备动作负载

我们刚刚看到,来自 `createSlice` 的 action creator 通常期望一个参数,该参数将成为 `action.payload`。这简化了最常见的用法模式,但有时我们需要做更多工作来准备动作对象的內容。在我们的 `postAdded` 动作的情况下,我们需要为新帖子生成一个唯一的 ID,并且还需要确保负载是一个类似于 `{id, title, content}` 的对象。

现在,我们在 React 组件中生成 ID 并创建负载对象,并将负载对象传递给postAdded。但是,如果我们需要从不同的组件分派相同的操作,或者准备负载的逻辑很复杂怎么办?我们必须在每次想要分派操作时都复制该逻辑,而且我们强迫组件确切地知道此操作的负载应该是什么样子。

注意

如果操作需要包含唯一的 ID 或其他随机值,请始终先生成该值并将其放入操作对象中。Reducer 永远不应该计算随机值,因为这会使结果不可预测。

如果我们手动编写postAdded操作创建者,我们可以将设置逻辑放在其中。

// hand-written action creator
function postAdded(title, content) {
const id = nanoid()
return {
type: 'posts/postAdded',
payload: { id, title, content }
}
}

但是,Redux Toolkit 的createSlice为我们生成了这些操作创建者。这使得代码更短,因为我们不必自己编写它们,但我们仍然需要一种方法来定制action.payload的内容。

幸运的是,createSlice允许我们在编写 reducer 时定义一个“准备回调”函数。“准备回调”函数可以接受多个参数,生成像唯一 ID 这样的随机值,并运行任何其他同步逻辑来决定哪些值进入操作对象。然后它应该返回一个包含payload字段的对象。(返回对象也可以包含一个meta字段,它可以用来向操作添加额外的描述性值,以及一个error字段,它应该是一个布尔值,表示此操作是否代表某种错误。)

createSlicereducers字段中,我们可以将其中一个字段定义为一个类似于{reducer, prepare}的对象。

features/posts/postsSlice.js
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action) {
state.push(action.payload)
},
prepare(title, content) {
return {
payload: {
id: nanoid(),
title,
content
}
}
}
}
// other reducers here
}
})

现在我们的组件不必担心负载对象是什么样子 - 操作创建者将负责以正确的方式将它组合在一起。因此,我们可以更新组件,以便它在分派postAdded时将titlecontent作为参数传递。

features/posts/AddPostForm.js
const onSavePostClicked = () => {
if (title && content) {
dispatch(postAdded(title, content))
setTitle('')
setContent('')
}
}

用户和帖子

到目前为止,我们只有一个状态切片。逻辑定义在postsSlice.js中,数据存储在state.posts中,我们所有的组件都与帖子功能相关。真实的应用程序可能会有许多不同的状态切片,以及几个不同的“功能文件夹”用于 Redux 逻辑和 React 组件。

如果没有其他用户参与,你就无法拥有一个“社交媒体”应用程序。让我们添加跟踪应用程序中用户列表的功能,并更新与帖子相关的功能以利用这些数据。

添加用户切片

由于“用户”的概念不同于“帖子”的概念,我们希望将用户的代码和数据与帖子的代码和数据分开。我们将添加一个新的features/users文件夹,并将usersSlice文件放在其中。与帖子切片一样,现在我们将添加一些初始条目,以便我们有数据可以使用。

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

const initialState = [
{ id: '0', name: 'Tianna Jenkins' },
{ id: '1', name: 'Kevin Grant' },
{ id: '2', name: 'Madison Price' }
]

const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {}
})

export default usersSlice.reducer

现在,我们不需要实际更新数据,因此我们将reducers字段保留为空对象。(我们将在后面的部分中回到这一点。)

与之前一样,我们将usersReducer导入到我们的存储文件并将其添加到存储设置中

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

import postsReducer from '../features/posts/postsSlice'
import usersReducer from '../features/users/usersSlice'

export default configureStore({
reducer: {
posts: postsReducer,
users: usersReducer
}
})

为帖子添加作者

我们应用程序中的每篇文章都是由我们的用户之一撰写的,每次我们添加新文章时,我们都应该跟踪哪个用户写了该文章。在一个真实的应用程序中,我们将拥有某种state.currentUser字段来跟踪当前登录的用户,并在他们添加文章时使用该信息。

为了使此示例更简单,我们将更新我们的<AddPostForm>组件,以便我们可以从下拉列表中选择用户,并将该用户的 ID 作为帖子的一部分包含在内。一旦我们的帖子对象包含用户 ID,我们就可以使用它来查找用户的姓名并在 UI 中的每个单独帖子中显示它。

首先,我们需要更新我们的postAdded操作创建者以接受用户 ID 作为参数,并将该 ID 包含在操作中。(我们还将更新initialState中的现有帖子条目,使其具有post.user字段,其中包含示例用户 ID 之一。)

features/posts/postsSlice.js
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action) {
state.push(action.payload)
},
prepare(title, content, userId) {
return {
payload: {
id: nanoid(),
title,
content,
user: userId
}
}
}
}
// other reducers
}
})

现在,在我们的<AddPostForm>中,我们可以使用useSelector从存储中读取用户列表并将其显示为下拉菜单。然后,我们将获取所选用户的 ID 并将其传递给postAdded操作创建者。趁此机会,我们可以向我们的表单添加一些验证逻辑,以便用户只有在标题和内容输入框中包含实际文本时才能单击“保存帖子”按钮

features/posts/AddPostForm.js
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'

import { postAdded } from './postsSlice'

export const AddPostForm = () => {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [userId, setUserId] = useState('')

const dispatch = useDispatch()

const users = useSelector(state => state.users)

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

const onSavePostClicked = () => {
if (title && content) {
dispatch(postAdded(title, content, userId))
setTitle('')
setContent('')
}
}

const canSave = Boolean(title) && Boolean(content) && Boolean(userId)

const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))

return (
<section>
<h2>Add a New Post</h2>
<form>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
placeholder="What's on your mind?"
value={title}
onChange={onTitleChanged}
/>
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" value={userId} onChange={onAuthorChanged}>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
value={content}
onChange={onContentChanged}
/>
<button type="button" onClick={onSavePostClicked} disabled={!canSave}>
Save Post
</button>
</form>
</section>
)
}

现在,我们需要一种方法来在我们的帖子列表项和<SinglePostPage>中显示帖子的作者姓名。由于我们希望在多个地方显示这种信息,因此我们可以创建一个PostAuthor组件,该组件接受用户 ID 作为道具,查找正确的用户对象,并格式化用户的姓名

features/posts/PostAuthor.js
import React from 'react'
import { useSelector } from 'react-redux'

export const PostAuthor = ({ userId }) => {
const author = useSelector(state =>
state.users.find(user => user.id === userId)
)

return <span>by {author ? author.name : 'Unknown author'}</span>
}

请注意,我们在每个组件中都遵循相同的模式。任何需要从 Redux store 读取数据的组件都可以使用 useSelector hook,并提取它需要的特定数据片段。此外,许多组件可以同时访问 Redux store 中的相同数据。

现在,我们可以将 PostAuthor 组件导入 PostsList.jsSinglePostPage.js,并将其渲染为 <PostAuthor userId={post.user} />,每次添加帖子条目时,所选用户的姓名都应该显示在渲染的帖子中。

更多帖子功能

此时,我们可以创建和编辑帖子。让我们添加一些额外的逻辑,使我们的帖子提要更有用。

存储帖子的日期

社交媒体提要通常按帖子创建的时间排序,并以相对描述(如“5 小时前”)显示帖子创建时间。为此,我们需要开始跟踪帖子条目的 date 字段。

post.user 字段类似,我们将更新 postAdded prepare 回调,以确保在调度操作时始终包含 post.date。但是,它不是另一个将传入的参数。我们希望始终使用调度操作时的确切时间戳,因此我们将让 prepare 回调自行处理。

注意

Redux 操作和状态应该只包含纯 JS 值,例如对象、数组和基本类型。不要将类实例、函数或其他不可序列化值放入 Redux 中!.

由于我们不能将 Date 类实例直接放入 Redux store,因此我们将跟踪 post.date 值作为时间戳字符串

features/posts/postsSlice.js
    postAdded: {
reducer(state, action) {
state.push(action.payload)
},
prepare(title, content, userId) {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId,
},
}
},
},

就像帖子作者一样,我们需要在 <PostsList><SinglePostPage> 组件中显示相对时间戳描述。我们将添加一个 <TimeAgo> 组件来处理将时间戳字符串格式化为相对描述。像 date-fns 这样的库有一些有用的实用程序函数用于解析和格式化日期,我们可以在此处使用它们。

features/posts/TimeAgo.js
import React from 'react'
import { parseISO, formatDistanceToNow } from 'date-fns'

export const TimeAgo = ({ timestamp }) => {
let timeAgo = ''
if (timestamp) {
const date = parseISO(timestamp)
const timePeriod = formatDistanceToNow(date)
timeAgo = `${timePeriod} ago`
}

return (
<span title={timestamp}>
&nbsp; <i>{timeAgo}</i>
</span>
)
}

对帖子列表进行排序

我们目前的 <PostsList> 以帖子在 Redux 存储中保存的相同顺序显示所有帖子。我们的示例将最旧的帖子放在最前面,并且每次添加新帖子时,它都会添加到帖子数组的末尾。这意味着最新的帖子始终位于页面底部。

通常,社交媒体提要会首先显示最新的帖子,然后向下滚动以查看旧帖子。即使数据在存储中以最旧到最新的顺序保存,我们也可以在 <PostsList> 组件中重新排序数据,以便最新的帖子排在最前面。理论上,由于我们知道 state.posts 数组已经排序,我们可以直接反转列表。但是,最好还是自己排序,以确保万无一失。

由于 array.sort() 会修改现有数组,因此我们需要复制 state.posts 并对该副本进行排序。我们知道我们的 post.date 字段被保留为日期时间戳字符串,我们可以直接比较它们以按正确顺序对帖子进行排序。

features/posts/PostsList.js
// Sort posts in reverse chronological order by datetime string
const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))

const renderedPosts = orderedPosts.map(post => {
return (
<article className="post-excerpt" key={post.id}>
<h3>{post.title}</h3>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content.substring(0, 100)}</p>
<Link to={`/posts/${post.id}`} className="button muted-button">
View Post
</Link>
</article>
)
})

我们还需要在 postsSlice.js 中将 date 字段添加到 initialState。我们将在此处再次使用 date-fns 从当前日期/时间减去分钟,以便它们彼此不同。

features/posts/postsSlice.js
import { createSlice, nanoid } from '@reduxjs/toolkit'
import { sub } from 'date-fns'

const initialState = [
{
// omitted fields
content: 'Hello!',
date: sub(new Date(), { minutes: 10 }).toISOString()
},
{
// omitted fields
content: 'More text',
date: sub(new Date(), { minutes: 5 }).toISOString()
}
]

帖子反应按钮

我们还有另一个新功能要添加到本节中。现在,我们的帖子有点无聊。我们需要让它们更令人兴奋,还有什么比让朋友们在我们的帖子中添加反应表情符号更好的方法呢?

我们将在每个帖子底部,在 <PostsList><SinglePostPage> 中添加一行表情符号反应按钮。每次用户点击一个反应按钮时,我们需要更新 Redux 存储中该帖子的匹配计数字段。由于反应计数数据位于 Redux 存储中,因此在应用程序的不同部分之间切换应该始终在使用该数据的任何组件中显示相同的值。

与帖子作者和时间戳一样,我们希望在显示帖子的所有地方使用它,因此我们将创建一个 <ReactionButtons> 组件,该组件将 post 作为道具。我们将从仅在内部显示按钮开始,并显示每个按钮的当前反应计数。

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

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

export const ReactionButtons = ({ post }) => {
const reactionButtons = Object.entries(reactionEmoji).map(([name, emoji]) => {
return (
<button key={name} type="button" className="muted-button reaction-button">
{emoji} {post.reactions[name]}
</button>
)
})

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

我们的数据中还没有 post.reactions 字段,因此我们需要更新 initialState 帖子对象和我们的 postAdded prepare 回调函数,以确保每个帖子都包含该数据,例如 reactions: {thumbsUp: 0, hooray: 0, heart: 0, rocket: 0, eyes: 0}

现在,我们可以定义一个新的 reducer,它将在用户点击反应按钮时处理更新帖子的反应计数。

与编辑帖子一样,我们需要知道帖子的 ID 以及用户点击了哪个反应按钮。我们将让我们的 action.payload 是一个看起来像 {id, reaction} 的对象。然后,reducer 可以找到正确的帖子对象,并更新正确的反应字段。

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
// other reducers
}
})

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

正如我们已经看到的那样,createSlice 允许我们在 reducer 中编写“可变”逻辑。如果我们没有使用 createSlice 和 Immer 库,existingPost.reactions[reaction]++ 行确实会改变现有的 post.reactions 对象,这可能会导致我们应用程序其他地方出现错误,因为我们没有遵循 reducer 的规则。但是,由于我们确实使用了 createSlice,因此我们可以以更简单的方式编写这种更复杂的更新逻辑,并让 Immer 完成将此代码转换为安全不可变更新的工作。

请注意,**我们的操作对象只包含描述发生事件所需的最小信息量**。我们知道需要更新哪个帖子,以及点击了哪个反应名称。我们可以计算新的反应计数器值并将其放入操作中,但**始终最好使操作对象尽可能小,并在 reducer 中进行状态更新计算**。这也意味着**reducer 可以包含尽可能多的逻辑来计算新状态**。

信息

使用 Immer 时,您可以“修改”现有状态对象,也可以自己返回新的状态值,但不能同时进行两者。有关更多详细信息,请参阅 Immer 文档指南中的 陷阱返回新数据

我们的最后一步是更新 <ReactionButtons> 组件,以便在用户点击按钮时分派 reactionAdded 操作。

features/posts/ReactionButtons.jsx
import React from 'react'
import { useDispatch } from 'react-redux'

import { reactionAdded } from './postsSlice'

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

export const ReactionButtons = ({ post }) => {
const dispatch = useDispatch()

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

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

现在,每次我们点击反应按钮时,计数器都应该递增。如果我们浏览应用程序的不同部分,我们应该看到每次查看此帖子时显示的正确计数器值,即使我们在 <PostsList> 中点击反应按钮,然后在 <SinglePostPage> 上单独查看帖子。

您学到了什么

以下是我们完成所有这些更改后应用程序的外观。

它实际上开始变得更有用和更有趣了!

在本节中,我们涵盖了许多信息和概念。让我们回顾一下需要记住的重要事项。

总结
  • 任何 React 组件都可以根据需要使用 Redux 存储中的数据。
    • 任何组件都可以读取 Redux 存储中的任何数据。
    • 多个组件可以读取相同的数据,即使是在同一时间。
    • 组件应该提取渲染自身所需的最少数据量。
    • 组件可以组合来自 props、state 和 Redux 存储的值来确定需要渲染的 UI。它们可以从存储中读取多个数据片段,并根据需要重塑数据以进行显示。
    • 任何组件都可以分派操作以导致状态更新。
  • Redux 操作创建者可以准备具有正确内容的操作对象。
    • createSlicecreateAction 可以接受一个“准备回调”,该回调返回操作负载。
    • 唯一 ID 和其他随机值应该放在操作中,而不是在 reducer 中计算。
  • Reducers 应该包含实际的状态更新逻辑
    • Reducers 可以包含任何用于计算下一个状态的逻辑
    • Action 对象应该只包含足够的信息来描述发生了什么

下一步?

现在你应该对在 Redux 存储和 React 组件中使用数据感到舒适。到目前为止,我们只使用了初始状态中或用户添加的数据。在 第 5 部分:异步逻辑和数据获取 中,我们将了解如何处理来自服务器 API 的数据。