第四节:使用数据

你将学到

  • 在多个 React 组件中使用 Redux 数据
  • 开发逻辑来 dispatch action
  • 在 reducer 中编写更复杂的更新逻辑

必备能力

简介

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

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

说明

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

显示单个帖子

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

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

创建单个帖子的页面

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

  1. import React from 'react'
  2. import { useSelector } from 'react-redux'
  3. export const SinglePostPage = ({ match }) => {
  4. const { postId } = match.params
  5. const post = useSelector(state =>
  6. state.posts.find(post => post.id === postId)
  7. )
  8. if (!post) {
  9. return (
  10. <section>
  11. <h2>页面未找到!</h2>
  12. </section>
  13. )
  14. }
  15. return (
  16. <section>
  17. <article className="post">
  18. <h2>{post.title}</h2>
  19. <p className="post-content">{post.content}</p>
  20. </article>
  21. </section>
  22. )
  23. }

React Router 将传入一个 match 对象作为包含我们正在寻找的 URL 信息的 prop。当我们设置路由来渲染这个组件时,我们将告诉它解析 URL 的第二部分作为一个名为 postId 的变量,我们可以从 match.params 中读取该值。

一旦我们有了这个 postId 值,我们就可以在 selector 函数中使用它来从 Redux store 中找到正确的 post 对象。我们知道 state.posts 应该是所有 post 对象的数组,所以我们可以使用 Array.find() 函数循环遍历数组并返回带有我们正在寻找的 ID 的 post 条目。

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

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

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

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

添加单个帖子的路由

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

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

  1. import { PostsList } from './features/posts/PostsList'
  2. import { AddPostForm } from './features/posts/AddPostForm'
  3. // highlight-next-line
  4. import { SinglePostPage } from './features/posts/SinglePostPage'
  5. function App() {
  6. return (
  7. <Router>
  8. <Navbar />
  9. <div className="App">
  10. <Switch>
  11. <Route
  12. exact
  13. path="/"
  14. render={() => (
  15. <React.Fragment>
  16. <AddPostForm />
  17. <PostsList />
  18. </React.Fragment>
  19. )}
  20. />
  21. // highlight-next-line
  22. <Route exact path="/posts/:postId" component={SinglePostPage} />
  23. <Redirect to="/" />
  24. </Switch>
  25. </div>
  26. </Router>
  27. )
  28. }

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

  1. import React from 'react'
  2. import { useSelector } from 'react-redux'
  3. // highlight-next-line
  4. import { Link } from 'react-router-dom'
  5. export const PostsList = () => {
  6. const posts = useSelector(state => state.posts)
  7. const renderedPosts = posts.map(post => (
  8. <article className="post-excerpt" key={post.id}>
  9. <h3>{post.title}</h3>
  10. <p className="post-content">{post.content.substring(0, 100)}</p>
  11. // highlight-start
  12. <Link to={`/posts/${post.id}`} className="button muted-button">
  13. View Post
  14. </Link>
  15. // highlight-end
  16. </article>
  17. ))
  18. return (
  19. <section className="posts-list">
  20. <h2>Posts</h2>
  21. {renderedPosts}
  22. </section>
  23. )
  24. }

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

  1. import React from 'react'
  2. // highlight-next-line
  3. import { Link } from 'react-router-dom'
  4. export const Navbar = () => {
  5. return (
  6. <nav>
  7. <section>
  8. <h1>Redux 循序渐进示例</h1>
  9. <div className="navContent">
  10. // highlight-start
  11. <div className="navLinks">
  12. <Link to="/">帖子列表</Link>
  13. </div>
  14. // highlight-end
  15. </div>
  16. </section>
  17. </nav>
  18. )
  19. }

编辑帖子

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

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

更新帖子条目

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

createSlice() 函数中,我们应该在 reducers 对象中添加一个新函数。请记住,reducer 的名称应该很好地描述了正在发生的事情,因为无论何时调度此 action,我们都会看到 reducer 名称显示为 Redux DevTools 中的 action type 字符串的一部分。我们的第一个 reducer 被称为 postAdded,这个就命名为 postUpdated

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

  • 正在更新的帖子的ID,以便我们可以在状态中找到正确的帖子对象
  • 用户输入的新“标题”和“内容”字段

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

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

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

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

考虑到所有这些要求,修改后的 postsSlice 代码如下:

  1. const postsSlice = createSlice({
  2. name: 'posts',
  3. initialState,
  4. reducers: {
  5. postAdded(state, action) {
  6. state.push(action.payload)
  7. },
  8. // highlight-start
  9. postUpdated(state, action) {
  10. const { id, title, content } = action.payload
  11. const existingPost = state.find(post => post.id === id)
  12. if (existingPost) {
  13. existingPost.title = title
  14. existingPost.content = content
  15. }
  16. }
  17. // highlight-end
  18. }
  19. })
  20. // highlight-next-line
  21. export const { postAdded, postUpdated } = postsSlice.actions
  22. export default postsSlice.reducer

编辑帖子表单

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

  1. import React, { useState } from 'react'
  2. import { useDispatch, useSelector } from 'react-redux'
  3. import { useHistory } from 'react-router-dom'
  4. import { postUpdated } from './postsSlice'
  5. export const EditPostForm = ({ match }) => {
  6. const { postId } = match.params
  7. const post = useSelector(state =>
  8. state.posts.find(post => post.id === postId)
  9. )
  10. const [title, setTitle] = useState(post.title)
  11. const [content, setContent] = useState(post.content)
  12. const dispatch = useDispatch()
  13. const history = useHistory()
  14. const onTitleChanged = e => setTitle(e.target.value)
  15. const onContentChanged = e => setContent(e.target.value)
  16. const onSavePostClicked = () => {
  17. if (title && content) {
  18. dispatch(postUpdated({ id: postId, title, content }))
  19. history.push(`/posts/${postId}`)
  20. }
  21. }
  22. return (
  23. <section>
  24. <h2>编辑帖子</h2>
  25. <form>
  26. <label htmlFor="postTitle">帖子标题:</label>
  27. <input
  28. type="text"
  29. id="postTitle"
  30. name="postTitle"
  31. placeholder="What's on your mind?"
  32. value={title}
  33. onChange={onTitleChanged}
  34. />
  35. <label htmlFor="postContent">内容:</label>
  36. <textarea
  37. id="postContent"
  38. name="postContent"
  39. value={content}
  40. onChange={onContentChanged}
  41. />
  42. </form>
  43. <button type="button" onClick={onSavePostClicked}>
  44. 保存帖子
  45. </button>
  46. </section>
  47. )
  48. }

SinglePostPage 一样,我们需要将它导入 App.js 并添加一个路由来渲染这个组件。我们还应该向我们的 SinglePostPage 添加一个新链接,该链接将路由到 EditPostPage,例如:

  1. // highlight-next-line
  2. import { Link } from 'react-router-dom'
  3. export const SinglePostPage = ({ match }) => {
  4. // omit other contents
  5. <p className="post-content">{post.content}</p>
  6. // highlight-start
  7. <Link to={`/editPost/${post.id}`} className="button">
  8. Edit Post
  9. </Link>
  10. // highlight-end

准备 Action Payloads

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

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

注意

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

如果我们手动编写 postAdded 的 action creator,我们可以自己将设置逻辑放在其中:

  1. // hand-written action creator
  2. function postAdded(title, content) {
  3. const id = nanoid()
  4. return {
  5. type: 'posts/postAdded',
  6. payload: { id, title, content }
  7. }
  8. }

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

幸运的是,createSlice 允许我们在编写 reducer 时定义一个 prepare 函数。 prepare 函数可以接受多个参数,生成诸如唯一 ID 之类的随机值,并运行需要的任何其他同步逻辑来决定哪些值进入 action 对象。然后它应该返回一个包含 payload 字段的对象。(返回对象还可能包含一个 meta 字段,可用于向 action 添加额外的描述性值,以及一个 error 字段,该字段应该是一个布尔值,指示此 action 是否表示某种错误。)

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

  1. const postsSlice = createSlice({
  2. name: 'posts',
  3. initialState,
  4. reducers: {
  5. // highlight-start
  6. postAdded: {
  7. reducer(state, action) {
  8. state.push(action.payload)
  9. },
  10. prepare(title, content) {
  11. return {
  12. payload: {
  13. id: nanoid(),
  14. title,
  15. content
  16. }
  17. }
  18. }
  19. }
  20. // highlight-end
  21. // other reducers here
  22. }
  23. })

现在我们的组件不必担心 payload 对象是什么样子 - action creator 将负责以正确的方式将它组合在一起。因此,我们可以更新组件代码,以便它在调度 postAdded 时传入 titlecontent 作为参数:

  1. const onSavePostClicked = () => {
  2. if (title && content) {
  3. // highlight-next-line
  4. dispatch(postAdded(title, content))
  5. setTitle('')
  6. setContent('')
  7. }
  8. }

用户与帖子

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

如果没有任何其他人参与,您就无法做出社交媒体。让我们添加在我们的应用程序中跟踪用户列表的功能,并更新与发布相关的功能。

添加用户切片

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

添加用户切片

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

  1. import { createSlice } from '@reduxjs/toolkit'
  2. const initialState = [
  3. { id: '0', name: 'Tianna Jenkins' },
  4. { id: '1', name: 'Kevin Grant' },
  5. { id: '2', name: 'Madison Price' }
  6. ]
  7. const usersSlice = createSlice({
  8. name: 'users',
  9. initialState,
  10. reducers: {}
  11. })
  12. export default usersSlice.reducer

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

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

  1. import { configureStore } from '@reduxjs/toolkit'
  2. import postsReducer from '../features/posts/postsSlice'
  3. // highlight-next-line
  4. import usersReducer from '../features/users/usersSlice'
  5. export default configureStore({
  6. reducer: {
  7. posts: postsReducer,
  8. // highlight-next-line
  9. users: usersReducer
  10. }
  11. })

为帖子添加作者

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

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

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

  1. const postsSlice = createSlice({
  2. name: 'posts',
  3. initialState,
  4. reducers: {
  5. postAdded: {
  6. reducer(state, action) {
  7. state.push(action.payload)
  8. },
  9. // highlight-next-line
  10. prepare(title, content, userId) {
  11. return {
  12. payload: {
  13. id: nanoid(),
  14. title,
  15. content,
  16. // highlight-next-line
  17. user: userId
  18. }
  19. }
  20. }
  21. }
  22. // other reducers
  23. }
  24. })

现在,在我们的 <AddPostForm> 中,我们可以使用 useSelector 从 store 中读取用户列表,并将它们显示为下拉列表。然后我们将获取所选用户的 ID 并将其传递给 postAdded 这个 action creator。 在此过程中,我们可以在表单中添加一些验证逻辑,以便用户只能在标题和内容合规时才能单击“保存帖子”按钮:

  1. import React, { useState } from 'react'
  2. // highlight-next-line
  3. import { useDispatch, useSelector } from 'react-redux'
  4. import { postAdded } from './postsSlice'
  5. export const AddPostForm = () => {
  6. const [title, setTitle] = useState('')
  7. const [content, setContent] = useState('')
  8. // highlight-next-line
  9. const [userId, setUserId] = useState('')
  10. const dispatch = useDispatch()
  11. // highlight-next-line
  12. const users = useSelector(state => state.users)
  13. const onTitleChanged = e => setTitle(e.target.value)
  14. const onContentChanged = e => setContent(e.target.value)
  15. // highlight-next-line
  16. const onAuthorChanged = e => setUserId(e.target.value)
  17. const onSavePostClicked = () => {
  18. if (title && content) {
  19. // highlight-next-line
  20. dispatch(postAdded(title, content, userId))
  21. setTitle('')
  22. setContent('')
  23. }
  24. }
  25. // highlight-start
  26. const canSave = Boolean(title) && Boolean(content) && Boolean(userId)
  27. const usersOptions = users.map(user => (
  28. <option key={user.id} value={user.id}>
  29. {user.name}
  30. </option>
  31. ))
  32. // highlight-end
  33. return (
  34. <section>
  35. <h2>Add a New Post</h2>
  36. <form>
  37. <label htmlFor="postTitle">Post Title:</label>
  38. <input
  39. type="text"
  40. id="postTitle"
  41. name="postTitle"
  42. placeholder="What's on your mind?"
  43. value={title}
  44. onChange={onTitleChanged}
  45. />
  46. // highlight-start
  47. <label htmlFor="postAuthor">Author:</label>
  48. <select id="postAuthor" value={userId} onChange={onAuthorChanged}>
  49. <option value=""></option>
  50. {usersOptions}
  51. </select>
  52. // highlight-end
  53. <label htmlFor="postContent">Content:</label>
  54. <textarea
  55. id="postContent"
  56. name="postContent"
  57. value={content}
  58. onChange={onContentChanged}
  59. />
  60. // highlight-next-line
  61. <button type="button" onClick={onSavePostClicked} disabled={!canSave}>
  62. Save Post
  63. </button>
  64. </form>
  65. </section>
  66. )
  67. }

现在,我们需要一种方法来在我们的帖子列表项和 <SinglePostPage> 中显示帖子作者的姓名。由于我们想要在多个地方显示相同类型的信息,我们可以创建一个 PostAuthor 组件,它将用户 ID 作为 prop,查找正确的用户对象,并格式化用户名:

  1. import React from 'react'
  2. import { useSelector } from 'react-redux'
  3. export const PostAuthor = ({ userId }) => {
  4. const author = useSelector(state =>
  5. state.users.find(user => user.id === userId)
  6. )
  7. return <span>by {author ? author.name : 'Unknown author'}</span>
  8. }

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

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

更多帖子功能

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

存储帖子的日期

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

post.user 字段一样,我们将更新我们的 postAdded prepare 回调,以确保在 dispatch action 时始终包含 post.date。然而,它不是将被传入的另一个参数。我们希望始终使用 dispatch action 时的时间戳,因此我们将让 prepare 回调自己处理它。

:::caution 注意

Redux action 和 state 应该只能包含普通的 JS 值,如对象、数组和基本类型。不要将类实例、函数或其他不可序列化的值放入 Redux!

:::

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

  1. postAdded: {
  2. reducer(state, action) {
  3. state.push(action.payload)
  4. },
  5. prepare(title, content, userId) {
  6. return {
  7. payload: {
  8. id: nanoid(),
  9. // highlight-next-line
  10. date: new Date().toISOString(),
  11. title,
  12. content,
  13. user: userId,
  14. },
  15. }
  16. },
  17. },

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

  1. import React from 'react'
  2. import { parseISO, formatDistanceToNow } from 'date-fns'
  3. export const TimeAgo = ({ timestamp }) => {
  4. let timeAgo = ''
  5. if (timestamp) {
  6. const date = parseISO(timestamp)
  7. const timePeriod = formatDistanceToNow(date)
  8. timeAgo = `${timePeriod} ago`
  9. }
  10. return (
  11. <span title={timestamp}>
  12. &nbsp; <i>{timeAgo}</i>
  13. </span>
  14. )
  15. }

为帖子列表排序

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

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

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

  1. // 根据日期时间字符串,对帖子安装时间倒序进行排序
  2. //highlight-start
  3. const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))
  4. const renderedPosts = orderedPosts.map(post => {
  5. //highlight-end
  6. return (
  7. <article className="post-excerpt" key={post.id}>
  8. <h3>{post.title}</h3>
  9. <div>
  10. <PostAuthor userId={post.user} />
  11. <TimeAgo timestamp={post.date} />
  12. </div>
  13. <p className="post-content">{post.content.substring(0, 100)}</p>
  14. <Link to={`/posts/${post.id}`} className="button muted-button">
  15. View Post
  16. </Link>
  17. </article>
  18. )
  19. })

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

  1. import { createSlice, nanoid } from '@reduxjs/toolkit'
  2. // highlight-next-line
  3. import { sub } from 'date-fns'
  4. const initialState = [
  5. {
  6. // omitted fields
  7. content: 'Hello!',
  8. // highlight-next-line
  9. date: sub(new Date(), { minutes: 10 }).toISOString()
  10. },
  11. {
  12. // omitted fields
  13. content: '更多',
  14. // highlight-next-line
  15. date: sub(new Date(), { minutes: 5 }).toISOString()
  16. }
  17. ]

为帖子添加反应表情

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

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

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

  1. import React from 'react'
  2. const reactionEmoji = {
  3. thumbsUp: '👍',
  4. hooray: '🎉',
  5. heart: '❤️',
  6. rocket: '🚀',
  7. eyes: '👀'
  8. }
  9. export const ReactionButtons = ({ post }) => {
  10. const reactionButtons = Object.entries(reactionEmoji).map(([name, emoji]) => {
  11. return (
  12. <button key={name} type="button" className="muted-button reaction-button">
  13. {emoji} {post.reactions[name]}
  14. </button>
  15. )
  16. })
  17. return <div>{reactionButtons}</div>
  18. }

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

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

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

  1. const postsSlice = createSlice({
  2. name: 'posts',
  3. initialState,
  4. reducers: {
  5. // highlight-start
  6. reactionAdded(state, action) {
  7. const { postId, reaction } = action.payload
  8. const existingPost = state.find(post => post.id === postId)
  9. if (existingPost) {
  10. existingPost.reactions[reaction]++
  11. }
  12. }
  13. // highlight-end
  14. // other reducers
  15. }
  16. })
  17. // highlight-next-line
  18. export const { postAdded, postUpdated, reactionAdded } = postsSlice.actions

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

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

:::info 说明

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

:::

最后一步是更新 <ReactionButtons> 组件以在用户单击按钮时 dispatch reactionAdded action:

  1. import React from 'react'
  2. // highlight-start
  3. import { useDispatch } from 'react-redux'
  4. import { reactionAdded } from './postsSlice'
  5. // highlight-end
  6. const reactionEmoji = {
  7. thumbsUp: '👍',
  8. hooray: '🎉',
  9. heart: '❤️',
  10. rocket: '🚀',
  11. eyes: '👀'
  12. }
  13. export const ReactionButtons = ({ post }) => {
  14. // highlight-next-line
  15. const dispatch = useDispatch()
  16. const reactionButtons = Object.entries(reactionEmoji).map(([name, emoji]) => {
  17. return (
  18. <button
  19. key={name}
  20. type="button"
  21. className="muted-button reaction-button"
  22. // highlight-start
  23. onClick={() =>
  24. dispatch(reactionAdded({ postId: post.id, reaction: name }))
  25. }
  26. // highlight-end
  27. >
  28. {emoji} {post.reactions[name]}
  29. </button>
  30. )
  31. })
  32. return <div>{reactionButtons}</div>
  33. }

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

你学到了

所有这些更改后我们的应用程序长这样:

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

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

总结

下一步

到现在为止,您应该已经熟练使用 Redux store 和 React 组件中的数据了。到目前为止,我们只使用了处于初始状态或用户添加的数据。 在 第五节:异步逻辑和数据获取 中,我们将了解如何处理来自服务器 API 的数据。