Skip to main content

Redux 基础知识,第 3 部分:状态、操作和 reducer

你将学到什么
  • 如何定义包含应用数据的状态值

    ¥How to define state values that contain your app's data

  • 如何定义描述应用中发生的情况的操作对象

    ¥How to define action objects that describe what happens in your app

  • 如何编写根据现有状态和操作计算更新状态的 reducer 函数

    ¥How to write reducer functions that calculate updated state based on existing state and actions

先决条件

介绍

¥Introduction

第 2 部分:Redux 概念和数据流 中,我们研究了 Redux 如何通过为我们提供一个放置全局应用状态的单一中心位置来帮助我们构建可维护的应用。我们还讨论了核心 Redux 概念,例如分派操作对象和使用返回新状态值的 reducer 函数。

¥In Part 2: Redux Concepts and Data Flow, we looked at how Redux can help us build maintainable apps by giving us a single central place to put global app state. We also talked about core Redux concepts like dispatching action objects and using reducer functions that return new state values.

现在你已经了解了这些内容是什么,是时候将这些知识付诸实践了。我们将构建一个小型示例应用来了解这些部分如何实际协同工作。

¥Now that you have some idea of what these pieces are, it's time to put that knowledge into practice. We're going to build a small example app to see how these pieces actually work together.

提醒

请注意,本教程有意展示旧式 Redux 逻辑模式,这些模式比我们使用 Redux Toolkit 教授的 "现代回归" 模式需要更多的代码,作为当今使用 Redux 构建应用的正确方法,以便解释 Redux 背后的原理和概念。它并不意味着是一个生产就绪的项目。

¥Note that this tutorial intentionally shows older-style Redux logic patterns that require more code than the "modern Redux" patterns with Redux Toolkit we teach as the right approach for building apps with Redux today, in order to explain the principles and concepts behind Redux. It's not meant to be a production-ready project.

请参阅以下页面以了解如何将 "现代回归" 与 Redux Toolkit 结合使用:

¥See these pages to learn how to use "modern Redux" with Redux Toolkit:

项目设置

¥Project Setup

在本教程中,我们创建了一个预配置的入门项目,该项目已经设置了 React,包括一些默认样式,并且有一个假 REST API,允许我们在应用中编写实际的 API 请求。你将使用它作为编写实际应用代码的基础。

¥For this tutorial, we've created a pre-configured starter project that already has React set up, includes some default styling, and has a fake REST API that will allow us to write actual API requests in our app. You'll use this as the basis for writing the actual application code.

首先,你可以打开并分叉此 CodeSandbox:

¥To get started, you can open and fork this CodeSandbox:

你也可以 从这个 Github 存储库克隆相同的项目。克隆存储库后,你可以使用 npm install 安装该项目的工具,并使用 npm start 启动它。

¥You can also clone the same project from this Github repo. After cloning the repo, you can install the tools for the project with npm install, and start it with npm start.

如果你想查看我们将要构建的最终版本,你可以查看 tutorial-steps 分行看看这个 CodeSandbox 中的最终版本

¥If you'd like to see the final version of what we're going to build, you can check out the tutorial-steps branch, or look at the final version in this CodeSandbox.

创建一个新的 Redux + React 项目

¥Creating a New Redux + React Project

完成本教程后,你可能会想要尝试处理自己的项目。我们建议使用 Create-React-App 的 Redux 模板 作为创建新 Redux + React 项目的最快方式。它附带了已配置的 Redux Toolkit 和 React-Redux,使用 你在第 1 部分中看到的 "counter" 应用示例的现代化版本。这使你可以直接编写实际的应用代码,而无需添加 Redux 包并设置存储。

¥Once you've finished this tutorial, you'll probably want to try working on your own projects. We recommend using the Redux templates for Create-React-App as the fastest way to create a new Redux + React project. It comes with Redux Toolkit and React-Redux already configured, using a modernized version of the "counter" app example you saw in Part 1. This lets you jump right into writing your actual application code without having to add the Redux packages and set up the store.

如果你想了解如何将 Redux 添加到项目的具体细节,请参阅以下说明:

¥If you want to know specific details on how to add Redux to a project, see this explanation:

Detailed Explanation: Adding Redux to a React Project

CRA 的 Redux 模板附带了已配置的 Redux Toolkit 和 React-Redux。如果你要在没有该模板的情况下从头开始设置新项目,请按照以下步骤操作:

¥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:

  • 添加 @reduxjs/toolkitreact-redux

    ¥Add the @reduxjs/toolkit and react-redux packages

  • 使用 RTK 的 configureStore API 创建 Redux store,并传入至少一个 reducer 函数

    ¥Create a Redux store using RTK's configureStore API, and pass in at least one reducer function

  • 将 Redux 存储导入到应用的入口点文件中(例如 src/index.js

    ¥Import the Redux store into your application's entry point file (such as src/index.js)

  • 使用 React-Redux 中的 <Provider> 组件封装你的根 React 组件,例如:

    ¥Wrap your root React component with the <Provider> component from React-Redux, like:

ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
)

探索初始项目

¥Exploring the Initial Project

该初始项目基于 标准的 Create-React-App 项目模板,并进行了一些修改。

¥This initial project is based on the standard Create-React-App project template, with some modifications.

让我们快速浏览一下初始项目包含的内容:

¥Let's take a quick look at what the initial project contains:

  • /src

    • index.js:应用的入口点文件。它渲染主要的 <App> 组件。

      ¥index.js: the entry point file for the application. It renders the main <App> component.

    • App.js:主要应用组件。

      ¥App.js: the main application component.

    • index.css:完整应用的样式

      ¥index.css: styles for the complete application

    • /api

      • client.js:一个小型 AJAX 请求客户端,允许我们发出 GET 和 POST 请求

        ¥client.js: a small AJAX request client that allows us to make GET and POST requests

      • server.js:为我们的数据提供一个虚假的 REST API。我们的应用稍后将从这些虚假端点获取数据。

        ¥server.js: provides a fake REST API for our data. Our app will fetch data from these fake endpoints later.

    • /exampleAddons:包含一些额外的 Redux 插件,我们将在本教程后面使用它们来展示事情是如何工作的

      ¥/exampleAddons: contains some additional Redux addons that we'll use later in the tutorial to show how things work

如果你现在加载应用,你应该会看到一条欢迎消息,但应用的其余部分是空的。

¥If you load the app now, you should see a welcome message, but the rest of the app is otherwise empty.

就这样,我们开始吧!

¥With that, let's get started!

启动 Todo 示例应用

¥Starting the Todo Example App

我们的示例应用将是一个小型 "todo" 应用。你以前可能看过待办事项应用示例 - 它们是很好的例子,因为它们让我们展示了如何执行诸如跟踪项目列表、处理用户输入以及在数据更改时更新 UI 等操作,这些都是在正常应用中发生的事情。

¥Our example application will be a small "todo" application. You've probably seen todo app examples before - they make good examples because they let us show how to do things like tracking a list of items, handling user input, and updating the UI when that data changes, which are all things that happen in a normal application.

定义要求

¥Defining Requirements

让我们首先确定此应用的初始业务需求:

¥Let's start by figuring out the initial business requirements for this application:

  • 用户界面应包含三个主要部分:

    ¥The UI should consist of three main sections:

    • 让用户输入新待办事项文本的输入框

      ¥An input box to let the user type in the text of a new todo item

    • 所有现有待办事项的列表

      ¥A list of all the existing todo items

    • 页脚部分显示未完成的待办事项数量,并显示过滤选项

      ¥A footer section that shows the number of non-completed todos, and shows filtering options

  • 待办事项列表项应该有一个复选框来切换其 "completed" 状态。我们还应该能够为预定义的颜色列表添加颜色编码的类别标签,并删除待办事项。

    ¥Todo list items should have a checkbox that toggles their "completed" status. We should also be able to add a color-coded category tag for a predefined list of colors, and delete todo items.

  • 计数器应将活动待办事项的数量设为复数:"0 项目"、"1 件"、"3 项目" 等

    ¥The counter should pluralize the number of active todos: "0 items", "1 item", "3 items", etc

  • 应该有按钮将所有待办事项标记为已完成,并通过删除它们来清除所有已完成的待办事项

    ¥There should be buttons to mark all todos as completed, and to clear all completed todos by removing them

  • 应该有两种方法来过滤列表中显示的待办事项:

    ¥There should be two ways to filter the displayed todos in the list:

    • 根据显示 "全部"、"积极的" 和 "完全的" 待办事项进行过滤

      ¥Filtering based on showing "All", "Active", and "Completed" todos

    • 基于选择一种或多种颜色进行过滤,并显示其标签与这些颜色匹配的任何待办事项

      ¥Filtering based on selecting one or more colors, and showing any todos whose tag that match those colors

稍后我们将添加更多要求,但这足以让我们开始。

¥We'll add some more requirements later on, but this is enough to get us started.

最终目标是一个应如下所示的应用:

¥The end goal is an app that should look like this:

Example todo app screenshot

设计状态值

¥Designing the State Values

React 和 Redux 的核心原则之一是你的 UI 应该基于你的状态。因此,设计应用的一种方法是首先考虑描述应用如何工作所需的所有状态。尝试使用尽可能少的状态值来描述 UI 也是一个好主意,这样你需要跟踪和更新的数据就会减少。

¥One of the core principles of React and Redux is that your UI should be based on your state. So, one approach to designing an application is to first think of all the state needed to describe how the application works. It's also a good idea to try to describe your UI with as few values in the state as possible, so there's less data you need to keep track of and update.

从概念上讲,该应用有两个主要方面:

¥Conceptually, there are two main aspects of this application:

  • 当前待办事项的实际列表

    ¥The actual list of current todo items

  • 当前的过滤选项

    ¥The current filtering options

我们还需要跟踪用户在 "添加待办事项" 输入框中输入的数据,但这并不重要,我们稍后会处理它。

¥We'll also need to keep track of the data the user is typing into the "Add Todo" input box, but that's less important and we'll handle that later.

对于每个待办事项,我们需要存储几条信息:

¥For each todo item, we need to store a few pieces of information:

  • 用户输入的文本

    ¥The text the user entered

  • 布尔标志表示是否完成

    ¥The boolean flag saying if it's completed or not

  • 唯一的 ID 值

    ¥A unique ID value

  • 颜色类别(如果选择)

    ¥A color category, if selected

我们的过滤行为可能可以用一些枚举值来描述:

¥Our filtering behavior can probably be described with some enumerated values:

  • 完成状态:"全部"、"积极的" 和 "完全的"

    ¥Completed status: "All", "Active", and "Completed"

  • 颜色:"红色的"、"黄色的"、"绿色的"、"蓝色的"、"橙子"、"紫色的"

    ¥Colors: "Red", "Yellow", "Green", "Blue", "Orange", "Purple"

查看这些值,我们还可以说待办事项是 "应用状态"(应用使用的核心数据),而过滤值是 "用户界面状态"(描述应用当前正在执行的操作的状态)。思考这些不同类型的类别有助于理解不同状态的使用方式。

¥Looking at these values, we can also say that the todos are "app state" (the core data that the application works with), while the filtering values are "UI state" (state that describes what the app is doing right now). It can be helpful to think about these different kinds of categories to help understand how the different pieces of state are being used.

设计状态结构

¥Designing the State Structure

使用 Redux,我们的应用状态始终保存在纯 JavaScript 对象和数组中。这意味着你不能将其他东西放入 Redux 状态 - 没有类实例、内置 JS 类型(例如 Map / Set / Promise / Date)、函数或任何其他非纯 JS 数据的内容。

¥With Redux, our application state is always kept in plain JavaScript objects and arrays. That means you may not put other things into the Redux state - no class instances, built-in JS types like Map / Set / Promise / Date, functions, or anything else that is not plain JS data.

根 Redux 状态值几乎总是一个普通的 JS 对象,其中嵌套有其他数据。

¥The root Redux state value is almost always a plain JS object, with other data nested inside of it.

基于这些信息,我们现在应该能够描述 Redux 状态中需要的值类型:

¥Based on this information, we should now be able to describe the kinds of values we need to have inside our Redux state:

  • 首先,我们需要一个待办事项对象数组。每个项目都应具有以下字段:

    ¥First, we need an array of todo item objects. Each item should have these fields:

    • id:唯一的号码

      ¥id: a unique number

    • text:用户输入的文本

      ¥text: the text the user typed in

    • completed:布尔标志

      ¥completed: a boolean flag

    • color:可选颜色类别

      ¥color: An optional color category

  • 然后,我们需要描述我们的过滤选项。我们需要:

    ¥Then, we need to describe our filtering options. We need to have:

    • 当前 "completed" 过滤器值

      ¥The current "completed" filter value

    • 当前选定的颜色类别的数组

      ¥An array of the currently selected color categories

因此,我们的应用状态示例如下:

¥So, here's what an example of our app's state might look like:

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 中(例如 "这个下拉菜单打开了吗?" 或 "表单输入的当前值")。

¥It's important to note that it's okay to have other state values outside of Redux! This example is small enough so far that we actually do have all our state in the Redux store, but as we'll see later, some data really doesn't need to be kept in Redux (like "is this dropdown open?" or "current value of a form input").

设计动作

¥Designing Actions

操作是具有 type 字段的纯 JavaScript 对象。如前所述,你可以将操作视为描述应用中发生的事件的事件。

¥Actions are plain JavaScript objects that have a type field. As mentioned earlier, you can think of an action as an event that describes something that happened in the application.

就像我们根据应用的要求设计状态结构一样,我们也应该能够列出一些描述正在发生的事情的操作:

¥In the same way that we designed the state structure based on the app's requirements, we should also be able to come up with a list of some of the actions that describe what's happening:

  • 根据用户输入的文本添加新的待办事项条目

    ¥Add a new todo entry based on the text the user entered

  • 切换待办事项的完成状态

    ¥Toggle the completed status of a todo

  • 选择待办事项的颜色类别

    ¥Select a color category for a todo

  • 删除待办事项

    ¥Delete a todo

  • 将所有待办事项标记为已完成

    ¥Mark all todos as completed

  • 清除所有已完成的待办事项

    ¥Clear all completed todos

  • 选择不同的 "completed" 过滤器值

    ¥Choose a different "completed" filter value

  • 添加新的滤色器

    ¥Add a new color filter

  • 取下彩色滤光片

    ¥Remove a color filter

我们通常将描述正在发生的情况所需的任何额外数据放入 action.payload 字段中。这可以是数字、字符串或内部包含多个字段的对象。

¥We normally put any extra data needed to describe what's happening into the action.payload field. This could be a number, a string, or an object with multiple fields inside.

Redux 存储并不关心 action.type 字段的实际文本是什么。但是,你自己的代码将查看 action.type 以查看是否需要更新。此外,在调试时你会经常查看 Redux DevTools Extension 中的操作类型字符串,以了解应用中发生的情况。因此,尝试选择可读且清楚描述正在发生的事情的操作类型 - 以后再看的时候会更容易理解!

¥The Redux store doesn't care what the actual text of the action.type field is. However, your own code will look at action.type to see if an update is needed. Also, you will frequently look at action type strings in the Redux DevTools Extension while debugging to see what's going on in your app. So, try to choose action types that are readable and clearly describe what's happening - it'll be much easier to understand things when you look at them later!

根据可能发生的事情列表,我们可以创建应用将使用的操作列表:

¥Based on that list of things that can happen, we can create a list of actions that our application will use:

  • {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 字段中。我们可以将颜色滤镜行为分为两个操作,一个用于 "added",一个用于 "removed",但在这种情况下,我们将其作为一个操作来执行,其中有一个额外的字段,专门用于表明我们可以将对象作为操作负载。

¥In this case, the actions primarily have a single extra piece of data, so we can put that directly in the action.payload field. We could have split the color filter behavior into two actions, one for "added" and one for "removed", but in this case we'll do it as one action with an extra field inside specifically to show that we can have objects as an action payload.

与状态数据一样,操作应该包含描述所发生事件所需的最少量信息。

¥Like the state data, actions should contain the smallest amount of information needed to describe what happened.

编写 reducer

¥Writing Reducers

现在我们知道我们的状态结构和我们的动作是什么样的,是时候编写我们的第一个 reducer 了。

¥Now that we know what our state structure and our actions look like, it's time to write our first reducer.

reducer 是以当前 stateaction 作为参数,并返回新的 state 结果的函数。换句话说,(state, action) => newState

¥Reducers are functions that take the current state and an action as arguments, and return a new state result. In other words, (state, action) => newState.

创建根 reducer

¥Creating the Root Reducer

一个 Redux 应用实际上只有一个 reducer 函数:稍后你将传递给 createStore 的 "根 reducer" 函数。该根 reducer 函数负责处理所有已分派的操作,并计算每次的整个新状态结果应该是什么。

¥A Redux app really only has one reducer function: the "root reducer" function that you will pass to createStore later on. That one root reducer function is responsible for handling all of the actions that are dispatched, and calculating what the entire new state result should be every time.

首先,我们在 src 文件夹中创建一个 reducer.js 文件,以及 index.jsApp.js

¥Let's start by creating a reducer.js file in the src folder, alongside index.js and App.js.

每个 reducer 都需要一些初始状态,因此我们将添加一些假的待办事项条目来帮助我们开始。然后,我们可以写出 reducer 函数内部的逻辑概要:

¥Every reducer needs some initial state, so we'll add some fake todo entries to get us started. Then, we can write an outline for the logic inside the reducer function:

src/reducer.js
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
}
}

当应用初始化时,可以使用 undefined 作为状态值来调用 reducer。如果发生这种情况,我们需要提供一个初始状态值,以便其余的 reducer 代码可以使用。reducer 通常使用默认参数语法来提供初始状态:(state = initialState, action)

¥A reducer may be called with undefined as the state value when the application is being initialized. If that happens, we need to provide an initial state value so the rest of the reducer code has something to work with. Reducers normally use default argument syntax to provide initial state: (state = initialState, action).

接下来,让我们添加处理 'todos/todoAdded' 操作的逻辑。

¥Next, let's add the logic to handle the 'todos/todoAdded' action.

我们首先需要检查当前操作的类型是否与该特定字符串匹配。然后,我们需要返回一个包含所有状态的新对象,即使是未更改的字段。

¥We first need to check if the current action's type matches that specific string. Then, we need to return a new object containing all of the state, even for the fields that didn't change.

src/reducer.js
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
}
}

那是...向状态添加一个待办事项需要花费大量的工作。为什么需要所有这些额外的工作?

¥That's... an awful lot of work to add one todo item to the state. Why is all this extra work necessary?

reducer 规则

¥Rules of Reducers

我们之前说过,reducer 必须始终遵循一些特殊规则:

¥We said earlier that reducers must always follow some special rules:

  • 他们应该只根据 stateaction 参数计算新的状态值

    ¥They should only calculate the new state value based on the state and action arguments

  • 他们不得修改现有的 state。相反,他们必须通过复制现有的 state 并对复制的值进行更改来进行不可变的更新。

    ¥They are not allowed to modify the existing state. Instead, they must make immutable updates, by copying the existing state and making changes to the copied values.

  • 他们不得执行任何异步逻辑或其他 "副作用"

    ¥They must not do any asynchronous logic or other "side effects"

提示

"副作用" 是除了从函数返回值之外可以看到的状态或行为的任何更改。一些常见的副作用如下:

¥A "side effect" is any change to state or behavior that can be seen outside of returning a value from a function. Some common kinds of side effects are things like:

  • 将值记录到控制台

    ¥Logging a value to the console

  • 保存文件

    ¥Saving a file

  • 设置异步计时器

    ¥Setting an async timer

  • 发出 AJAX HTTP 请求

    ¥Making an AJAX HTTP request

  • 修改函数外部存在的某些状态,或改变函数的参数

    ¥Modifying some state that exists outside of a function, or mutating arguments to a function

  • 生成随机数或唯一的随机 ID(例如 Math.random()Date.now()

    ¥Generating random numbers or unique random IDs (such as Math.random() or Date.now())

任何遵循这些规则的函数也称为 "pure" 函数,即使它没有专门编写为 reducer 函数。

¥Any function that follows these rules is also known as a "pure" function, even if it's not specifically written as a reducer function.

但为什么这些规则很重要?有几个不同的原因:

¥But why are these rules important? There's a few different reasons:

  • Redux 的目标之一是使你的代码可预测。当函数的输出仅根据输入参数计算时,更容易理解代码的工作原理并对其进行测试。

    ¥One of the goals of Redux is to make your code predictable. When a function's output is only calculated from the input arguments, it's easier to understand how that code works, and to test it.

  • 另一方面,如果一个函数依赖于其自身外部的变量,或者行为随机,那么你永远不知道运行它时会发生什么。

    ¥On the other hand, if a function depends on variables outside itself, or behaves randomly, you never know what will happen when you run it.

  • 如果函数修改其他值(包括其参数),则可能会意外更改应用的工作方式。这可能是错误的常见来源,例如 "我更新了我的状态,但现在我的 UI 没有在应该更新的时候更新!"

    ¥If a function modifies other values, including its arguments, that can change the way the application works unexpectedly. This can be a common source of bugs, such as "I updated my state, but now my UI isn't updating when it should!"

  • Redux DevTools 的一些功能取决于你的 reducer 是否正确遵循这些规则

    ¥Some of the Redux DevTools capabilities depend on having your reducers follow these rules correctly

关于 "不可变的更新" 的规则尤其重要,值得进一步讨论。

¥The rule about "immutable updates" is particularly important, and worth talking about further.

reducer 和不可变更新

¥Reducers and Immutable Updates

之前,我们讨论了 "mutation"(修改现有对象/数组值)和 "immutability"(将值视为无法更改的内容)。

¥Earlier, we talked about "mutation" (modifying existing object/array values) and "immutability" (treating values as something that cannot be changed).

警告

在 Redux 中,我们的 reducer 永远不允许改变原始/当前状态值!

¥In Redux, our reducers are never allowed to mutate the original / current state values!

// ❌ Illegal - by default, this will mutate the state!
state.value = 123

不得在 Redux 中改变状态有几个原因:

¥There are several reasons why you must not mutate state in Redux:

  • 它会导致错误,例如 UI 无法正确更新以显示最新值

    ¥It causes bugs, such as the UI not updating properly to show the latest values

  • 这使得理解状态更新的原因和方式变得更加困难

    ¥It makes it harder to understand why and how the state has been updated

  • 这使得编写测试变得更加困难

    ¥It makes it harder to write tests

  • 它破坏了正确使用 "时间旅行调试" 的能力

    ¥It breaks the ability to use "time-travel debugging" correctly

  • 它违背了 Redux 的预期精神和使用模式

    ¥It goes against the intended spirit and usage patterns for Redux

那么如果我们不能改变原始状态,我们如何返回更新后的状态呢?

¥So if we can't change the originals, how do we return an updated state?

提示

reducer 只能复制原始值,然后可以对副本进行修改。

¥Reducers can only make copies of the original values, and then they can mutate the copies.

// ✅ This is safe, because we made a copy
return {
...state,
value: 123
}

我们已经看到,通过使用 JavaScript 的数组/对象扩展运算符和其他返回原始值副本的函数,我们可以 手动编写不可变的更新

¥We already saw that we can write immutable updates by hand, by using JavaScript's array / object spread operators and other functions that return copies of the original values.

当数据嵌套时,这变得更加困难。不可变更新的一个关键规则是,你必须为需要更新的每个嵌套级别创建一个副本。

¥This becomes harder when the data is nested. A critical rule of immutable updates is that you must make a copy of every level of nesting that needs to be updated.

但是,如果你认为 "以这种方式手动编写不可变的更新看起来很难记住并且很难正确执行"...是啊,你说得对!:)

¥However, if you're thinking that "writing immutable updates by hand this way looks hard to remember and do correctly"... yeah, you're right! :)

手动编写不可变的更新逻辑很困难,并且意外改变 reducer 中的状态是 Redux 用户最常犯的错误。

¥Writing immutable update logic by hand is hard, and accidentally mutating state in reducers is the single most common mistake Redux users make.

提示

在现实应用中,你不必手动编写这些复杂的嵌套不可变更新。在 第 8 部分:使用 Redux 工具包的现代 Redux 中,你将学习如何使用 Redux Toolkit 来简化在 reducers 中编写不可变更新逻辑。

¥In real-world applications, you won't have to write these complex nested immutable updates by hand. In Part 8: Modern Redux with Redux Toolkit, you'll learn how to use Redux Toolkit to simplify writing immutable update logic in reducers.

处理附加操作

¥Handling Additional Actions

考虑到这一点,让我们为更多的情况添加 reducer 逻辑。首先,根据待办事项的 ID 切换其 completed 字段:

¥With that in mind, let's add the reducer logic for a couple more cases. First, toggling a todo's completed field based on its ID:

src/reducer.js
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 状态,因此我们也添加一个案例来处理 "可见性选择已更改" 操作:

¥And since we've been focusing on the todos state, let's add a case to handle the "visibility selection changed" action as well:

src/reducer.js
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 函数中的每一个操作,那么将很难阅读全部内容。

¥We've only handled 3 actions, but this is already getting a bit long. If we try to handle every action in this one reducer function, it's going to be hard to read it all.

这就是为什么 reducer 通常被分成多个较小的 reducer 函数的原因 - 使 reducer 逻辑更容易理解和维护。

¥That's why reducers are typically split into multiple smaller reducer functions - to make it easier to understand and maintain the reducer logic.

拆分 Reducer

¥Splitting Reducers

作为其中的一部分,Redux reducer 通常根据它们更新的 Redux 状态部分进行拆分。我们的待办事项应用状态当前有两个顶层部分:state.todosstate.filters。因此,我们可以将大根 reducer 函数拆分为两个较小的 reducer - 一个 todosReducer 和一个 filtersReducer

¥As part of this, Redux reducers are typically split apart based on the section of the Redux state that they update. Our todo app state currently has two top-level sections: state.todos and state.filters. So, we can split the large root reducer function into two smaller reducers - a todosReducer and a filtersReducer.

那么,这些拆分的 reducer 函数应该放在哪里呢?

¥So, where should these split-up reducer functions live?

我们建议根据 "features" 组织你的 Redux 应用文件夹和文件 - 与应用的特定概念或字段相关的代码。特定功能的 Redux 代码通常编写为单个文件,称为 "slice" 文件,其中包含应用状态该部分的所有 reducer 逻辑和所有与操作相关的代码。

¥We recommend organizing your Redux app folders and files based on "features" - code that relates to a specific concept or area of your application. The Redux code for a particular feature is usually written as a single file, known as a "slice" file, which contains all the reducer logic and all of the action-related code for that part of your app state.

因此,Redux 应用状态特定部分的 reducer 称为 "切片 reducer"。通常,某些操作对象将与特定切片缩减器密切相关,因此操作类型字符串应以该功能的名称(如 'todos')开头并描述发生的事件(如 'todoAdded'),并连接在一起形成一个字符串('todos/todoAdded')。

¥Because of that, the reducer for a specific section of the Redux app state is called a "slice reducer". Typically, some of the action objects will be closely related to a specific slice reducer, and so the action type strings should start with the name of that feature (like 'todos') and describe the event that happened (like 'todoAdded'), joined together into one string ('todos/todoAdded').

在我们的项目中,创建一个新的 features 文件夹,然后在其中创建一个 todos 文件夹。创建一个名为 todosSlice.js 的新文件,然后将与待办事项相关的初始状态剪切并粘贴到该文件中:

¥In our project, create a new features folder, and then a todos folder inside that. Create a new file named todosSlice.js, and let's cut and paste the todo-related initial state over into this file:

src/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
}
}

现在我们可以复制更新待办事项的逻辑。然而,这里有一个重要的区别。该文件只需要更新 todos 相关的状态 - 它不再嵌套了!这是我们拆分 reducer 的另一个原因。由于 todos 状态本身就是一个数组,因此我们不必在这里复制外部根状态对象。这使得这个 reducer 更容易阅读。

¥Now we can copy over the logic for updating the todos. However, there's an important difference here. This file only has to update the todos-related state - it's not nested any more! This is another reason why we split up reducers. Since the todos state is an array by itself, we don't have to copy the outer root state object in here. That makes this reducer easier to read.

这称为 reducer 组合,它是构建 Redux 应用的基本模式。

¥This is called reducer composition, and it's the fundamental pattern of building Redux apps.

这是我们处理这些操作后更新后的 reducer 的样子:

¥Here's what the updated reducer looks like after we handle those actions:

src/features/todos/todosSlice.js
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
}
}

这有点短并且更容易阅读。

¥That's a bit shorter and easier to read.

现在我们可以对可见性逻辑做同样的事情。创建 src/features/filters/filtersSlice.js,然后将所有与过滤器相关的代码移到那里:

¥Now we can do the same thing for the visibility logic. Create src/features/filters/filtersSlice.js, and let's move all the filter-related code over there:

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

我们仍然必须复制包含过滤器状态的对象,但由于嵌套较少,因此更容易读取正在发生的情况。

¥We still have to copy the object containing the filters state, but since there's less nesting, it's easier to read what's happening.

信息

为了使本页更简短,我们将跳过展示如何为其他操作编写 reducer 更新逻辑。

¥To keep this page shorter, we'll skip showing how to write the reducer update logic for the other actions.

尝试根据 上述要求 自己编写更新。

¥Try writing the updates for those yourself, based on the requirements described above.

如果你遇到困难,请参阅 本页末尾的 CodeSandbox 了解这些 reducer 的完整实现。

¥If you get stuck, see the CodeSandbox at the end of this page for the complete implementation of these reducers.

组合 Reducer

¥Combining Reducers

我们现在有两个独立的切片文件,每个文件都有自己的切片缩减器函数。但是,我们之前说过,Redux 存储在创建时需要一个根 reducer 函数。那么,我们如何才能回到拥有根 reducer 而不将所有代码放入一个大函数中呢?

¥We now have two separate slice files, each with its own slice reducer function. But, we said earlier that the Redux store needs one root reducer function when we create it. So, how can we go back to having a root reducer without putting all the code in one big function?

由于 reducer 是普通的 JS 函数,我们可以将切片 reducer 导入回 reducer.js,并编写一个新的根 reducer,其唯一的工作就是调用其他两个函数。

¥Since reducers are normal JS functions, we can import the slice reducers back into reducer.js, and write a new root reducer whose only job is to call the other two functions.

src/reducer.js
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 的状态参数都是不同的,并且对应于它管理的状态部分。

¥Note that each of these reducers is managing its own part of the global state. The state parameter is different for every reducer, and corresponds to the part of the state it manages.

这使我们能够根据功能和状态切片分割逻辑,以保持事物的可维护性。

¥This allows us to split up our logic based on features and slices of state, to keep things maintainable.

combineReducers

我们可以看到新的根 reducer 对每个切片执行相同的操作:调用切片 reducer,传入该 reducer 拥有的状态切片,并将结果分配回根状态对象。如果我们添加更多切片,该模式就会重复。

¥We can see that the new root reducer is doing the same thing for each slice: calling the slice reducer, passing in the slice of the state owned by that reducer, and assigning the result back to the root state object. If we were to add more slices, the pattern would repeat.

Redux 核心库包含一个名为 combineReducers 的实用程序,它为我们执行相同的样板步骤。我们可以将手写的 rootReducer 替换为 combineReducers 生成的较短的 rootReducer

¥The Redux core library includes a utility called combineReducers, which does this same boilerplate step for us. We can replace our hand-written rootReducer with a shorter one generated by combineReducers.

现在我们需要 combineReducers,是时候实际安装 Redux 核心库了:

¥Now that we need combineReducers, it's time to actually install the Redux core library:

npm install redux

完成后,我们可以导入 combineReducers 并使用它:

¥Once that's done, we can import combineReducers and use it:

src/reducer.js
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 状态的这些切片。

¥combineReducers accepts an object where the key names will become the keys in your root state object, and the values are the slice reducer functions that know how to update those slices of the Redux state.

请记住,你赋予 combineReducers 的键名称决定了状态对象的键名称!

¥Remember, the key names you give to combineReducers decides what the key names of your state object will be!

你学到了什么

¥What You've Learned

状态、操作和 reducer 是 Redux 的构建块。每个 Redux 应用都有状态值,创建操作来描述发生的情况,并使用 reducer 函数根据先前的状态和操作计算新的状态值。

¥State, Actions, and Reducers are the building blocks of Redux. Every Redux app has state values, creates actions to describe what happened, and uses reducer functions to calculate new state values based on the previous state and an action.

到目前为止,我们的应用的内容如下:

¥Here's the contents of our app so far:

概括
  • Redux 应用使用普通 JS 对象、数组和基元作为状态值

    ¥Redux apps use plain JS objects, arrays, and primitives as the state values

    • 根状态值应该是一个普通的 JS 对象

      ¥The root state value should be a plain JS object

    • 状态应包含应用运行所需的最少数据量

      ¥The state should contain the smallest amount of data needed to make the app work

    • 类、Promise、函数和其他非普通值不应进入 Redux 状态

      ¥Classes, Promises, functions, and other non-plain values should not go in the Redux state

    • reducer 不得创建像 Math.random()Date.now() 这样的随机值

      ¥Reducers must not create random values like Math.random() or Date.now()

    • 可以将 Redux 存储中没有的其他状态值(例如本地组件状态)与 Redux 并排放置

      ¥It's okay to have other state values that are not in the Redux store (like local component state) side-by side with Redux

  • 操作是带有 type 字段的普通对象,用于描述发生的情况

    ¥Actions are plain objects with a type field that describe what happened

    • type 字段应该是可读的字符串,通常写为 'feature/eventName'

      ¥The type field should be a readable string, and is usually written as 'feature/eventName'

    • 操作可能包含其他值,这些值通常存储在 action.payload 字段中

      ¥Actions may contain other values, which are typically stored in the action.payload field

    • 操作应该具有描述所发生事件所需的最少数据量

      ¥Actions should have the smallest amount of data needed to describe what happened

  • reducer 是类似于 (state, action) => newState 的函数

    ¥Reducers are functions that look like (state, action) => newState

    • reducer 必须始终遵循特殊规则:

      ¥Reducers must always follow special rules:

      • 仅根据 stateaction 参数计算新状态

        ¥Only calculate the new state based on the state and action arguments

      • 永远不要改变现有的 state - 总是返回一份副本

        ¥Never mutate the existing state - always return a copy

      • 没有像 AJAX 调用或异步逻辑这样的 "副作用"

        ¥No "side effects" like AJAX calls or async logic

  • reducer 应该分开以便于阅读

    ¥Reducers should be split up to make them easier to read

    • reducer 通常根据顶层状态键或状态的 "切片" 进行拆分

      ¥Reducers are usually split based on top-level state keys or "slices" of state

    • reducer 通常以 "slice" 文件编写,组织到 "feature" 文件夹中

      ¥Reducers are usually written in "slice" files, organized into "feature" folders

    • reducer 可以与 Redux combineReducers 功能结合在一起

      ¥Reducers can be combined together with the Redux combineReducers function

    • combineReducers 的键名定义了顶层状态对象键

      ¥The key names given to combineReducers define the top-level state object keys

下一步是什么?

¥What's Next?

我们现在有一些 reducer 逻辑可以更新我们的状态,但这些 reducer 本身不会做任何事情。它们需要放入 Redux 存储中,当发生某些情况时,它可以通过操作调用 reducer 代码。

¥We now have some reducer logic that will update our state, but those reducers won't do anything by themselves. They need to be put inside a Redux store, which can call the reducer code with actions when something has happened.

第 4 部分:存储 中,我们将了解如何创建 Redux 存储并运行我们的 reducer 逻辑。

¥In Part 4: Store, we'll see how to create a Redux store and run our reducer logic.