Skip to main content

Redux 要点,第 4 部分:使用 Redux 数据

你将学到什么
  • 在多个 React 组件中使用 Redux 数据

    ¥Using Redux data in multiple React components

  • 组织调度动作的逻辑

    ¥Organizing logic that dispatches actions

  • 在 reducer 中编写更复杂的更新逻辑

    ¥Writing more complex update logic in reducers

介绍

¥Introduction

第 3 部分:基本 Redux 数据流 中,我们了解了如何从空的 Redux+React 项目设置开始,添加新的状态片,并创建可以从 Redux 存储读取数据并分派操作来更新该数据的 React 组件。我们还研究了数据如何流经应用,其中组件调度操作、Reducer 处理操作并返回新状态,以及组件读取新状态并重新渲染 UI。

¥In Part 3: Basic Redux Data Flow, we saw how to start from an empty Redux+React project setup, add a new slice of state, and create React components that can read data from the Redux store and dispatch actions to update that data. We also looked at how data flows through the application, with components dispatching actions, reducers processing actions and returning new state, and components reading the new state and rerendering the UI.

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

¥Now that you know the core steps to write Redux logic, we're going to use those same steps to add some new features to our social media feed that will make it more useful: viewing a single post, editing existing posts, showing post author details, post timestamps, and reaction buttons.

信息

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

¥As a reminder, the code examples focus on the key concepts and changes for each section. See the CodeSandbox projects and the tutorial-steps branch in the project repo for the complete changes in the application.

显示单个帖子

¥Showing Single Posts

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

¥Since we have the ability to add new posts to the Redux store, we can add some more features that use the post data in different ways.

目前,我们的帖子条目显示在主提要页面中,但如果文本太长,我们只会显示内容的摘录。如果能够在自己的页面上查看单个帖子条目,将会很有帮助。

¥Currently, our post entries are being shown in the main feed page, but if the text is too long, we only show an excerpt of the content. It would be helpful to have the ability to view a single post entry on its own page.

创建单个帖子页面

¥Creating a Single Post Page

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

¥First, we need to add a new SinglePostPage component to our posts feature folder. We'll use React Router to show this component when the page URL looks like /posts/123, where the 123 part should be the ID of the post we want to show.

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 信息的 prop。当我们设置渲染此组件的路由时,我们将告诉它将 URL 的第二部分解析为名为 postId 的变量,并且我们可以从 match.params 读取该值。

¥React Router will pass in a match object as a prop that contains the URL information we're looking for. When we set up the route to render this component, we're going to tell it to parse the second part of the URL as a variable named postId, and we can read that value from match.params.

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

¥Once we have that postId value, we can use it inside a selector function to find the right post object from the Redux store. We know that state.posts should be an array of all post objects, so we can use the Array.find() function to loop through the array and return the post entry with the ID we're looking for.

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

¥It's important to note that the component will re-render any time the value returned from useSelector changes to a new reference. Components should always try to select the smallest possible amount of data they need from the store, which will help ensure that it only renders when it actually needs to.

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

¥It's possible that we might not have a matching post entry in the store - maybe the user tried to type in the URL directly, or we don't have the right data loaded. If that happens, the find() function will return undefined instead of an actual post object. Our component needs to check for that and handle it by showing a "Post not found!" message in the page.

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

¥Assuming we do have the right post object in the store, useSelector will return that, and we can use it to render the title and content of the post in the page.

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

¥You might notice that this looks fairly similar to the logic we have in the body of our <PostsList> component, where we loop over the whole posts array to show post excerpts on the main feed. We could try to extract a Post component that could be used in both places, but there are already some differences in how we're showing a post excerpt and the whole post. It's usually better to keep writing things separately for a while even if there's some duplication, and then we can decide later if the different sections of code are similar enough that we can really extract a reusable component.

添加单个帖子路由

¥Adding the Single Post Route

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

¥Now that we have a <SinglePostPage> component, we can define a route to show it, and add links to each post in the front page feed.

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

¥We'll import SinglePostPage in App.js, and add the route:

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>

¥Then, in <PostsList>, we'll update the list rendering logic to include a <Link> that routes to that specific post:

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> 组件中提供返回主帖子页面的链接也会很有帮助:

¥And since we can now click through to a different page, it would also be helpful to have a link back to the main posts page in the <Navbar> component as well:

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

编辑帖子

¥Editing Posts

作为一个用户,写完一篇文章,保存它,然后意识到自己在某个地方犯了错误,这真的很烦人。创建帖子后能够对其进行编辑会很有用。

¥As a user, it's really annoying to finish writing a post, save it, and realize you made a mistake somewhere. Having the ability to edit a post after we created it would be useful.

让我们添加一个新的 <EditPostForm> 组件,该组件能够获取现有帖子 ID、从存储读取该帖子、让用户编辑标题和帖子内容,然后保存更改以更新存储中的帖子。

¥Let's add a new <EditPostForm> component that has the ability to take an existing post ID, read that post from the store, lets the user edit the title and post content, and then save the changes to update the post in the store.

更新帖子条目

¥Updating Post Entries

首先,我们需要更新 postsSlice 以创建新的 reducer 函数和操作,以便存储知道如何实际更新帖子。

¥First, we need to update our postsSlice to create a new reducer function and an action so that the store knows how to actually update posts.

createSlice() 调用内部,我们应该向 reducers 对象添加一个新函数。请记住,该 reducer 的名称应该能够很好地描述所发生的情况,因为每当调度此操作时,我们都会看到 reducer 名称作为操作类型字符串的一部分显示在 Redux DevTools 中。我们的第一个 reducer 称为 postAdded,所以我们将其称为 postUpdated

¥Inside of the createSlice() call, we should add a new function into the reducers object. Remember that the name of this reducer should be a good description of what's happening, because we're going to see the reducer name show up as part of the action type string in the Redux DevTools whenever this action is dispatched. Our first reducer was called postAdded, so let's call this one postUpdated.

为了更新 post 对象,我们需要知道:

¥In order to update a post object, we need to know:

  • 正在更新的帖子的 ID,以便我们可以在状态中找到正确的帖子对象

    ¥The ID of the post being updated, so that we can find the right post object in the state

  • 用户输入的新 titlecontent 字段

    ¥The new title and content fields that the user typed in

Redux 操作对象需要有一个 type 字段,该字段通常是一个描述性字符串,并且还可能包含其他字段,其中包含有关所发生事件的更多信息。按照惯例,我们通常将附加信息放在名为 action.payload 的字段中,但由我们决定 payload 字段包含什么内容 - 它可以是字符串、数字、对象、数组或其他东西。在本例中,由于我们需要三条信息,因此我们计划将 payload 字段设置为一个内部包含三个字段的对象。这意味着操作对象将类似于 {type: 'posts/postUpdated', payload: {id, title, content}}

¥Redux action objects are required to have a type field, which is normally a descriptive string, and may also contain other fields with more information about what happened. By convention, we normally put the additional info in a field called action.payload, but it's up to us to decide what the payload field contains - it could be a string, a number, an object, an array, or something else. In this case, since we have three pieces of information we need, let's plan on having the payload field be an object with the three fields inside of it. That means the action object will look like {type: 'posts/postUpdated', payload: {id, title, content}}.

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

¥By default, the action creators generated by createSlice expect you to pass in one argument, and that value will be put into the action object as action.payload. So, we can pass an object containing those fields as the argument to the postUpdated action creator.

我们还知道,reducer 负责确定在分派操作时实际应如何更新状态。鉴于此,我们应该让 reducer 根据 ID 找到正确的 post 对象,并专门更新该 post 中的 titlecontent 字段。

¥We also know that the reducer is responsible for determining how the state should actually be updated when an action is dispatched. Given that, we should have the reducer find the right post object based on the ID, and specifically update the title and content fields in that post.

最后,我们需要导出 createSlice 为我们生成的操作创建器函数,以便 UI 可以在用户保存帖子时调度新的 postUpdated 操作。

¥Finally, we'll need to export the action creator function that createSlice generated for us, so that the UI can dispatch the new postUpdated action when the user saves the post.

考虑到所有这些要求,完成后我们的 postsSlice 定义应如下所示:

¥Given all those requirements, here's how our postsSlice definition should look after we're done:

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

创建编辑帖子表单

¥Creating an Edit Post Form

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

¥Our new <EditPostForm> component will look similar to the <AddPostForm>, but the logic needs to be a bit different. We need to retrieve the right post object from the store, then use that to initialize the state fields in the component so the user can make changes. We'll save the changed title and content values back to the store after the user is done. We'll also use React Router's history API to switch over to the single post page and show that post.

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 作为路由参数来渲染此组件。

¥Like with SinglePostPage, we'll need to import it into App.js and add a route that will render this component with the postId as a route parameter.

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,例如:

¥We should also add a new link to our SinglePostPage that will route to EditPostForm, like:

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>

准备操作有效负载

¥Preparing Action Payloads

我们刚刚看到,createSlice 中的动作创建者通常期望一个参数,该参数将成为 action.payload。这简化了最常见的使用模式,但有时我们需要做更多的工作来准备操作对象的内容。在我们的 postAdded 操作中,我们需要为新帖子生成一个唯一的 ID,并且我们还需要确保有效负载是一个看起来像 {id, title, content} 的对象。

¥We just saw that the action creators from createSlice normally expect one argument, which becomes action.payload. This simplifies the most common usage pattern, but sometimes we need to do more work to prepare the contents of an action object. In the case of our postAdded action, we need to generate a unique ID for the new post, and we also need to make sure that the payload is an object that looks like {id, title, content}.

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

¥Right now, we're generating the ID and creating the payload object in our React component, and passing the payload object into postAdded. But, what if we needed to dispatch the same action from different components, or the logic for preparing the payload is complicated? We'd have to duplicate that logic every time we wanted to dispatch the action, and we're forcing the component to know exactly what the payload for this action should look like.

提醒

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

¥If an action needs to contain a unique ID or some other random value, always generate that first and put it in the action object. Reducers should never calculate random values, because that makes the results unpredictable.

如果我们手工编写 postAdded 动作创建器,我们可以自己将设置逻辑放入其中:

¥If we were writing the postAdded action creator by hand, we could have put the setup logic inside of it ourselves:

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

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

¥But, Redux Toolkit's createSlice is generating these action creators for us. That makes the code shorter because we don't have to write them ourselves, but we still need a way to customize the contents of action.payload.

幸运的是,createSlice 让我们在编写 reducer 时定义了 "准备回调" 函数。"准备回调" 函数可以采用多个参数,生成随机值(例如唯一 ID),并运行所需的任何其他同步逻辑来决定将哪些值放入操作对象中。然后它应该返回一个内部包含 payload 字段的对象。(返回对象还可能包含一个 meta 字段,该字段可用于向操作添加额外的描述性值,以及一个 error 字段,该字段应该是一个布尔值,指示该操作是否表示某种错误。)

¥Fortunately, createSlice lets us define a "prepare callback" function when we write a reducer. The "prepare callback" function can take multiple arguments, generate random values like unique IDs, and run whatever other synchronous logic is needed to decide what values go into the action object. It should then return an object with the payload field inside. (The return object may also contain a meta field, which can be used to add extra descriptive values to the action, and an error field, which should be a boolean indicating whether this action represents some kind of an error.)

createSlicereducers 字段内部,我们可以将其中一个字段定义为看起来像 {reducer, prepare} 的对象:

¥Inside of the reducers field in createSlice, we can define one of the fields as an object that looks like {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 作为参数:

¥Now our component doesn't have to worry about what the payload object looks like - the action creator will take care of putting it together the right way. So, we can update the component so that it passes in title and content as arguments when it dispatches postAdded:

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

用户和帖子

¥Users and Posts

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

¥So far, we only have one slice of state. The logic is defined in postsSlice.js, the data is stored in state.posts, and all of our components have been related to the posts feature. Real applications will probably have many different slices of state, and several different "feature folders" for the Redux logic and React components.

如果没有任何其他人参与,你就不可能拥有 "社交媒体" 应用。让我们添加跟踪应用中的用户列表的功能,并更新帖子相关功能以利用该数据。

¥You can't have a "social media" app if there aren't any other people involved. Let's add the ability to keep track of a list of users in our app, and update the post-related functionality to make use of that data.

添加用户切片

¥Adding a Users Slice

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

¥Since the concept of "users" is different than the concept of "posts", we want to keep the code and data for the users separated from the code and data for posts. We'll add a new features/users folder, and put a usersSlice file in there. Like with the posts slice, for now we'll add some initial entries so that we have data to work with.

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 字段保留为空对象。(我们将在后面的部分中讨论这一点。)

¥For now, we don't need to actually update the data, so we'll leave the reducers field as an empty object. (We'll come back to this in a later section.)

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

¥As before, we'll import the usersReducer into our store file and add it to the store setup:

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

添加帖子作者

¥Adding Authors for Posts

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

¥Every post in our app was written by one of our users, and every time we add a new post, we should keep track of which user wrote that post. In a real app, we'd have some sort of a state.currentUser field that keeps track of the current logged-in user, and use that information whenever they add a post.

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

¥To keep things simpler for this example, we'll update our <AddPostForm> component so that we can select a user from a dropdown list, and we'll include that user's ID as part of the post. Once our post objects have a user ID in them, we can use that to look up the user's name and show it in each individual post in the UI.

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

¥First, we need to update our postAdded action creator to accept a user ID as an argument, and include that in the action. (We'll also update the existing post entries in initialState to have a post.user field with one of the example user IDs.)

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 操作创建者。当我们这样做时,我们可以向表单添加一些验证逻辑,以便用户只能在标题和内容输入中包含一些实际文本时单击 "保存帖子" 按钮:

¥Now, in our <AddPostForm>, we can read the list of users from the store with useSelector and show them as a dropdown. We'll then take the ID of the selected user and pass that to the postAdded action creator. While we're at it, we can add a bit of validation logic to our form so that the user can only click the "Save Post" button if the title and content inputs have some actual text in them:

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 作为 prop,查找正确的用户对象,并格式化用户名:

¥Now, we need a way to show the name of the post's author inside of our post list items and <SinglePostPage>. Since we want to show this same kind of info in more than one place, we can make a PostAuthor component that takes a user ID as a prop, looks up the right user object, and formats the user's name:

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 存储读取数据的组件都可以使用 useSelector 钩子,并提取它需要的特定数据片段。此外,许多组件可以同时访问 Redux 存储中的相同数据。

¥Notice that we're following the same pattern in each of our components as we go. Any component that needs to read data from the Redux store can use the useSelector hook, and extract the specific pieces of data that it needs. Also, many components can access the same data in the Redux store at the same time.

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

¥We can now import the PostAuthor component into both PostsList.js and SinglePostPage.js, and render it as <PostAuthor userId={post.user} />, and every time we add a post entry, the selected user's name should show up inside of the rendered post.

更多帖子功能

¥More Post Features

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

¥At this point, we can create and edit posts. Let's add some additional logic to make our posts feed more useful.

存储帖子日期

¥Storing Dates for Posts

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

¥Social media feeds are typically sorted by when the post was created, and show us the post creation time as a relative description like "5 hours ago". In order to do that, we need to start tracking a date field for our post entries.

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

¥Like with the post.user field, we'll update our postAdded prepare callback to make sure that post.date is always included when the action is dispatched. However, it's not another parameter that will be passed in. We want to always use the exact timestamp from when the action is dispatched, so we'll let the prepare callback handle that itself.

提醒

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

¥Redux actions and state should only contain plain JS values like objects, arrays, and primitives. Don't put class instances, functions, or other non-serializable values into Redux!.

由于我们不能只将 Date 类实例放入 Redux 存储中,因此我们将跟踪 post.date 值作为时间戳字符串:

¥Since we can't just put a Date class instance into the Redux store, we'll track the post.date value as a timestamp string:

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 这样的库有一些有用的实用函数来解析和格式化日期,我们可以在这里使用:

¥Like with post authors, we need to show the relative timestamp description in both our <PostsList> and <SinglePostPage> components. We'll add a <TimeAgo> component to handle formatting a timestamp string as a relative description. Libraries like date-fns have some useful utility functions for parsing and formatting dates, which we can use here:

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

对帖子列表进行排序

¥Sorting the Posts List

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

¥Our <PostsList> is currently showing all the posts in the same order the posts are kept in the Redux store. Our example has the oldest post first, and any time we add a new post, it gets added to the end of the posts array. That means the newest post is always at the bottom of the page.

通常,社交媒体源首先显示最新的帖子,然后向下滚动以查看较旧的帖子。即使数据在存储中按照最旧的顺序保存,我们也可以对 <PostsList> 组件中的数据重新排序,以便最新的帖子位于最前面。理论上,由于我们知道 state.posts 数组已经排序,因此我们可以反转列表。但是,为了确定起见,最好还是自己进行排序。

¥Typically, social media feeds show the newest posts first, and you scroll down to see older posts. Even though the data is being kept oldest-first in the store, we can reorder the data in our <PostsList> component so that the newest post is first. In theory, since we know that the state.posts array is already sorted, we could just reverse the list. But, it's better to go ahead and sort it ourselves just to be sure.

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

¥Since array.sort() mutates the existing array, we need to make a copy of state.posts and sort that copy. We know that our post.date fields are being kept as date timestamp strings, and we can directly compare those to sort the posts in the right order:

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

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

¥We also need to add the date field to initialState in postsSlice.js. We'll use date-fns here again to subtract minutes from the current date/time so they differ from each other.

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

后反应按钮

¥Post Reaction Buttons

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

¥We have one more new feature to add for this section. Right now, our posts are kind of boring. We need to make them more exciting, and what better way to do that than letting our friends add reaction emoji to our posts?

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

¥We'll add a row of emoji reaction buttons at the bottom of each post in <PostsList> and <SinglePostPage>. Every time a user clicks one of the reaction buttons, we'll need to update a matching counter field for that post in the Redux store. Since the reaction counter data is in the Redux store, switching between different parts of the app should consistently show the same values in any component that uses that data.

与帖子作者和时间戳一样,我们希望在显示帖子的任何地方都使用它,因此我们将创建一个 <ReactionButtons> 组件,该组件将 post 作为属性。我们将首先显示内部按钮,以及每个按钮的当前反应计数:

¥Like with post authors and timestamps, we want to use this everywhere we show posts, so we'll create a <ReactionButtons> component that takes a post as a prop. We'll start by just showing the buttons inside, with the current reaction counts for each button:

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 准备回调函数,以确保每个帖子都包含该数据,例如 reactions: {thumbsUp: 0, hooray: 0, heart: 0, rocket: 0, eyes: 0}

¥We don't yet have a post.reactions field in our data, so we'll need to update the initialState post objects and our postAdded prepare callback function to make sure that every post has that data inside, like reactions: {thumbsUp: 0, hooray: 0, heart: 0, rocket: 0, eyes: 0}.

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

¥Now, we can define a new reducer that will handle updating the reaction count for a post when a user clicks the reaction button.

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

¥Like with editing posts, we need to know the ID of the post, and which reaction button the user clicked on. We'll have our action.payload be an object that looks like {id, reaction}. The reducer can then find the right post object, and update the correct reactions field.

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 中编写 "mutating" 逻辑。如果我们不使用 createSlice 和 Immer 库,existingPost.reactions[reaction]++ 行确实会改变现有的 post.reactions 对象,这可能会导致我们应用中其他地方出现错误,因为我们没有遵循 reducer 的规则。但是,由于我们使用的是 createSlice,所以我们可以用更简单的方式编写这个更复杂的更新逻辑,并让 Immer 完成将这段代码转换为安全的不可变更新的工作。

¥As we've seen already, createSlice lets us write "mutating" logic in our reducers. If we weren't using createSlice and the Immer library, the line existingPost.reactions[reaction]++ would indeed mutate the existing post.reactions object, and this would probably cause bugs elsewhere in our app because we didn't follow the rules of reducers. But, since we are using createSlice, we can write this more complex update logic in a simpler way, and let Immer do the work of turning this code into a safe immutable update.

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

¥Notice that our action object just contains the minimum amount of information needed to describe what happened. We know which post we need to update, and which reaction name was clicked on. We could have calculated the new reaction counter value and put that in the action, but it's always better to keep the action objects as small as possible, and do the state update calculations in the reducer. This also means that reducers can contain as much logic as necessary to calculate the new state.

信息

使用 Immer 时,你可以对现有状态对象进行 "mutate",或者自己返回一个新的状态值,但不能同时执行这两种操作。有关更多详细信息,请参阅有关 陷阱返回新数据 的 Immer 文档指南。

¥When using Immer, you can either "mutate" an existing state object, or return a new state value yourself, but not both at the same time. See the Immer docs guides on Pitfalls and Returning New Data for more details.

我们的最后一步是更新 <ReactionButtons> 组件以在用户单击按钮时调度 reactionAdded 操作:

¥Our last step is to update the <ReactionButtons> component to dispatch the reactionAdded action when the user clicks a button:

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> 上查看该帖子本身。

¥Now, every time we click a reaction button, the counter should increment. If we browse around to different parts of the app, we should see the correct counter values displayed any time we look at this post, even if we click a reaction button in the <PostsList> and then look at the post by itself on the <SinglePostPage>.

你学到了什么

¥What You've Learned

经过所有这些更改后,我们的应用如下所示:

¥Here's what our app looks like after all these changes:

它实际上开始看起来更加有用和有趣!

¥It's actually starting to look more useful and interesting!

我们在本节中介绍了很多信息和概念。让我们回顾一下需要记住的重要事项:

¥We've covered a lot of information and concepts in this section. Let's recap the important things to remember:

概括
  • 任何 React 组件都可以根据需要使用 Redux 存储中的数据

    ¥Any React component can use data from the Redux store as needed

    • 任何组件都可以读取 Redux 存储中的任何数据

      ¥Any component can read any data that is in the Redux store

    • 多个组件甚至可以同时读取相同的数据

      ¥Multiple components can read the same data, even at the same time

    • 组件应该提取渲染自身所需的最少量的数据

      ¥Components should extract the smallest amount of data they need to render themselves

    • 组件可以组合来自 props、state 和 Redux 存储的值来确定它们需要渲染什么 UI。他们可以从存储中读取多条数据,并根据显示需要重塑数据。

      ¥Components can combine values from props, state, and the Redux store to determine what UI they need to render. They can read multiple pieces of data from the store, and reshape the data as needed for display.

    • 任何组件都可以调度操作来导致状态更新

      ¥Any component can dispatch actions to cause state updates

  • Redux 动作创建者可以准备具有正确内容的动作对象

    ¥Redux action creators can prepare action objects with the right contents

    • createSlicecreateAction 可以接受返回操作有效负载的 "准备回调"

      ¥createSlice and createAction can accept a "prepare callback" that returns the action payload

    • 唯一 ID 和其他随机值应该放在 action 中,而不是在 reducer 中计算

      ¥Unique IDs and other random values should be put in the action, not calculated in the reducer

  • reducer 应该包含实际的状态更新逻辑

    ¥Reducers should contain the actual state update logic

    • reducer 可以包含计算下一个状态所需的任何逻辑

      ¥Reducers can contain whatever logic is needed to calculate the next state

    • 动作对象应该包含足够的信息来描述发生的事情

      ¥Action objects should contain just enough info to describe what happened

下一步是什么?

¥What's Next?

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

¥By now you should be comfortable working with data in the Redux store and React components. So far we've just used data that was in the initial state or added by the user. In Part 5: Async Logic and Data Fetching, we'll see how to work with data that comes from a server API.