Redux Essentials Part 4 详解:在 React 组件中使用 Redux 数据(选择器、prepare 回调与 extraReducers)
本篇基于 Redux 官方仓库中的 Essentials 系列教程第四部分,围绕"如何让多个 React 组件正确地读、改 Redux 数据"展开:从单帖页面与编辑表单,到 prepare 回调准备 action payload、可复用选择器封装状态读取、以及用 extraReducers 让多个 slice 响应同一个 action。读完后,你将掌握组织 dispatch 逻辑、封装 selector、在多 slice 应用间共享事件这几类 Redux 核心模式,并能理解它们背后的 store 更新机制。
前置知识与本章目标
本章假设你已经掌握 Part 3 的 Redux 数据流与 React-Redux API——即如何从空项目起步、添加 slice、用 useSelector/useDispatch 读数据和派发 action,以及如何创建带类型的 useAppSelector/useAppDispatch。熟悉 React Router 的 <Link> 与 <Route> 页面路由也有帮助。
在 Part 3 里我们完成了社交媒体信息流的基础版。本章用同样的核心步骤继续给应用加功能,使其更实用:
- 查看单篇帖子(
SinglePostPage) - 编辑已有帖子(
EditPostForm+postUpdatedreducer) - 显示帖子作者(新增
usersslice) - 帖子时间戳与相对时间显示(
date字段 +TimeAgo) - emoji 反应按钮(
reactionAddedreducer) - 用户登录/登出(
authslice +ProtectedRoute+extraReducers)
本章代码示例聚焦每部分的关键概念与改动,完整可运行代码见 Redux Essentials 官方示例工程(redux-essentials-example-app)中对应 checkpoint 的分支。
显示单篇帖子
主信息流里帖子正文过长时只显示节选。为了能完整阅读,我们需要一个独立的帖子详情页,URL 形如 /posts/123,其中 123 是要查看的帖子 ID。
创建 SinglePostPage 组件
在 features/posts 功能文件夹中新增 SinglePostPage 组件。路由配置会告诉 React Router 把 URL 的第二段解析为名为 postId 的变量,组件通过 useParams 钩子读取它:
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>
)
}
拿到 postId 后,我们在 selector 函数里用它从 Redux store 中找到对应的帖子对象。state.posts 是全部帖子的数组,用 Array.find() 遍历并返回 ID 匹配的那条即可。
有两个关键点值得注意:
- 组件会在
useAppSelector返回值变成新引用时重新渲染。 组件应始终尽量只选择它渲染所需的最小数据集,这样才能确保只在真正需要时渲染。 - 必须处理"找不到"的分支。 用户可能直接手敲 URL,或者数据尚未加载,此时
find()返回undefined而不是帖子对象。组件要检查这种情况并显示 "Post not found!" 提示;若能取到帖子对象,则渲染标题与正文。
这段逻辑和 <PostsList> 里遍历 posts 数组渲染节选的代码比较相似。你可以试着抽出一个 Post 组件在两个地方复用,但帖子节选与完整帖子的展示方式已有差别——通常更好的做法是先各自独立编写,观察一段时间后如果发现两块代码足够相似,再抽取可复用组件。
添加单帖路由与链接
组件就位后,在 App.tsx 中定义路由。顺便把"主页"内容抽成独立的 <PostsMainPage> 组件提升可读性:
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>:
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> 里也应提供一个返回主帖列表页的链接:
import { Link } from 'react-router-dom'
export const Navbar = () => {
return (
<nav>
<section>
<h1>Redux Essentials Example</h1>
<div className="navContent">
<div className="navLinks">
<Link to="/">Posts</Link>
</div>
</div>
</section>
</nav>
)
}
编辑帖子
写完帖子保存后才发现写错地方是很常见的体验,所以我们需要"编辑已有帖子"的能力。新组件 <EditPostForm> 要能接收一个帖子 ID,从 store 中读出该帖,让用户修改标题和正文,保存后把改动写回 store。
更新帖子条目:postUpdated reducer
首先要更新 postsSlice,新增一个 reducer 函数和对应 action,让 store 知道如何更新帖子。在 createSlice() 的 reducers 对象里添加一个新函数。reducer 的命名应当能清晰描述正在发生的事,因为它会作为 action type 字符串的一部分出现在 Redux DevTools 里。第一个 reducer 叫 postAdded,所以这个叫 postUpdated:
Redux 本身不关心你给 reducer 函数起什么名字——叫
postAdded、addPost、POST_ADDED还是someRandomName,运行效果完全一样。但官方建议用过去式命名(如postAdded),因为我们在描述的是"应用中发生过的一个事件"。
要更新一个帖子对象,需要知道:
- 被更新帖子的 ID,以便在 state 中找到正确对象
- 用户输入的新
title和content字段
Redux action 对象必须有 type 字段(通常是有描述性的字符串),还可以携带其他描述"发生了什么"的字段。约定上把附加信息放在 action.payload 中,而 payload 里放什么由我们自己决定——字符串、数字、对象、数组都行。这里有三块信息(id、title、content),所以让 payload 是一个包含这三个字段的对象,action 对象形如 {type: 'posts/postUpdated', payload: {id, title, content}}。
createSlice 生成的 action creator 默认期望传入一个参数,该值会成为 action.payload。和 postAdded 一样,这里传的是完整 Post 对象,因此 reducer 参数声明为 action: PayloadAction<Post>。
reducer 负责决定 state 实际如何更新:根据 ID 找到帖子对象,只更新其 title 和 content 字段:
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
最后把 createSlice 生成的 action creator 导出,这样 UI 在用户保存帖子时才能派发 postUpdated。
创建 EditPostForm 组件
<EditPostForm> 看起来类似 <AddPostForm> 和 <SinglePostPage>,但逻辑略有不同:需要根据 URL 中的 postId 从 store 取出帖子对象,用它初始化输入框,用户提交时把新的标题和正文写回 store,并用 React Router 的 useNavigate 钩子在保存后跳转到单帖页:
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 从 store 读值,在用户与 UI 交互时通过 useAppDispatch 派发 action,仅此而已。
和 SinglePostPage 一样,需要在 App.tsx 中导入并添加以 postId 为路由参数的路由:
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 的链接:
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>
用 prepare 回调准备 Action Payload
前面看到,createSlice 生成的 action creator 通常接收一个参数并作为 action.payload。这简化了最常见的使用方式,但有时需要更多工作来准备 action 对象的内容。以 postAdded 为例:我们需要为新帖子生成一个唯一 ID,还要保证 payload 形如 {id, title, content}。
当前我们在 React 组件里生成 ID、构造 payload 对象再传给 postAdded。但如果有多个组件需要派发同一个 action,或者构造 payload 的逻辑很复杂呢?那样每次派发都要重复这套逻辑,而且组件被迫要知道这个 action 的 payload 长什么样。
如果 action 需要包含唯一 ID 或其他随机值,一定要先生成好再放进 action 对象。Reducer 永远不应计算随机值,那会让结果不可预测。
如果手写 action creator,可以把准备工作逻辑放进去:
// hand-written action creator
function postAdded(title: string, content: string) {
const id = nanoid()
return {
type: 'posts/postAdded',
payload: { id, title, content }
}
}
但 createSlice 已经替我们生成了这些 action creator——代码更短,却需要另一种方式定制 action.payload。createSlice 提供了"prepare 回调":reducer 定义可以是一个 {reducer, prepare} 对象,prepare 函数可以接收多个参数、生成唯一 ID 等随机值、执行任意同步逻辑来决定 action 对象的内容,最后返回一个含 payload 字段的对象(返回对象还可以包含 meta 字段用于附加描述性信息,以及表示该 action 是否代表某种错误的布尔 error 字段):
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 会把它组装好。于是可以把组件改成直接传入 title 和 content 两个参数,ID 自动生成:
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()
}
源码佐证:本章教程使用的是
@reduxjs/toolkit的configureStore。在本仓库核心包的 src/createStore.ts 中可以看到dispatch的基础校验:action 必须是普通对象(isPlainObject检查)、type必须是字符串且不能为undefined,并且 reducer 执行期间禁止再次 dispatch("Reducers may not dispatch actions.")。这正是"action 是纯数据事件、随机值必须在 dispatch 前生成进 payload"这条规则在核心层的依据——校验逻辑见 src/utils/isAction.ts。同时,src/createStore.ts 的 JSDoc 也明确标注createStore已弃用,推荐使用@reduxjs/toolkit的configureStore——教程示例工程正是遵循这一官方建议。
用选择器(Selectors)读取数据
此时已经有好几个组件在按 ID 查找帖子,重复着 state.posts.find() 调用。这既是重复代码,又是脆弱的——后续章节我们会改变 posts slice 的 state 结构(规范化),届时必须找到每处引用 state.posts 的代码逐一修改。TypeScript 能在编译期帮我们抓住与 state 类型不匹配的代码,但更理想的是:改数据格式时不必反复重写组件,也不必在组件里重复逻辑。
一个办法是在 slice 文件中定义可复用的 selector 函数,组件用这些 selector 提取数据。这样以后 state 结构变化时,只需要更新 slice 文件里的代码。
定义 Selector 函数
每次调用 useAppSelector(state => state.posts) 时其实都在写 selector 函数,只是定义为内联形式。它就是一个普通函数,也可以写成独立形式:
const selectPosts = (state: RootState) => state.posts
const posts = useAppSelector(selectPosts)
Selector 通常作为独立函数写在 slice 文件里。它们一般以整个 Redux RootState 作为第一个参数,还可以接受其他参数。
抽取 Posts Selectors
<PostsList> 需要读取全部帖子列表,<SinglePostPage> 和 <EditPostForm> 需要按 ID 查找单帖。我们从 postsSlice.ts 导出两个小 selector:
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)
注意这些 selector 的 state 参数就是根 state 对象,与直接写在 useAppSelector 里的内联匿名 selector 一样。
组件中使用方式:
// omit imports
import { selectAllPosts } from './postsSlice'
export const PostsList = () => {
const posts = useAppSelector(selectAllPosts)
// omit component contents
}
// omit imports
import { selectPostById } from './postsSlice'
export const SinglePostPage = () => {
const { postId } = useParams()
const post = useAppSelector(state => selectPostById(state, postId!))
// omit component logic
}
// 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 的 ! 非空断言告诉编译器此处不会是 undefined。(这有一定危险性,但这里可以这样假设,因为我们知道路由配置只在 URL 中有帖子 ID 时才渲染 <EditPostForm>。)
后续我们延续这个模式:selector 写在 slice 中,而不是内联在组件的 useAppSelector 里。记住这并非强制要求,但是一个值得遵循的好模式!
有效使用选择器
封装数据查找、写可复用 selector 通常是好主意。理想情况下组件甚至不需要知道数据存放在 Redux state 的哪个位置——直接用 slice 提供的 selector 访问即可。
之后教程还会介绍"记忆化(memoized)selector",通过优化重渲染、跳过不必要的重算来提升性能。
但和任何抽象一样,它不应该是无时无刻、无处不做的事。写 selector 意味着更多需要理解和维护的代码。不要觉得每个 state 字段都必须写 selector。可以先不写,等发现自己在应用的很多地方查找同一批值时,再补上。
可选:在 createSlice 内定义 Selector
前面我们看到 selector 可以写为 slice 文件中的独立函数。有时也可以直接在 createSlice 内部定义来精简代码:createSlice 除了必需的 name、initialState、reducers 字段和可选的 extraReducers 字段外,还接受一个可选的 selectors 字段。它的形状类似 reducers:键是 selector 函数名,值是待生成的 selector 函数。
与独立 selector 函数的关键区别:这些 selector 的 state 参数只是该 slice 的 slice state,而不再是整个 RootState!
把 posts slice 的 selector 移到 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)
仍然有些场合必须在 createSlice 外写独立 selector 函数——尤其是当你要调用其他需要完整 RootState 作为参数的 selector 时,独立函数才能让类型正确对齐。
用户与帖子
目前应用只有一个 state slice:逻辑在 postsSlice.ts,数据在 state.posts,所有组件都与 posts 功能相关。真实应用通常有很多不同的 state slice 和若干"功能文件夹"。
社交媒体应用总得有"别人"参与!我们给应用加上维护用户列表的能力,并让帖子功能利用这些数据。
添加 Users Slice
"users"和"posts"是不同概念,所以要把它们的代码和数据分开:新增 features/users 文件夹并放入 usersSlice 文件。和 posts slice 一样,先加几条初始数据以便操作:
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 字段留空对象(后面章节会补上)。和之前一样,把 usersReducer 导入 store 文件并加入配置:
import { configureStore } from '@reduxjs/toolkit'
import postsReducer from '@/features/posts/postsSlice'
import usersReducer from '@/features/users/usersSlice'
export default configureStore({
reducer: {
posts: postsReducer,
users: usersReducer
}
})
现在根 state 的形状是 {posts, users},与传给 reducer 参数的对象一致。
为帖子添加作者字段
应用里每篇帖子都由某个用户撰写,每次新增帖子都应记录是哪位用户写的。这需要改动 Redux state 和 <AddPostForm> 组件两部分。
首先更新 Post 数据类型,加入 user: string 字段(创建该帖的用户 ID),并给 initialState 中的已有帖子补上 user 字段。然后更新现有 reducer:postAdded 的 prepare 回调要接收用户 ID 参数并放入 action;而更新帖子时不想携带 user 字段——更新只需要被改帖子的 id 以及新的 title、content。为此定义一个只含这三个字段的 PostUpdate 类型,作为 postUpdated 的 payload:
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> 可以用 useSelector 从 store 读取用户列表并展示为下拉框,取选中用户的 ID 传给 postAdded action creator。顺带给表单加一点校验:只有标题和正文都填了内容才允许点 "Save Post":
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"
name="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,查出对应的用户对象并格式化用户名:
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 store 读数据的组件都可以用 useAppSelector 钩子提取它需要的具体数据;同时,许多组件可以同时访问 Redux store 中的同一份数据。
把 PostAuthor 导入 PostsList.tsx 和 SinglePostPage.tsx,以 <PostAuthor userId={post.user} /> 渲染即可。每新增一条帖子,选中用户的名字都会显示在渲染的帖子里。
更多帖子功能:时间戳与列表排序
为帖子存储日期
社交媒体信息流通常按创建时间排序,并以"5 hours ago"这类相对描述显示创建时间。为此需要给帖子条目跟踪 date 字段。
和 post.user 字段一样,要更新 postAdded 的 prepare 回调保证派发时总带上 post.date。但它不是传入的参数——我们总是想用派发那一刻的时间戳,所以让 prepare 回调自己生成:
Redux 的 action 和 state 应只包含纯 JS 值:对象、数组和基本类型。不要把类实例、函数、
Date/Map/Set实例或其他不可序列化值放进 Redux!
既然不能直接把 Date 类实例放进 store,post.date 就以时间戳字符串形式保存。初始 state 值里加上 date(用 date-fns 的 sub 从当前时间往前减几分钟),prepare 回调里也加:
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
}
})
相对时间显示组件 TimeAgo
和作者一样,相对时间戳描述需要在 <PostsList> 和 <SinglePostPage> 中都显示。新增 <TimeAgo> 组件,用 date-fns 的 parseISO 和 formatDistanceToNow 把时间戳字符串格式化为相对描述:
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}>
<i>{timeAgo}</i>
</time>
)
}
排序帖子列表
<PostsList> 当前按帖子在 store 中的存储顺序显示:示例里最老的帖子在前,新帖追加到数组末尾——新帖总是出现在页面底部。社交信息流通常是最新在前。虽然我们知道 state.posts 数组已经是"最老在前"排好的,理论上反转列表即可,但为了保险还是自己显式排序。
由于 array.sort() 会原地修改现有数组,需要先复制 state.posts 再对副本排序。post.date 保存的是 ISO 时间戳字符串,可以直接字符串比较来排序:
// 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 => {
// omit rendering logic
})
帖子反应按钮
我们的帖子现在还有点无聊——让朋友们能给帖子加 emoji 反应吧!在 <PostsList> 和 <SinglePostPage> 的每篇帖子底部加一排 emoji 反应按钮。用户每点一次,就要更新 Redux store 中该帖子对应的计数器字段。由于反应计数数据在 Redux store 里,在应用不同部分间切换时,任何使用该数据的组件都会一致地显示相同的值。
在帖子中跟踪反应数据
数据里还没有 post.reactions 字段,所以要更新 initialState 帖子对象和 postAdded 的 prepare 回调,保证每篇帖子都带有如 reactions: {thumbsUp: 0, tada: 0, heart: 0, rocket: 0, eyes: 0} 的数据。
然后定义处理"用户点击反应按钮"的新 reducer。和编辑帖子类似,需要知道帖子 ID 和用户点了哪个反应。action.payload 是形如 {id, reaction} 的对象,reducer 找到对应帖子并更新正确的 reactions 字段:
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
几个重要的 Redux 理念在这里集中体现:
createSlice允许在 reducer 里写"看起来像修改(mutating)"的逻辑。 如果不用createSlice和 Immer,existingPost.reactions[reaction]++这一行会真的修改已有post.reactions对象,违反 reducer 规则并很可能在应用其他地方引发 bug。而因为用了createSlice,这种更复杂的更新逻辑可以写得更简单,由 Immer 负责把它变成安全的不可变更新。- action 对象只包含描述"发生了什么"所需的最少信息:哪个帖子、哪个反应名被点击。可以把算好的新计数值放进 action,但action 对象越小越好,状态更新计算应放在 reducer 里。
- 由此推出:reducer 可以包含计算新 state 所需的任意多逻辑,状态更新逻辑应该写在 reducer 里。这避免了在不同组件里重复逻辑、或 UI 层拿不到最新数据的问题。
使用 Immer 时,你可以"修改"现有 state 对象,或者自己返回一个新 state 值,但不能两者同时做。
显示 ReactionButtons 组件
和作者、时间戳一样,反应要在所有显示帖子的地方使用,所以创建 <ReactionButtons> 组件,接收 post 作为 prop。用户点击按钮时,用该反应的 emoji 名派发 reactionAdded:
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 store 中同一份帖子数据。
添加用户登录
添加 Auth Slice
目前是在 <AddPostForm> 里手动选择"谁在写帖子"。为了更真实,应让用户先登录,这样就知道帖子是谁写的(也为后续功能打基础)。作为小型示例应用,这里不实现真正的认证校验(重点是学习如何使用 Redux 功能,而非实现真正的 auth),只显示用户名列表让用户选一个。
新增一个 auth slice 跟踪 state.auth.username,之后用户添加帖子时就能自动带上正确的用户 ID。认证 state 就是当前登录用户名,登出时重置为 null:
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
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
}
})
添加登录页与 ProtectedRoute
改变应用入口行为:用户首先看到登录界面,登录后才能看到帖子页。创建 <LoginPage> 组件:从 store 读取用户列表、展示为下拉框,表单提交时派发 userLoggedIn,然后导航到 /posts 路由以显示 <PostsMainPage>:
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 做检查,把帖子相关的路由区块整个包起来:
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 state,并强制导航到/posts,此时<ProtectedRoute>会放行显示帖子页。
用当前用户更新 UI
既然应用运行期间已经知道登录者是谁,就可以在导航栏显示真实用户名,并提供"Log Out"按钮。
需要先从 store 拿到当前用户对象以读取 user.name。做法是先用 auth slice 拿到当前用户名,再据此查找用户对象。这个操作可能在多处需要,适合写成一个可复用的 selectCurrentUser selector。把它放在 usersSlice.ts 中,内部导入并依赖 authSlice.ts 的 selectCurrentUsername:
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)
}
把一个 selector 组合在另一个 selector 内部使用常常很有用——这里同时组合了 selectCurrentUsername 和 selectUserById。
其余模式与前文一致:从 store 选中相关 state(当前用户对象)、显示其值,点击 "Log Out" 按钮时派发 userLoggedOut():
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> 也改为使用 state 中的登录用户名,去掉用户下拉框:移除所有 postAuthor 输入字段的引用,加一个 useAppSelector 从 authSlice 读取用户 ID:
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 时才显示 "Edit Post" 按钮:
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>
)
}
登出时清空其他 Slice 的 State
还差最后一块认证拼图。目前如果以用户 A 登录、新建帖子、登出,再以用户 B 登录,会同时看到初始示例帖子和用户 A 的新帖。这从代码角度是"正确"的——Redux 按我们写的逻辑工作:posts 列表 state 被更新了,页面没刷新,同一份 JS 数据仍在内存中。但从应用行为看这令人困惑,甚至可能是隐私泄露:如果用户 B 和 A 互不相识,或多人共用一台电脑,登录时不应看到彼此的数据。
因此应该在当前用户登出时清空已有的 posts state。
让多个 Slice 处理同一个 Action
以往每次想更新 state,我们都定义一个新的 case reducer、导出生成的 action creator、再从组件派发。这里可以照做,但会变成连续派发两个 action:
dispatch(userLoggedOut())
// This seems like it's duplicate behavior
dispatch(clearUserData())
每派发一次 action,整个 Redux store 更新流程都要走一遍——运行 reducer、通知订阅的 UI 组件、重渲染更新的组件。这没问题,是 Redux 和 React 的工作方式,但连续派发两个 action 通常是个信号,提示我们需要重新思考逻辑的组织方式。
我们已经在派发 userLoggedOut() 了,只是它是从 auth slice 导出的 action。如果能让 posts slice 也监听它就更好了。
回想之前的建议:把 action 理解为**"应用中发生的一个事件",而不是"设置某个值的命令"**。这里是这个理念的实际体现——我们不需要单独的 clearUserData action,因为只发生了一个事件:"用户登出了"。我们只需要一种方式让这一个 userLoggedOut action 在多处被处理,从而一次性应用所有相关的 state 更新。
使用 extraReducers 处理外部 Action
createSlice 接受一个名为 extraReducers 的选项,让 slice 监听应用中其他地方定义的 action。每当这些外部 action 被派发,该 slice 也可以更新自己的 state。也就是说:许多不同的 slice reducer 都可以响应同一个被派发的 action,每个 slice 按需更新自己的 state!
extraReducers 字段是一个接收 builder 参数的函数。builder 对象上有三个方法,各让 slice 以不同方式监听其他 action 并做 state 更新:
builder.addCase(actionCreator, caseReducer):监听一个特定的 action type;builder.addMatcher(matcherFunction, caseReducer):用 Redux Toolkit 的 "matcher" 函数比较 action 对象,监听多种 action type 中的一种;builder.addDefaultCase(caseReducer):添加一个 case reducer,当本 slice 中其他都没匹配到该 action 时运行(等价于switch中的default分支)。
这些方法可以链式调用,如 builder.addCase().addCase().addMatcher().addDefaultCase()。若多个 matcher 都匹配,它们按定义顺序执行。
据此,把 userLoggedOut 从 authSlice.ts 导入 postsSlice.ts,在 postsSlice.extraReducers 中监听该 action,返回空数组以在登出时重置帖子列表:
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 里可以写和其他 case reducer 一样的"修改式"状态更新;但既然我们要整体替换现有 state,最简单的做法就是直接返回一个空数组作为新的 posts state。
现在点击 "Log Out" 按钮再以另一用户登录,"Posts" 页面就是空的——我们成功在登出时清空了 posts state。
reducers 和 extraReducers 的区别:
reducers字段通常是对象。对reducers对象中定义的每个 case reducer,createSlice会自动生成同名的 action creator,以及用于在 Redux DevTools 中显示的 action type 字符串。用reducers定义 slice 的一部分新 action。extraReducers接收一个带builder参数的函数,builder.addCase()和builder.addMatcher()用于处理其他 action type,不定义新 action。用extraReducers处理在 slice 外部定义的 action。
源码佐证:为什么"多个 slice 响应同一 action"在底层必然成立? 在本仓库核心包的 src/combineReducers.ts 中,
combineReducers返回的combination函数对每一个传入的 action 都会遍历所有子 reducer 逐一调用reducer(previousStateForKey, action),并把各自返回值收集为新的 state 对象,只有当某个子 state 的引用发生变化时(hasChanged)才返回新对象,否则返回原 state 引用。这正是教程中"posts slice 监听 auth slice 的userLoggedOut"能工作的机制:configureStore底层就是combineReducers的产物,每个 slice reducer 都会收到同一 action,自己决定是否返回新 state。而createStore的dispatch(src/createStore.ts)在 reducer 计算出currentState后立即遍历所有订阅者listener(),触发 React-Redux 的选中值比对与重渲染——所以"连续派发两个 action 会走两遍完整流程"的说法在源码层面可以直接验证。combineReducers还会对子 reducer 做形状断言(assertReducerShape,src/combineReducers.ts):任何对未知 action 返回undefined的 reducer 都会抛错,这也解释了为什么 case reducer 不处理某 action 时必须返回原 state。
本章要点回顾
到这里,应用已经能查看和编辑单帖、显示每篇帖子的作者、添加 emoji 反应、跟踪登录/登出的当前用户了。总结本章需要记住的核心要点:
- 任何 React 组件都可以按需使用 Redux store 中的数据
- 任何组件都能读取 store 中的任意数据
- 多个组件可以同时读取同一份数据
- 组件应提取渲染自身所需的最小数据集
- 组件可以把 props、state 和 Redux store 的值组合起来决定渲染什么 UI,可以从 store 读多份数据并按显示需要重塑数据
- 任何组件都能派发 action 引发 state 更新
- Redux action creator 可以用 prepare 回调准备内容正确的 action 对象
createSlice和createAction都接受返回 action payload 的 "prepare 回调"- 唯一 ID 和随机值应放进 action,而不是在 reducer 里计算
- Reducer 应包含真正的状态更新逻辑
- Reducer 可以包含计算下一个 state 所需的任意逻辑
- Action 对象只包含描述"发生了什么"所需的信息
- 可以写可复用的 "selector" 函数封装从 Redux state 中读取值
- Selector 是以 Redux
state为参数、返回某些数据的函数
- Selector 是以 Redux
- Action 应被理解为描述"已发生的事件",许多 reducer 可以响应同一个被派发的 action
- 应用通常一次只派发一个 action
- Case reducer 名(和 action)通常用过去式命名,如
postAdded - 许多 slice reducer 可以对同一个 action 各自做 state 更新
createSlice.extraReducers让 slice 监听在 slice 外部定义的 action- 可以从 case reducer 返回一个新值作为替换来重置 state 值,而不是修改现有 state
下一步
到这里,你应该已经熟悉如何在 Redux store 和 React 组件之间处理数据。目前我们只使用了初始 state 里的数据或用户添加的数据。在 Part 5: Async Logic and Data Fetching 中,我们将学习如何处理来自服务器 API 的数据。
(本文基于 docs/tutorials/essentials/part-4-using-data.md 原文完整内容撰写,源码级佐证来自 src/createStore.ts、src/combineReducers.ts、src/utils/isAction.ts。注意:本仓库是 redux 核心包,教程示例使用 @reduxjs/toolkit 的 configureStore 与 createSlice,仓库核心包的 createStore 已标记弃用并推荐迁移到 Redux Toolkit,两者行为机制在 dispatch 校验与 reducer 组合层面保持一致。)
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00