Redux 基础教程,第 3 部分:状态、操作与 Reducer
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
- 如何定义包含应用数据的状态值
- 如何定义描述应用事件的操作对象
- 如何编写基于当前状态和操作计算更新状态的 reducer 函数
- 熟悉 Redux 的核心术语与概念,如"操作"、"reducer"、"store"和"派发"(具体解释请参见 第 2 部分:Redux 概念与数据流)
简介
在第 2 部分:Redux 概念与数据流中,我们探讨了 Redux 如何通过提供单一全局状态存储来构建可维护应用,并介绍了派发操作对象和使用返回新状态的 reducer 函数等核心概念。
现在您已初步了解这些概念,是时候付诸实践了。我们将构建一个示例应用来演示这些部分如何协同工作。
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
请注意,本教程有意展示旧式的 Redux 逻辑模式(这些模式比我们当前推荐的 "现代 Redux" 模式需要编写更多代码),目的是为了解释 Redux 背后的原理和概念。它_并非_用于生产环境的项目。
学习使用 Redux Toolkit 实现 "现代 Redux" 模式,请参考以下文档:
- 完整的 "Redux 必备教程":使用 Redux Toolkit 教授"如何以正确方式使用 Redux"构建真实应用。我们推荐所有 Redux 学习者阅读此教程!
- Redux 基础教程,第 8 部分:使用 Redux Toolkit 的现代 Redux:展示如何将前面章节的底层示例转换为现代 Redux Toolkit 实现
项目设置
本教程提供了预配置的初始项目,包含 React 基础框架、默认样式和模拟 REST API(用于在应用中编写实际 API 请求)。您将以此为基础编写实际应用代码。
您可以通过以下 CodeSandbox 开始项目(建议先 Fork):
您也可以从该 GitHub 仓库克隆项目。克隆后,通过 npm install 安装依赖,并通过 npm start 启动项目。
如需查看最终成果,请访问tutorial-steps 分支或在此 CodeSandbox 查看完整版本。
创建新的Redux + React项目
完成本教程后,您可能希望尝试自己的项目。我们推荐使用 Create-React-App 的 Redux 模板作为创建 Redux + React 项目的最快方式。该模板预配置了 Redux Toolkit 和 React-Redux,并采用第 1 部分中"计数器"应用的现代化版本,让您无需配置即可直接编写业务代码。
如需了解向项目添加 Redux 的具体细节,请参阅以下说明:
Detailed Explanation: Adding Redux to a React Project
The Redux template for CRA comes with Redux Toolkit and React-Redux already configured. If you're setting up a new project from scratch without that template, follow these steps:
- Add the
@reduxjs/toolkitandreact-reduxpackages - Create a Redux store using RTK's
configureStoreAPI, and pass in at least one reducer function - Import the Redux store into your application's entry point file (such as
src/index.js) - Wrap your root React component with the
<Provider>component from React-Redux, like:
root.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)
探索初始项目
初始项目基于标准 Vite 模板进行定制开发。
让我们快速浏览一下初始项目包含的内容:
/srcindex.js:应用入口文件,渲染主组件<App>App.js:主应用组件index.css:全局样式文件/apiclient.js:轻量级fetch封装客户端,用于发起 HTTP GET/POST 请求server.js:提供模拟 REST API,后续将从这些端点获取数据
/exampleAddons:包含额外的 Redux 插件,将在后续教程中演示
现在加载应用,您会看到欢迎信息,其余部分为空。
那么,让我们开始吧!
启动待办事项示例应用
我们的示例应用将是一个简单的“待办事项”应用。你可能之前见过待办事项应用的例子 —— 它们是非常好的示例,因为可以展示如何跟踪项目列表、处理用户输入以及数据变化时更新UI,这些都是常规应用中常见的功能。
定义需求
让我们先明确该应用的基本业务需求:
-
UI应包含三个主要部分:
- 一个输入框,供用户输入新待办事项的文本
- 现有所有待办事项的列表
- 页脚部分,显示未完成待办事项的数量及筛选选项
-
待办事项列表中的每个项目应包含:
- 用于切换“完成”状态的复选框
- 可添加预定义颜色的分类标签
- 删除功能
-
计数器应根据未完成待办事项数量正确使用复数形式:“0个项目”、“1个项目”、“3个项目”等
-
提供以下功能按钮:
- 将所有待办事项标记为已完成
- 清除所有已完成事项
-
提供两种列表筛选方式:
- 按状态筛选:“全部”、“进行中”、“已完成”
- 按颜色筛选:选择一种或多种颜色标签
后续我们会添加更多需求,但目前这些已足够开始开发。
最终目标应用的界面应如下图所示:

设计状态值
React和Redux的核心原则之一是UI应基于状态。因此,设计应用时应先思考描述应用运作所需的所有状态。同时,尽量用最少的状态值描述UI也是好实践,可减少需要跟踪和更新的数据量。
从概念上讲,该应用包含两个主要方面:
-
当前待办事项的实际列表
-
当前的筛选选项
我们还需要记录用户在“添加待办事项”输入框中输入的内容,但这一部分相对次要,稍后再处理。
每个待办事项需要存储以下信息:
-
用户输入的文本内容
-
表示是否完成的布尔值
-
唯一ID值
-
颜色分类(若已选择)
筛选行为可通过枚举值描述:
-
完成状态:"全部"、"进行中"、"已完成"
-
颜色:"红色"、"黄色"、"绿色"、"蓝色"、"橙色"、"紫色"
观察这些值,我们可以将待办事项视为“应用状态”(应用运作的核心数据),而筛选值则是“UI状态”(描述应用当前行为的状态)。区分这些类别有助于理解状态各部分的使用方式。
设计状态结构
在Redux中,应用状态始终以普通JavaScript对象和数组的形式保存。这意味着不可将其他内容放入Redux状态中 —— 包括类实例、内置JS类型(如Map/Set/Promise/Date)、函数或任何非普通JS数据。
Redux的根状态值几乎总是一个普通JS对象,其中嵌套了其他数据。
基于以上信息,我们现在可以描述 Redux 状态中需要包含的值:
-
首先需要一个待办事项对象数组。每个对象应包含以下字段:
id:唯一数字标识text:用户输入的文本内容completed:表示完成状态的布尔值color:可选的分类颜色
-
其次需要描述筛选选项。我们需要:
- 当前"完成状态"筛选值
- 当前选中的颜色分类数组
以下是我们应用状态的示例结构:
const todoAppState = {
todos: [
{ id: 0, text: 'Learn React', completed: true },
{ id: 1, text: 'Learn Redux', completed: false, color: 'purple' },
{ id: 2, text: 'Build something fun!', completed: false, color: 'blue' }
],
filters: {
status: 'Active',
colors: ['red', 'blue']
}
}
重要提示:允许在 Redux 之外维护其他状态值! 当前示例规模较小,因此将所有状态存入 Redux 存储是可行的。但后续我们会看到,某些数据(如"下拉菜单是否打开"或"表单输入框的当前值")其实无需存入 Redux。
设计动作(Actions)
动作是包含 type 字段的普通 JavaScript 对象。如前所述,可将动作视为描述应用中发生事件的信号。
就像根据应用需求设计状态结构那样,我们同样可以列出描述各类事件的动作:
-
根据用户输入添加新待办事项
-
切换待办事项的完成状态
-
为待办事项选择颜色分类
-
删除待办事项
-
将所有待办事项标记为完成
-
清除所有已完成事项
-
更改"完成状态"筛选值
-
添加新的颜色筛选条件
-
移除颜色筛选条件
通常我们会将描述事件所需的额外数据放入 action.payload 字段。这可以是数字、字符串或包含多个字段的对象。
Redux 存储本身不关心 action.type 字段的实际文本内容。但您的代码需要通过检查 action.type 判断是否需要更新。此外,在调试时您会频繁通过 Redux DevTools 查看动作类型字符串来理解应用行为。因此,请选择可读性强且能清晰描述事件的动作类型——这将大幅提升后续代码可读性!
根据上述事件列表,我们可以定义应用将使用的动作集合:
-
{type: 'todos/todoAdded', payload: todoText} -
{type: 'todos/todoToggled', payload: todoId} -
{type: 'todos/colorSelected', payload: {todoId, color}} -
{type: 'todos/todoDeleted', payload: todoId} -
{type: 'todos/allCompleted'} -
{type: 'todos/completedCleared'} -
{type: 'filters/statusFilterChanged', payload: filterValue} -
{type: 'filters/colorFilterChanged', payload: {color, changeType}}
本例中动作通常只需携带单条数据,因此可直接放入 action.payload 字段。虽然颜色筛选行为本可拆分为"添加"和"移除"两个动作,但这里我们将其合并为一个动作,并通过额外字段区分操作类型——这展示了动作负载(payload)支持使用对象结构。
与状态数据类似,动作应包含描述事件所需的最少信息量。
编写 Reducer
既然我们已经了解了状态结构和 action 的形态,是时候编写第一个 reducer 了。
Reducers 是接收当前 state 和 action 作为参数,并返回新 state 结果的函数。简言之,即 (state, action) => newState。
创建根 Reducer
Redux 应用实际上只有一个 reducer 函数:即稍后要传递给 createStore 的“根 reducer”函数。这个根 reducer 负责处理所有已派发的 action,并在每次调用时计算整个应用的新状态结果。
让我们在 src 文件夹中创建 reducer.js 文件,与 index.js 和 App.js 并列。
每个 reducer 都需要初始状态,因此我们先添加一些模拟的待办事项作为起点。然后编写 reducer 函数的基本框架:
const initialState = {
todos: [
{ id: 0, text: 'Learn React', completed: true },
{ id: 1, text: 'Learn Redux', completed: false, color: 'purple' },
{ id: 2, text: 'Build something fun!', completed: false, color: 'blue' }
],
filters: {
status: 'All',
colors: []
}
}
// Use the initialState as a default value
export default function appReducer(state = initialState, action) {
// The reducer normally looks at the action type field to decide what happens
switch (action.type) {
// Do something here based on the different types of actions
default:
// If this reducer doesn't recognize the action type, or doesn't
// care about this specific action, return the existing state unchanged
return state
}
}
应用初始化时,reducer 可能被调用且 state 值为 undefined。此时需要提供初始状态值,以便后续逻辑正常运行。Reducer 通常使用默认参数语法提供初始状态:(state = initialState, action)。
接下来添加处理 'todos/todoAdded' action 的逻辑。
首先检查当前 action 的类型是否匹配特定字符串。然后返回包含全部状态的新对象,即使是未更改的字段。
function nextTodoId(todos) {
const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1)
return maxId + 1
}
// Use the initialState as a default value
export default function appReducer(state = initialState, action) {
// The reducer normally looks at the action type field to decide what happens
switch (action.type) {
// Do something here based on the different types of actions
case 'todos/todoAdded': {
// We need to return a new state object
return {
// that has all the existing state data
...state,
// but has a new array for the `todos` field
todos: [
// with all of the old todos
...state.todos,
// and the new todo object
{
// Use an auto-incrementing numeric ID for this example
id: nextTodoId(state.todos),
text: action.payload,
completed: false
}
]
}
}
default:
// If this reducer doesn't recognize the action type, or doesn't
// care about this specific action, return the existing state unchanged
return state
}
}
这...仅仅为了添加一个待办事项,工作量是否过大了?为何需要这些额外步骤?
Reducer 规则
我们曾强调 reducer 必须始终遵循特定规则:
-
它们应该只根据
state和action参数来计算新的状态值 -
不允许直接修改现有的
state。相反,必须通过复制现有state并对副本进行修改,实现 不可变更新。 -
不得包含任何异步逻辑或其他“副作用”
“副作用”是指函数返回值之外任何可被察觉的状态或行为变更。常见副作用包括:
- 在控制台打印日志
- 保存文件
- 设置异步定时器
- 发起 HTTP 请求
- 修改函数外部的状态或变更函数参数
- 生成随机数或唯一 ID(如
Math.random()或Date.now())
任何遵循这些规则的函数也被称为纯函数,即使并非专门作为 reducer 编写。
但这些规则为何重要?主要有以下原因:
这些规则为何重要?主要有以下原因:
-
另一方面,若函数依赖外部变量或行为随机,运行时结果将无法预测。
-
若函数修改了其他值(包括其参数),可能导致应用行为异常。这是常见的错误来源,例如“我更新了状态,但 UI 却没有按预期更新!”
-
Redux DevTools 的某些功能依赖于 reducer 正确遵循这些规则
其中“不可变更新”这条规则尤为重要,值得深入探讨。
Reducer 与不可变更新
此前我们讨论了“变更”(修改现有对象/数组值)与“不可变性”(将值视为不可更改)。
在 Redux 中,reducer 绝对不允许直接修改原始/当前状态值!
// ❌ Illegal - by default, this will mutate the state!
state.value = 123
在 Redux 中禁止直接修改状态的原因如下:
-
会导致错误,例如 UI 无法正确更新以显示最新值
-
使状态更新的原因和过程难以理解
-
增加测试编写的难度
-
破坏“时间旅行调试”功能的正确使用
-
违背 Redux 的设计理念和使用模式
既然无法更改原始数据,我们如何返回更新后的状态?
Reducer 只能创建原始值的副本,然后对副本进行变更。
// ✅ This is safe, because we made a copy
return {
...state,
value: 123
}
我们已经知道可以手动编写不可变更新,通过 JavaScript 的数组/对象展开运算符等返回原始值副本的方法实现。
当数据存在嵌套时,这会变得复杂。不可变更新的核心原则是:必须为需要更新的每一层嵌套创建副本。
如果你觉得“手动编写不可变更新逻辑既难记忆又易出错”... 是的,确实如此! :)
手动编写不可变更新确实困难,而在 reducer 中意外修改状态是 Redux 用户最常犯的错误。
在实际应用中,您无需手动编写这些复杂的嵌套不可变更新逻辑。在第 8 部分:使用 Redux Toolkit 的现代 Redux中,您将学习如何利用 Redux Toolkit 简化 reducer 中的不可变更新逻辑。
处理其他动作
基于此思路,我们继续为其他情况添加 reducer 逻辑。首先,根据待办事项 ID 切换其 completed 状态:
export default function appReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return {
...state,
todos: [
...state.todos,
{
id: nextTodoId(state.todos),
text: action.payload,
completed: false
}
]
}
}
case 'todos/todoToggled': {
return {
// Again copy the entire state object
...state,
// This time, we need to make a copy of the old todos array
todos: state.todos.map(todo => {
// If this isn't the todo item we're looking for, leave it alone
if (todo.id !== action.payload) {
return todo
}
// We've found the todo that has to change. Return a copy:
return {
...todo,
// Flip the completed flag
completed: !todo.completed
}
})
}
}
default:
return state
}
}
由于我们一直专注于 todos 状态,现在也添加处理“可见性筛选条件变更”动作的逻辑:
export default function appReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return {
...state,
todos: [
...state.todos,
{
id: nextTodoId(state.todos),
text: action.payload,
completed: false
}
]
}
}
case 'todos/todoToggled': {
return {
...state,
todos: state.todos.map(todo => {
if (todo.id !== action.payload) {
return todo
}
return {
...todo,
completed: !todo.completed
}
})
}
}
case 'filters/statusFilterChanged': {
return {
// Copy the whole state
...state,
// Overwrite the filters value
filters: {
// copy the other filter fields
...state.filters,
// And replace the status field with the new value
status: action.payload
}
}
}
default:
return state
}
}
虽然只处理了 3 个动作,但代码已略显冗长。若尝试在单个 reducer 函数中处理所有动作,代码可读性将大幅降低。
因此我们通常将 reducer 拆分为多个小型 reducer 函数——以提升逻辑的可理解性和可维护性。
拆分 Reducer
为此,Redux reducer 通常根据其更新的状态区域进行拆分。我们的待办应用状态目前有两个顶级区域:state.todos 和 state.filters。因此可将根 reducer 拆分为两个小型 reducer——todosReducer 和 filtersReducer。
那么这些拆分后的 reducer 函数应置于何处?
我们建议基于“功能特性”组织 Redux 应用文件夹和文件——即与特定功能概念相关的代码。某个功能特性的 Redux 代码通常集中在一个称为“切片(slice)”的文件中,其中包含该状态区域的所有 reducer 逻辑及相关动作代码。
因此,处理特定状态区域的 reducer 被称为“切片 reducer”。通常,部分动作对象会与特定切片 reducer 紧密关联,故动作类型字符串应以功能名称(如 'todos')开头,后接发生的事件(如 'todoAdded'),组合为单个字符串('todos/todoAdded')。
在项目中创建 features 文件夹,其内新建 todos 文件夹。创建 todosSlice.js 文件,将待办事项相关的初始状态迁移至此:
const initialState = [
{ id: 0, text: 'Learn React', completed: true },
{ id: 1, text: 'Learn Redux', completed: false, color: 'purple' },
{ id: 2, text: 'Build something fun!', completed: false, color: 'blue' }
]
function nextTodoId(todos) {
const maxId = todos.reduce((maxId, todo) => Math.max(todo.id, maxId), -1)
return maxId + 1
}
export default function todosReducer(state = initialState, action) {
switch (action.type) {
default:
return state
}
}
现在可迁移待办事项的更新逻辑。但需注意关键区别:此文件仅需处理待办事项相关状态——不再存在嵌套! 这也是拆分 reducer 的另一优势。由于 todos 状态本身是数组,此处无需复制外层根状态对象,显著提升可读性。
这称为 reducer 组合(reducer composition),是构建 Redux 应用的核心模式。
处理完这些动作后,更新后的 reducer 如下所示:
export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
// Can return just the new todos array - no extra object around it
return [
...state,
{
id: nextTodoId(state),
text: action.payload,
completed: false
}
]
}
case 'todos/todoToggled': {
return state.map(todo => {
if (todo.id !== action.payload) {
return todo
}
return {
...todo,
completed: !todo.completed
}
})
}
default:
return state
}
}
代码更简洁易读。
现在对可见性逻辑执行相同操作。创建 src/features/filters/filtersSlice.js,迁移所有筛选条件相关代码:
const initialState = {
status: 'All',
colors: []
}
export default function filtersReducer(state = initialState, action) {
switch (action.type) {
case 'filters/statusFilterChanged': {
return {
// Again, one less level of nesting to copy
...state,
status: action.payload
}
}
default:
return state
}
}
虽然仍需复制包含筛选状态的对象,但因嵌套减少,逻辑更清晰易读。
组合 Reducer
目前我们有两个独立的切片文件,每个文件包含自己的切片 reducer 函数。但之前提到过,创建 Redux store 时需要_一个_根 reducer 函数。那么,如何在不将所有代码塞进单个大函数的前提下重建根 reducer?
由于 reducer 本质上是普通 JS 函数,我们可以将切片 reducer 重新导入 reducer.js,并编写一个新的根 reducer —— 其唯一职责是调用另外两个函数。
import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'
export default function rootReducer(state = {}, action) {
// always return a new object for the root state
return {
// the value of `state.todos` is whatever the todos reducer returns
todos: todosReducer(state.todos, action),
// For both reducers, we only pass in their slice of the state
filters: filtersReducer(state.filters, action)
}
}
请注意:每个 reducer 仅管理全局状态中自己负责的部分。每个 reducer 的 state 参数各不相同,对应其管理的状态子集。
这种方式允许我们根据功能特征和状态切片拆分逻辑,保持代码可维护性。
combineReducers
可以看到新根 reducer 对每个切片执行相同操作:调用切片 reducer,传入该 reducer 负责的状态切片,并将结果赋回根状态对象。如果添加更多切片,该模式将重复执行。
Redux 核心库提供了 combineReducers 工具函数,可自动完成此样板代码操作。我们可以用 combineReducers 生成的精简版替换手动编写的 rootReducer。
现在需要使用 combineReducers,是时候正式安装 Redux 核心库了:
npm install redux
安装完成后,即可导入并使用 combineReducers:
import { combineReducers } from 'redux'
import todosReducer from './features/todos/todosSlice'
import filtersReducer from './features/filters/filtersSlice'
const rootReducer = combineReducers({
// Define a top-level state field named `todos`, handled by `todosReducer`
todos: todosReducer,
filters: filtersReducer
})
export default rootReducer
combineReducers 接收一个对象,其键名将成为根状态对象的键,而值则是知道如何更新 Redux 状态对应切片的 reducer 函数。
请记住:传递给 combineReducers 的键名决定了状态对象的键名!
学习要点
状态(State)、动作(Actions)和 Reducer 是 Redux 的三大基石。每个 Redux 应用都包含状态值,通过创建动作描述发生的事件,并使用 reducer 函数基于先前状态和动作计算新状态值。
以下是当前应用的内容概览:
- Redux 应用使用普通 JS 对象、数组和原始值作为状态值
- 根状态值应为普通 JS 对象
- 状态应包含应用运行所需的最小数据量
- 类、Promise、函数等非普通值_不应_放入 Redux 状态
- Reducer 禁止生成随机值(如
Math.random()或Date.now()) - 允许在 Redux 存储之外存在其他状态值(如组件本地状态)
- 动作(Actions)是描述事件的普通对象,必须包含
type字段type字段应为可读字符串,通常采用'feature/eventName'格式- 动作可包含其他值,通常存储在
action.payload字段 - 动作应携带描述事件所需的最小数据量
- Reducer 是形如
(state, action) => newState的函数- Reducer 必须始终遵循特定规则:
- 必须仅基于
state和action参数计算新状态 - 禁止直接修改现有
state—— 始终返回副本 - 禁止包含 HTTP 请求或异步逻辑等"副作用"
- 必须仅基于
- Reducer 必须始终遵循特定规则:
- 应拆分 reducer 以提升可读性
- 通常根据顶级状态键或状态"切片"进行拆分
- 通常写在"切片"文件中,按"功能"文件夹组织
- 可通过 Redux 的
combineReducers函数组合多个 reducer - 传递给
combineReducers的键名决定顶级状态对象的键名
下一步是什么?
现在我们已有更新状态的 reducer 逻辑,但这些 reducer 不会自动执行。它们需要被放入 Redux store 中,当事件发生时 store 才能调用 reducer 处理动作。
在第四部分:Store中,我们将学习如何创建 Redux store 并运行 reducer 逻辑。