跳至主内容

Redux 基础教程,第 4 节:使用 Redux 数据

非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

你将学到
  • 在多个 React 组件中使用 Redux 数据
  • 组织派发 actions 的逻辑
  • 使用选择器(selectors)查找状态值
  • 在 reducers 中编写更复杂的更新逻辑
  • 如何理解 Redux actions

简介

第 3 节:Redux 数据流基础中,我们了解了如何从空的 Redux+React 项目设置开始,添加新的状态切片,并创建能够从 Redux store 读取数据及派发 action 更新数据的 React 组件。我们还探讨了数据如何在应用中流动:组件派发 action → reducer 处理 action 返回新状态 → 组件读取新状态并重新渲染 UI。同时我们学习了如何创建"预定义类型"的 useSelectoruseDispatch 钩子,这些钩子会自动应用正确的 store 类型。

现在你已掌握编写 Redux 逻辑的核心步骤,我们将使用这些步骤为社交媒体信息流添加新功能,使其更加实用:查看单篇帖子、编辑现有帖子、显示作者详情、帖子时间戳、互动按钮和认证功能。

信息

请注意,代码示例主要关注每个部分的核心概念和变更。完整的应用更改请查看 CodeSandbox 项目及项目仓库的 tutorial-steps-ts 分支

显示单篇帖子

既然我们能够向 Redux store 中添加新帖子,就可以添加一些以不同方式使用帖子数据的新功能。

目前帖子条目显示在信息流主页,但当文本过长时,我们仅显示内容摘要。能够单独查看单篇帖子的页面会很有帮助。

创建单篇帖子页面

首先需要在 posts 功能文件夹中添加新的 SinglePostPage 组件。我们将使用 React Router 在页面 URL 匹配 /posts/123 格式时显示该组件,其中 123 是目标帖子的 ID。

features/posts/SinglePostPage.tsx
import { useParams } from 'react-router-dom'

import { useAppSelector } from '@/app/hooks'

export const SinglePostPage = () => {
const { postId } = useParams()

const post = useAppSelector(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>
)
}

设置路由渲染此组件时,我们会配置路由将 URL 的第二部分解析为 postId 变量,该值可通过 useParams 钩子读取。

获取 postId 值后,可以在选择器函数中使用它从 Redux store 查找对应的帖子对象。已知 state.posts 是所有帖子对象的数组,因此可使用 Array.find() 遍历数组并返回匹配 ID 的帖子条目。

需要重点注意:每当 useAppSelector 返回的值变为新的引用时,组件都会重新渲染。组件应始终尝试从 store 中选择所需的最小数据量,这有助于确保仅在真正需要时才渲染。

可能 store 中没有匹配的帖子条目——用户可能直接输入了 URL,或我们尚未加载正确数据。此时 find() 会返回 undefined 而非帖子对象。组件需要检查这种情况,并在页面显示"未找到帖子!"的提示信息。

假设我们在 store 中找到了对应的文章对象,useAppSelector 将返回该对象,我们可以用它来在页面上渲染文章的标题和内容。

你可能会注意到,这与 <PostsList> 组件中遍历整个 posts 数组来展示摘要的逻辑非常相似。我们_可以_尝试提取一个能在两个地方复用的 Post 组件,但目前摘要展示和全文展示已有差异。通常更好的做法是暂时保留重复代码,等后续确认代码相似度足够高时再提取可复用组件。

添加单篇文章路由

现在有了 <SinglePostPage> 组件,我们可以定义路由来展示它,并在首页动态流中添加每篇文章的链接。

此时值得将"主页"内容提取为单独的 <PostsMainPage> 组件以提升可读性。

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

App.tsx
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'

import { Navbar } from './components/Navbar'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<PostsMainPage />}></Route>
<Route path="/posts/:postId" element={<SinglePostPage />} />
</Routes>
</div>
</Router>
)
}

export default App

接着在 <PostsList> 中更新渲染逻辑,添加一个 <Link> 来路由到特定文章:

features/posts/PostsList.tsx
import { Link } from 'react-router-dom'
import { useAppSelector } from '@/app/hooks'

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

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

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

由于现在可以跳转到不同页面,在 <Navbar> 组件中添加返回主站的链接也很必要:

app/Navbar.tsx
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> 组件,支持读取 store 中的现有文章,允许用户编辑标题和内容,并将变更保存到 store。

更新文章条目

首先需要更新 postsSlice,创建新的 reducer 函数和 action 以便 store 知道如何更新文章。

createSlice()reducers 对象中添加新函数。注意 reducer 名称应清晰描述操作行为,因为它会作为 action 类型出现在 Redux DevTools 中。第一个 reducer 名为 postAdded,因此本次命名为 postUpdated

技巧

Redux 本身不关心 reducer 函数的命名——无论是 postAddedaddPostPOST_ADDED 还是 someRandomName 都能运行。

我们建议使用过去式描述事件,如 postAdded,表示"应用中发生的事件"。

更新文章对象需要以下信息:

  • 被更新文章的 ID(用于在 state 中定位)

  • 用户输入的新 titlecontent 字段

Redux action 对象必须包含 type 字段(通常是描述性字符串),也可包含其他信息字段。按惯例,附加信息放在 action.payload 字段,而 payload 字段包含什么内容由我们决定 - 它可以是字符串、数字、对象、数组等。本例需要三个字段,因此规划 payload 为包含这三个字段的对象,即 action 对象形如 {type: 'posts/postUpdated', payload: {id, title, content}}

默认情况下,createSlice生成的动作创建器期望传入单个参数,该值将作为action.payload放入动作对象中。因此我们可以传递包含这些字段的对象作为postUpdated动作创建器的参数。与postAdded类似,这里传递的是完整的Post对象,因此我们在reducer中声明参数为action: PayloadAction<Post>

我们知道reducer负责确定当动作被派发时状态应如何更新。因此,reducer需要根据ID找到正确的帖子对象,并专门更新该帖子的titlecontent字段。

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

综合以上需求,完成后的postsSlice定义应如下所示:

features/posts/postsSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

// omit state types

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
postUpdated(state, action: PayloadAction<Post>) {
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

创建编辑帖子表单

新的<EditPostForm>组件看起来会类似于<AddPostForm><SinglePostPage>,但逻辑需要稍作调整。我们需要根据URL中的postId从存储中检索正确的post对象,然后用它初始化组件中的输入字段以便用户修改。当用户提交表单时,我们会将更改后的标题和内容值保存回存储。同时使用React Router的useNavigate钩子在保存更改后跳转到单帖页面展示该帖子。

features/posts/EditPostForm.tsx
import React from 'react'
import { useNavigate, useParams } from 'react-router-dom'

import { useAppSelector, useAppDispatch } from '@/app/hooks'
import { postUpdated } from './postsSlice'

// omit form element types

export const EditPostForm = () => {
const { postId } = useParams()

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

const dispatch = useAppDispatch()
const navigate = useNavigate()

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

const onSavePostClicked = (e: React.FormEvent<EditPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value

if (title && content) {
dispatch(postUpdated({ id: post.id, title, content }))
navigate(`/posts/${postId}`)
}
}

return (
<section>
<h2>Edit Post</h2>
<form onSubmit={onSavePostClicked}>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
defaultValue={post.title}
required
/>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue={post.content}
required
/>

<button>Save Post</button>
</form>
</section>
)
}

请注意这里的Redux相关代码相对较少。我们再次通过useAppSelector从Redux存储读取值,并在用户与UI交互时通过useAppDispatch派发动作。

SinglePostPage 类似,我们需要将其导入 App.tsx 并添加一个路由,该路由将使用 postId 作为路由参数来渲染此组件。

App.tsx
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'

import { Navbar } from './components/Navbar'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<PostsMainPage />}></Route>
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
</Routes>
</div>
</Router>
)
}

export default App

还应在SinglePostPage中添加指向EditPostForm的新链接:

features/post/SinglePostPage.tsx
import { Link, useParams } from 'react-router-dom'

export const SinglePostPage = () => {

// omit other contents

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

准备动作载荷

我们刚刚看到createSlice生成的动作创建器通常期望一个参数,该参数会成为action.payload。这简化了最常见的使用模式,但有时我们需要更多工作来准备动作对象的内容。对于postAdded动作,我们需要为新帖子生成唯一ID,并确保载荷是形如{id, title, content}的对象。

目前我们在React组件中生成ID并创建载荷对象,然后将载荷对象传入postAdded。但如果需要从不同组件派发相同动作,或载荷准备逻辑很复杂呢?每次派发动作时都需重复该逻辑,且强制组件了解动作载荷的具体结构。

注意

若动作需要包含唯一ID或其他随机值,务必先生成该值再放入动作对象。reducer永远不应计算随机值,这会导致结果不可预测。

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

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

但Redux Toolkit的createSlice正在为我们生成这些动作创建器。虽然这减少了代码量,我们仍需要自定义action.payload内容的方法。

幸运的是,createSlice允许我们在编写reducer时定义"prepare回调"函数。该函数可以接收多个参数,生成随机值(如唯一ID),并运行任何需要的同步逻辑来决定action对象应包含的值。它应返回一个包含payload字段的对象。(返回对象也可包含meta字段用于添加额外描述信息,以及error布尔字段表示该action是否代表错误)

createSlicereducers字段中,我们可以将某个字段定义为{reducer, prepare}格式的对象:

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

现在组件不再需要关心payload对象的结构——action creator会负责正确组装。因此我们可以更新组件,在派发postAdded时传入titlecontent作为参数:

features/posts/AddPostForm.tsx
const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value

// Now we can pass these in as separate arguments,
// and the ID will be generated automatically
dispatch(postAdded(title, content))

e.currentTarget.reset()
}

使用选择器读取数据

目前有多个组件通过ID查找帖子,重复编写了state.posts.find()逻辑。这种代码冗余值得优化——后续章节将修改posts切片的状态结构,届时需要查找所有引用state.posts的位置并更新逻辑。TypeScript虽然能在编译时捕获类型错误,但若能避免每次修改reducer数据结构时都要重写组件逻辑,并消除组件间的重复代码会更理想。

解决方案之一是在切片文件中定义可复用的选择器函数,让组件直接使用这些选择器提取数据而非重复编写选择逻辑。这样当状态结构变更时,只需更新切片文件中的代码。

定义选择器函数

每次调用useAppSelector时其实都在编写选择器,例如useAppSelector( state => state.posts )。这种情况下选择器是内联定义的。由于它本质是函数,我们也可以单独声明:

const selectPosts = (state: RootState) => state.posts
const posts = useAppSelector(selectPosts)

选择器通常作为独立函数定义在切片文件中,它们通常以整个Redux的RootState作为首个参数,也可接受其他参数。

提取帖子选择器

<PostsList>组件需要读取所有帖子列表,而<SinglePostPage><EditPostForm>需要按ID查找单个帖子。我们在postsSlice.ts中导出两个选择器函数:

features/posts/postsSlice.ts
import type { RootState } from '@/app/store'

const postsSlice = createSlice(/* omit slice code*/)

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

export default postsSlice.reducer

export const selectAllPosts = (state: RootState) => state.posts

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

注意这些选择器的state参数是整个Redux根状态对象,与直接在useAppSelector内编写的匿名选择器一致。

然后在组件中使用它们:

features/posts/PostsList.tsx
// omit imports
import { selectAllPosts } from './postsSlice'

export const PostsList = () => {
const posts = useAppSelector(selectAllPosts)
// omit component contents
}
features/posts/SinglePostPage.tsx
// omit imports
import { selectPostById } from './postsSlice'

export const SinglePostPage = () => {
const { postId } = useParams()

const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}
features/posts/EditPostForm.tsx
// omit imports
import { postUpdated, selectPostById } from './postsSlice'

export const EditPostForm = () => {
const { postId } = useParams()

const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}

注意:从 useParams() 获取的 postId 类型为 string | undefined,但 selectPostById 要求一个有效的 string 作为参数。我们可以使用 TS 的 ! 运算符来告知 TS 编译器,该值在此处不会为 undefined(这可能有风险,但我们可以基于这样的假设:我们知道路由设置仅在 URL 中存在帖子 ID 时才显示 <EditPostForm>)。

后续我们将延续这种在切片中定义选择器的模式,避免在组件的useAppSelector内联编写。记住这虽非强制,但值得遵循!

高效使用选择器

通过编写可复用的选择器封装数据查询通常是良好实践。理想情况下组件甚至无需知道数据在Reduxstate中的位置——直接使用切片导出的选择器访问数据即可。

你也可以创建“记忆化”(memoized)的选择器,通过优化重渲染和跳过不必要的重复计算来提升性能,我们将在本教程后续章节中详细介绍。

但如同所有抽象层一样,这并非意味着你需要随时随地使用。编写选择器会增加代码理解和维护成本。不必为状态中的每个字段都编写选择器。建议初期不使用选择器,当发现多处应用代码需要查询相同值时再考虑添加。

可选:在 createSlice 内部定义选择器

此前我们选择器是作为切片文件中的独立函数编写的。在某些场景下,可以直接在 createSlice 内部定义选择器来简化代码:

Defining Selectors inside createSlice

We've already seen that createSlice requires the name, initialState, and reducers fields, and also accepts an optional extraReducers field.

If you want to define selectors directly inside of createSlice, you can pass in an additional selectors field. The selectors field should be an object similar to reducers, where the keys will be the selector function names, and the values are the selector functions to be generated.

Note that unlike writing a standalone selector function, the state argument to these selectors will be just the slice state, and not the entire RootState!.

Here's what it might look like to convert the posts slice selectors to be defined inside of createSlice:

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
/* omit reducer logic */
},
selectors: {
// Note that these selectors are given just the `PostsState`
// as an argument, not the entire `RootState`
selectAllPosts: postsState => postsState,
selectPostById: (postsState, postId: string) => {
return postsState.find(post => post.id === postId)
}
}
})

export const { selectAllPosts, selectPostById } = postsSlice.selectors

export default postsSlice.reducer

// We've replaced these standalone selectors:
// export const selectAllPosts = (state: RootState) => state.posts

// export const selectPostById = (state: RootState, postId: string) =>
// state.posts.find(post => post.id === postId)

There are still times you'll need to write selectors as standalone functions outside of createSlice. This is especially true if you're calling other selectors that need the entire RootState as their argument, in order to make sure the types match up correctly.

用户与帖子

目前我们仅管理单一状态切片。所有逻辑集中在 postsSlice.ts 中,数据存储在 state.posts,组件均与帖子功能相关。实际应用通常包含多个状态切片和不同的“功能文件夹”来管理 Redux 逻辑与 React 组件。

若没有其他参与者,就不能称之为“社交媒体”应用!现在我们将添加用户追踪功能,并改造帖子功能使其关联用户数据。

添加用户切片

“用户”概念与“帖子”本质不同,需保持代码和数据的隔离。新建 features/users 目录并在其中创建 usersSlice 文件。仿照帖子切片做法,先添加初始数据以便后续操作:

features/users/usersSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

import type { RootState } from '@/app/store'

interface User {
id: string
name: string
}

const initialState: User[] = [
{ 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

export const selectAllUsers = (state: RootState) => state.users

export const selectUserById = (state: RootState, userId: string | null) =>
state.users.find(user => user.id === userId)

当前无需实际更新数据,故将 reducers 字段留空(后续章节会补充):

如前所述,需在 store 文件中导入 usersReducer 并加入配置:

app/store.ts
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
}
})

此时根状态结构变为 {posts, users},与传入 reducer 参数的对象完全对应。

为帖子添加作者属性

应用中每个帖子均由用户创建,每次新增帖子时需记录作者信息。这要求同时改造 Redux 状态和 <AddPostForm> 组件:

首先扩展 Post 数据类型,新增 user: string 字段存储创建者用户 ID。同时更新 initialState 中的示例数据,为 post.user 字段添加预设用户 ID:

接着更新现有 reducer 逻辑:

  1. postAdded 的准备回调需接收用户 ID 参数并加入 action
  2. 更新帖子时无需包含 user 字段——只需提供变更帖子的 id 及新 title/content 文本。定义仅从 Post 类型中提取这三个字段的 PostUpdate 类型,作为 postUpdated 的负载类型:
features/posts/postsSlice.ts
export interface Post {
id: string
title: string
content: string
user: string
}

type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>

const initialState: Post[] = [
{ id: '1', title: 'First Post!', content: 'Hello!', user: '0' },
{ id: '2', title: 'Second Post', content: 'More text', user: '2' }
]

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
return {
payload: {
id: nanoid(),
title,
content,
user: userId
}
}
}
},
postUpdated(state, action: PayloadAction<PostUpdate>) {
const { id, title, content } = action.payload
const existingPost = state.find(post => post.id === id)
if (existingPost) {
existingPost.title = title
existingPost.content = content
}
}
}
})

现在在 <AddPostForm> 中:

  1. 通过 useSelector 读取用户列表并渲染为下拉菜单
  2. 将选定用户 ID 传入 postAdded action 创建器
  3. 添加表单验证逻辑:仅当标题和内容非空时才允许点击“保存帖子”按钮
features/posts/AddPostForm.tsx
import { selectAllUsers } from '@/features/users/usersSlice'

// omit other imports and form types

const AddPostForm = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)

const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
const userId = elements.postAuthor.value

dispatch(postAdded(title, content, userId))

e.currentTarget.reset()
}

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

return (
<section>
<h2>Add a New Post</h2>
<form onSubmit={handleSubmit}>
<label htmlFor="postTitle">Post Title:</label>
<input type="text" id="postTitle" defaultValue="" required />
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" name="postAuthor" required>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
defaultValue=""
required
/>
<button>Save Post</button>
</form>
</section>
)
}

现在,我们需要在帖子列表项和<SinglePostPage>中显示作者姓名。由于多个地方需要展示相同信息,我们可以创建PostAuthor组件:它接收用户ID作为prop,查找相应用户对象并格式化用户名:

features/posts/PostAuthor.tsx
import { useAppSelector } from '@/app/hooks'

import { selectUserById } from '@/features/users/usersSlice'

interface PostAuthorProps {
userId: string
}

export const PostAuthor = ({ userId }: PostAuthorProps) => {
const author = useAppSelector(state => selectUserById(state, userId))

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

注意每个组件都遵循相同模式:任何需要从Redux读取数据的组件都可以使用useAppSelector钩子,提取所需的特定数据片段。多个组件可以同时访问Redux存储中的相同数据。

现在将PostAuthor组件导入PostsList.tsxSinglePostPage.tsx,渲染为<PostAuthor userId={post.user} />。每次添加新帖子时,所选用户名都会显示在渲染的帖子中。

更多帖子功能

现在我们可以创建和编辑帖子,让我们添加更多逻辑增强信息流实用性。

存储帖子日期

社交媒体信息流通常按创建时间排序,并以相对时间描述(如"5小时前")显示。为此我们需要为帖子添加date字段。

post.user字段类似,我们将更新postAdded的prepare回调函数,确保分发操作时始终包含post.date。但这不是传入参数——我们需要使用操作分发时的精确时间戳,因此由prepare回调自行处理。

注意

Redux操作和状态应仅包含纯JS值(对象/数组/原始值)。切勿将类实例、函数、Date/Map/Set实例或其他不可序列化值放入Redux!

由于不能直接将Date实例放入Redux存储,我们将把post.date存储为时间戳字符串。将其添加到初始状态值(使用date-fns从当前时间减去几分钟),并在prepare回调中添加到每个新帖子:

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

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

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action: PayloadAction<Post>) {
state.push(action.payload)
},
prepare(title: string, content: string, userId: string) {
return {
payload: {
id: nanoid(),
date: new Date().toISOString(),
title,
content,
user: userId
}
}
}
}
// omit `postUpdated
}
})

与作者信息类似,我们需要在<PostsList><SinglePostPage>组件中显示相对时间描述。创建<TimeAgo>组件来格式化时间戳字符串为相对描述。使用date-fns等库的日期解析/格式化工具:

components/TimeAgo.tsx
import { parseISO, formatDistanceToNow } from 'date-fns'

interface TimeAgoProps {
timestamp: string
}

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

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

排序帖子列表

当前<PostsList>按Redux存储中的原始顺序显示帖子(最旧的在最前)。新帖子总是添加到数组末尾,导致最新帖子显示在页面底部。

通常社交媒体将最新帖子置顶。虽然存储中数据按旧到新排序,但我们可以在<PostsList>组件中重新排序。理论上,由于state.posts数组已经排序,我们可以直接反转列表,但显式排序更可靠。

由于array.sort()会改变原数组,我们需要复制state.posts后再排序。利用post.date时间戳字符串直接比较:

features/posts/PostsList.tsx
// 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 (
// omit rendering logic
)
})

帖子反应按钮

当前的帖子略显单调。有什么比让朋友添加表情符号反应更有趣的呢?🎉

我们将在 <PostsList><SinglePostPage> 的每篇帖子底部添加一行表情符号反应按钮。每当用户点击某个反应按钮时,我们需要在 Redux 存储中更新该帖子的对应计数器字段。由于反应计数器数据存储在 Redux 中,在应用不同部分切换时,任何使用该数据的组件都应显示一致的值。

在帖子中追踪反应数据

我们的数据中尚未包含 post.reactions 字段,因此需要更新 initialState 中的帖子对象和 postAdded 的 prepare 回调函数,确保每个帖子都包含类似 reactions: {thumbsUp: 0, tada: 0, heart: 0, rocket: 0, eyes: 0} 的数据结构。

随后,我们可以定义一个新的 reducer,用于处理用户点击反应按钮时的计数器更新。

与编辑帖子类似,我们需要知道帖子的 ID 和用户点击的具体反应按钮。我们的 action.payload 将采用 {id, reaction} 的对象结构。reducer 据此找到对应帖子对象并更新正确的反应字段。

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

export interface Reactions {
thumbsUp: number
tada: number
heart: number
rocket: number
eyes: number
}

export type ReactionName = keyof Reactions

export interface Post {
id: string
title: string
content: string
user: string
date: string
reactions: Reactions
}

type PostUpdate = Pick<Post, 'id' | 'title' | 'content'>

const initialReactions: Reactions = {
thumbsUp: 0,
tada: 0,
heart: 0,
rocket: 0,
eyes: 0
}

const initialState: Post[] = [
// omit initial state
]

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

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

正如我们所见,createSlice 允许我们在 reducer 中编写"可变"逻辑。如果未使用 createSlice 和 Immer 库,existingPost.reactions[reaction]++ 这行代码确实会直接改变 post.reactions 对象,由于违反 reducer 规则可能导致应用错误。但由于我们使用了 createSlice,可以用这种简洁方式编写复杂更新逻辑,由 Immer 将其转换为安全的不可变更新。

注意:我们的 action 对象仅包含描述事件所需的最简信息。我们知道需要更新的帖子和点击的反应名称。虽然可以预先计算新的计数器值并放入 action,但保持 action 对象最小化并在 reducer 中执行状态更新计算始终是更佳实践。这也意味着 reducer 可包含任意必要的状态计算逻辑。实际上,状态更新逻辑应放在 reducer 中!这能避免在不同组件中重复逻辑,或 UI 层可能未使用最新数据的问题。

信息

使用 Immer 时,可选择"改变"现有状态对象或自行返回新状态值,但不可同时进行两种操作。详见 Immer 文档中的常见陷阱返回新数据指南。

显示反应按钮

与帖子的作者和时间戳类似,我们希望在所有显示帖子的地方使用此功能,因此创建接收 post 属性的 <ReactionButtons> 组件。用户点击按钮时,我们将分发携带对应表情符号名称的 reactionAdded action。

features/posts/ReactionButtons.tsx
import { useAppDispatch } from '@/app/hooks'

import type { Post, ReactionName } from './postsSlice'
import { reactionAdded } from './postsSlice'

const reactionEmoji: Record<ReactionName, string> = {
thumbsUp: '👍',
tada: '🎉',
heart: '❤️',
rocket: '🚀',
eyes: '👀'
}

interface ReactionButtonsProps {
post: Post
}

export const ReactionButtons = ({ post }: ReactionButtonsProps) => {
const dispatch = useAppDispatch()

const reactionButtons = Object.entries(reactionEmoji).map(
([stringName, emoji]) => {
// Ensure TS knows this is a _specific_ string type
const reaction = stringName as ReactionName
return (
<button
key={reaction}
type="button"
className="muted-button reaction-button"
onClick={() => dispatch(reactionAdded({ postId: post.id, reaction }))}
>
{emoji} {post.reactions[reaction]}
</button>
)
}
)

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

现在每次点击反应按钮时,该反应的计数器应递增。如果在应用不同部分浏览,无论何时查看该帖子(即使在 <PostsList> 点击反应按钮后通过 <SinglePostPage> 单独查看),都应显示正确的计数值。这是因为所有组件都从同一个 Redux 存储读取帖子数据。

添加用户登录功能

本节还需实现最后一个功能。

当前我们仅在 <AddPostForm> 中选择每篇帖子的作者。为了更贴近实际,应让用户登录应用,这样我们就能知道发帖者身份(并为后续功能提供支持)。

由于这是一个小型示例应用,我们不会实现任何真正的身份验证检查(这里的重点是学习如何使用Redux特性,而非实际实现身份验证)。我们将改为展示用户名列表,让实际用户从中选择一个。

在此示例中,我们将添加一个auth切片来跟踪state.auth.username,这样就能知道当前用户是谁。之后我们可以在用户发帖时使用该信息,自动将正确的用户ID添加到帖子中。

添加Auth切片

第一步是创建authSlice并将其添加到store中。这遵循我们已熟悉的模式:定义初始状态,编写包含处理登录/注销更新的reducer的切片,然后将切片reducer添加到store。

本例中,我们的认证状态实际上就是当前登录的用户名,当用户注销时我们会将其重置为null

features/auth/authSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'

interface AuthState {
username: string | null
}

const initialState: AuthState = {
// Note: a real app would probably have more complex auth state,
// but for this example we'll keep things simple
username: null
}

const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
userLoggedIn(state, action: PayloadAction<string>) {
state.username = action.payload
},
userLoggedOut(state) {
state.username = null
}
}
})

export const { userLoggedIn, userLoggedOut } = authSlice.actions

export const selectCurrentUsername = (state: RootState) => state.auth.username

export default authSlice.reducer
app/store.ts
import { configureStore } from '@reduxjs/toolkit'

import authReducer from '@/features/auth/authSlice'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'

export const store = configureStore({
reducer: {
auth: authReducer,
posts: postsReducer,
users: usersReducer
}
})

添加登录页面

目前应用的主界面是包含帖子列表和发帖表单的<Posts>组件。我们将改变此行为:用户需先看到登录界面,登录后才能访问帖子页面。

首先创建<LoginPage>组件。该组件将从store读取用户列表,以下拉菜单形式展示,表单提交时派发userLoggedIn动作。我们还将导航至/posts路由,登录后即可看到<PostsMainPage>

features/auth/LoginPage.tsx
import React from 'react'
import { useNavigate } from 'react-router-dom'

import { useAppDispatch, useAppSelector } from '@/app/hooks'
import { selectAllUsers } from '@/features/users/usersSlice'

import { userLoggedIn } from './authSlice'

interface LoginPageFormFields extends HTMLFormControlsCollection {
username: HTMLSelectElement
}
interface LoginPageFormElements extends HTMLFormElement {
readonly elements: LoginPageFormFields
}

export const LoginPage = () => {
const dispatch = useAppDispatch()
const users = useAppSelector(selectAllUsers)
const navigate = useNavigate()

const handleSubmit = (e: React.FormEvent<LoginPageFormElements>) => {
e.preventDefault()

const username = e.currentTarget.elements.username.value
dispatch(userLoggedIn(username))
navigate('/posts')
}

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

return (
<section>
<h2>Welcome to Tweeter!</h2>
<h3>Please log in:</h3>
<form onSubmit={handleSubmit}>
<label htmlFor="username">User:</label>
<select id="username" name="username" required>
<option value=""></option>
{usersOptions}
</select>
<button>Log In</button>
</form>
</section>
)
}

接下来需要更新<App>组件的路由配置。根路由/应显示<LoginPage>,同时将所有未经授权的访问重定向回登录界面。

常见做法是添加"受保护路由"组件:接受React组件作为children,执行授权检查,仅当用户授权时才显示子组件。我们将创建<ProtectedRoute>组件读取state.auth.username进行认证检查,并将帖子相关路由配置整体包裹在<ProtectedRoute>中:

App.tsx
import {
BrowserRouter as Router,
Route,
Routes,
Navigate
} from 'react-router-dom'

import { useAppSelector } from './app/hooks'
import { Navbar } from './components/Navbar'
import { LoginPage } from './features/auth/LoginPage'
import { PostsMainPage } from './features/posts/PostsMainPage'
import { SinglePostPage } from './features/posts/SinglePostPage'
import { EditPostForm } from './features/posts/EditPostForm'

import { selectCurrentUsername } from './features/auth/authSlice'

const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const username = useAppSelector(selectCurrentUsername)

if (!username) {
return <Navigate to="/" replace />
}

return children
}

function App() {
return (
<Router>
<Navbar />
<div className="App">
<Routes>
<Route path="/" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<Routes>
<Route path="/posts" element={<PostsMainPage />} />
<Route path="/posts/:postId" element={<SinglePostPage />} />
<Route path="/editPost/:postId" element={<EditPostForm />} />
</Routes>
</ProtectedRoute>
}
/>
</Routes>
</div>
</Router>
)
}

export default App

现在可以观察到认证行为的双向运作:

  • 若用户未登录时尝试访问/posts<ProtectedRoute>将重定向至/并显示<LoginPage>

  • 用户登录时,我们派发userLoggedIn()更新Redux状态,强制导航至/posts,此时<ProtectedRoute>将显示帖子页面。

使用当前用户更新UI

既然我们能在应用使用过程中识别登录用户,就可以在导航栏显示该用户的实际姓名。同时应添加"退出登录"按钮提供注销功能。

我们需要从store获取当前用户对象以读取user.name进行显示。这可以通过:先从auth切片获取当前用户名,再用其查找相应用户对象。由于可能需要在多处执行此操作,此时适合将其编写为可复用的selectCurrentUser选择器。可将其放在usersSlice.ts中,但需导入并依赖authSlice.ts中的selectCurrentUsername

features/users/usersSlice.ts
import { selectCurrentUsername } from '@/features/auth/authSlice'

// omit the rest of the slice and selectors

export const selectCurrentUser = (state: RootState) => {
const currentUsername = selectCurrentUsername(state)
return selectUserById(state, currentUsername)
}

组合选择器并在一个选择器内部使用另一个选择器通常很有用。本例中,我们可以同时使用selectCurrentUsernameselectUserById

与我们构建的其他功能一致:从store中选择相关状态(当前用户对象),展示其值,当用户点击"退出登录"按钮时派发userLoggedOut()动作:

components/Navbar.tsx
import { Link } from 'react-router-dom'

import { useAppDispatch, useAppSelector } from '@/app/hooks'

import { userLoggedOut } from '@/features/auth/authSlice'
import { selectCurrentUser } from '@/features/users/usersSlice'

import { UserIcon } from './UserIcon'

export const Navbar = () => {
const dispatch = useAppDispatch()
const user = useAppSelector(selectCurrentUser)

const isLoggedIn = !!user

let navContent: React.ReactNode = null

if (isLoggedIn) {
const onLogoutClicked = () => {
dispatch(userLoggedOut())
}

navContent = (
<div className="navContent">
<div className="navLinks">
<Link to="/posts">Posts</Link>
</div>
<div className="userDetails">
<UserIcon size={32} />
{user.name}
<button className="button small" onClick={onLogoutClicked}>
Log Out
</button>
</div>
</div>
)
}

return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
{navContent}
</section>
</nav>
)
}

在此过程中,我们还应将 <AddPostForm> 切换为使用来自状态的登录用户名,而非显示用户选择下拉框。这可以通过移除所有与 postAuthor 输入字段相关的引用,并添加 useAppSelector 来读取 authSlice 中的用户 ID 实现:

features/posts/AddPostForm.tsx
export const AddPostForm = () => {
const dispatch = useAppDispatch()
const userId = useAppSelector(selectCurrentUsername)!

const handleSubmit = (e: React.FormEvent<AddPostFormElements>) => {
// Prevent server submission
e.preventDefault()

const { elements } = e.currentTarget
const title = elements.postTitle.value
const content = elements.postContent.value
// Removed the `postAuthor` field everywhere in the component

dispatch(postAdded(title, content, userId))

e.currentTarget.reset()
}

最后,允许当前用户编辑_其他_用户定义的帖子也不合理。我们可以更新 <SinglePostPage>,使其仅在帖子作者 ID 与当前用户 ID 匹配时才显示"编辑帖子"按钮:

features/posts/SinglePostPage.tsx
import { selectCurrentUsername } from '@/features/auth/authSlice'

export const SinglePostPage = () => {
const { postId } = useParams()

const post = useAppSelector(state => selectPostById(state, postId!))
const currentUsername = useAppSelector(selectCurrentUsername)!

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

const canEdit = currentUsername === post.user

return (
<section>
<article className="post">
<h2>{post.title}</h2>
<div>
<PostAuthor userId={post.user} />
<TimeAgo timestamp={post.date} />
</div>
<p className="post-content">{post.content}</p>
<ReactionButtons post={post} />
{canEdit && (
<Link to={`/editPost/${post.id}`} className="button">
Edit Post
</Link>
)}
</article>
</section>
)
}

登出时清除其他状态

还有一个认证处理环节需要关注。当前如果以用户 A 登录,创建新帖子后登出,再以用户 B 重新登录,我们会看到初始示例帖子和新创建的帖子。

这从代码实现角度是"正确"的——Redux 完全按照我们编写的逻辑运行。我们更新了 Redux 存储中的帖子列表状态,且未刷新页面,因此相同 JS 数据仍在内存中。但从应用行为角度看,这会造成混淆甚至侵犯隐私:若用户 B 与用户 A 无关联怎么办?若多人共享同一设备怎么办?他们登录时不应看到彼此的数据。

因此,理想方案是在当前用户登出时清除现有帖子状态。

在多个切片中处理 Actions

迄今为止,每次需要更新状态时,我们都定义新的 Redux case reducer,导出生成的动作创建器,并从组件中派发该动作。我们_可以_在此沿用此模式,但会导致连续派发两个独立 Redux 动作:

dispatch(userLoggedOut())
// This seems like it's duplicate behavior
dispatch(clearUserData())

每次派发动作时,整个 Redux 存储更新流程(运行 reducer、通知订阅的 UI 组件、重新渲染更新组件)都会触发。这符合 Redux 和 React 的工作机制,但连续派发两个动作通常意味着需要重新审视逻辑设计。

当前我们已派发 userLoggedOut() 动作(该动作从 auth 切片导出)。理想情况是让 posts 切片也能监听此动作。

前文提到,将动作视为**"应用中发生的事件"**而非"设置值的命令"更有助益。这正是该理念的实践场景:我们不需要单独的 clearUserData 动作——因为仅发生了一个事件:"用户已登出"。只需找到在多个位置响应同一个 userLoggedOut 动作的方法,即可同时应用所有相关状态更新。

使用 extraReducers 处理其他动作

幸运的是,我们可以实现!createSlice 接受名为**extraReducers**的选项,允许切片监听应用中其他位置定义的动作。每当这些外部动作被派发时,该切片也能更新自身状态。这意味着_多个_不同切片 reducer 均可响应_同一个_派发动作,且每个切片能在需要时更新自身状态!

extraReducers 字段是一个接收 builder 参数的函数。builder 对象包含三个方法,每个方法都允许切片监听其他动作并执行自身状态更新:

  • builder.addCase(actionCreator, caseReducer):监听特定动作类型

  • builder.addMatcher(matcherFunction, caseReducer):使用 Redux Toolkit 的"匹配器函数" 监听多个动作类型

  • builder.addDefaultCase(caseReducer):添加一个 case reducer,当此 slice 中没有其他匹配项时运行(相当于 switch 语句中的 default 情况)。

你可以链式调用这些方法,例如 builder.addCase().addCase().addMatcher().addDefaultCase()。如果多个匹配器(matcher)匹配同一个 action,它们将按照定义的顺序执行。

因此,我们可以从 authSlice.ts 导入 userLoggedOut action 到 postsSlice.ts,在 postsSlice.extraReducers 内部监听该 action,并在注销时返回空数组来重置帖子列表:

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

import { userLoggedOut } from '@/features/auth/authSlice'

// omit initial state and types

const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
// omit postAdded and other case reducers
},
extraReducers: (builder) => {
// Pass the action creator to `builder.addCase()`
builder.addCase(userLoggedOut, (state) => {
// Clear out the list of posts whenever the user logs out
return []
})
},
})

我们调用 builder.addCase(userLoggedOut, caseReducer)。在该 reducer 内部,我们_可以_编写"可变"状态更新逻辑,就像在 createSlice 中的其他 case reducer 一样。但由于我们需要_完全替换_现有状态,最简单的方法就是直接返回空数组作为新的帖子状态。

现在,如果我们点击"Log Out"按钮,然后以另一个用户身份登录,"Posts"页面应该显示为空。这很好!我们已成功在注销时清空了帖子状态。

reducersextraReducers 有什么区别?

createSlice 中的 reducersextraReducers 字段有不同的用途:

  • reducers 字段通常是一个对象。对于在 reducers 对象中定义的每个 case reducer,createSlice 会自动生成同名的 action creator 以及用于 Redux DevTools 的 action 类型字符串。使用 reducers 来定义作为 slice 组成部分的新 action
  • extraReducers 接受带有 builder 参数的函数,builder.addCase()builder.addMatcher() 方法用于处理其他 action 类型,而_不_定义新 action。使用 extraReducers 来处理在 slice _外部_定义的 action

学习要点

本节内容到此结束!我们完成了大量工作。现在我们可以查看和编辑单个帖子、显示每篇帖子的作者、添加表情符号(emoji)反应,并跟踪当前用户的登录和注销状态。

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

它确实开始变得更加实用和有趣了!

我们在本节中涵盖了大量信息和概念。让我们回顾一下需要记住的重点:

总结
  • 任何 React 组件都能按需使用 Redux 存储中的数据
    • 任何组件都可以读取 Redux 存储中的任意数据
    • 多个组件可以同时读取相同数据
    • 组件应仅提取渲染所需的最小数据量
    • 组件可结合 props、state 和 Redux 存储中的值来确定渲染内容,能读取多个数据片段并按展示需求重组
    • 任何组件都能通过派发 actions 触发状态更新
  • Redux action 创建器可准备包含正确内容的 action 对象
    • createSlicecreateAction 可接受返回 action 负载的 "准备回调函数"
    • 唯一 ID 等随机值应置于 action 中,而非在 reducer 内计算
  • Reducers 应包含实际的状态更新逻辑
    • Reducers 可包含任何计算新状态所需的逻辑
    • Action 对象只需包含描述事件发生的最小信息
  • 可编写可复用的 "selector" 函数封装 Redux 状态读取逻辑
    • Selectors 是以 Redux state 为参数并返回数据的函数
  • Actions 应被视为描述 "已发生事件",多个 reducers 可响应同一派发的 action
    • 应用通常应单次仅派发一个 action
    • Case reducer 名称(及 actions)通常应使用过去式命名,如 postAdded
    • 多个切片 reducers 均可响应同一 action 执行各自状态更新
    • createSlice.extraReducers 允许切片监听外部定义的 actions
    • 可通过在 case reducer 中返回全新值来重置状态,而非直接修改现有状态

下一步是什么?

至此您应已熟练掌握在 Redux 存储和 React 组件中使用数据。目前我们仅使用了初始状态数据或用户添加的数据。在第五部分:异步逻辑与数据获取中,我们将学习如何处理来自服务器 API 的数据。