跳至主要内容

Redux 基础知识,第 7 部分:标准 Redux 模式

Redux 基础知识,第 7 部分:标准 Redux 模式

您将学到什么
  • 在真实世界 Redux 应用中使用的标准模式,以及这些模式存在的原因
    • 用于封装动作对象的动作创建者
    • 用于提高性能的记忆化选择器
    • 通过加载枚举跟踪请求状态
    • 规范化状态以管理项目集合
    • 使用 Promise 和 thunk
先决条件
  • 了解所有先前部分中的主题

第 6 部分:异步逻辑和数据获取 中,我们看到了如何使用 Redux 中间件编写可以与存储交互的异步逻辑。特别是,我们使用了 Redux 的“thunk”中间件来编写可以包含可重用异步逻辑的函数,而无需事先知道它们将与哪个 Redux 存储进行交互。

到目前为止,我们已经涵盖了 Redux 的基本工作原理。但是,真实世界的 Redux 应用程序在这些基础之上使用了一些额外的模式。

需要注意的是,这些模式中的任何一种都不需要使用 Redux!但是,每个模式都存在非常好的理由,并且您几乎会在每个 Redux 代码库中看到其中一些或全部模式。

在本节中,我们将重新编写现有的待办事项应用程序代码以使用其中一些模式,并讨论为什么它们在 Redux 应用程序中被普遍使用。然后,在 第 8 部分 中,我们将讨论“现代 Redux”,包括如何使用我们官方的 Redux Toolkit 包来简化我们在应用程序中“手动”编写的所有 Redux 逻辑,以及为什么我们建议将 Redux Toolkit 作为编写 Redux 应用程序的标准方法

注意

请注意,本教程有意展示旧式的 Redux 逻辑模式,这些模式需要比我们今天教导的“现代 Redux”模式(使用 Redux Toolkit)需要更多代码,以便解释 Redux 背后的原理和概念。旨在成为一个生产就绪的项目。

请参阅以下页面以了解如何使用 Redux Toolkit 使用“现代 Redux”

动作创建器

在我们的应用程序中,我们一直在代码中直接编写动作对象,并在那里进行调度

dispatch({ type: 'todos/todoAdded', payload: trimmedText })

但是,在实践中,编写良好的 Redux 应用程序实际上不会在调度动作时内联编写这些动作对象。相反,我们使用“动作创建器”函数。

动作创建器是一个创建并返回动作对象的函数。我们通常使用它们,这样我们就不必每次都手动编写动作对象

const todoAdded = text => {
return {
type: 'todos/todoAdded',
payload: text
}
}

然后,我们通过调用动作创建器,然后将生成的动作对象直接传递给 dispatch 来使用它们

store.dispatch(todoAdded('Buy milk'))

console.log(store.getState().todos)
// [ {id: 0, text: 'Buy milk', completed: false}]

详细说明:为什么要使用动作创建器?

在我们的小型示例待办事项应用程序中,每次都手动编写动作对象并不太难。事实上,通过切换到使用动作创建器,我们增加了更多工作 - 现在我们必须编写一个函数动作对象。

但是,如果我们需要从应用程序的许多部分调度相同的动作怎么办?或者如果每次调度动作时都需要执行一些额外的逻辑,例如创建唯一 ID?我们最终将不得不每次需要调度该动作时都复制粘贴额外的设置逻辑。

动作创建器有两个主要目的

  • 它们准备和格式化动作对象的内容
  • 它们封装了每次创建这些动作时所需的任何额外工作

这样,我们就有了一种创建动作的一致方法,无论是否需要执行任何额外工作。thunk 也一样。

使用动作创建器

让我们更新我们的待办事项切片文件,以便为我们的一些动作类型使用动作创建器。

我们将从我们一直在使用的两个主要动作开始:从服务器加载待办事项列表,以及在将新待办事项保存到服务器后添加它。

现在,todosSlice.js 直接调度动作对象,如下所示

dispatch({ type: 'todos/todosLoaded', payload: response.todos })

我们将创建一个函数来创建并返回相同类型的动作对象,但接受待办事项数组作为其参数,并将其放入动作中作为 action.payload。然后,我们可以使用该新动作创建器在我们的 fetchTodos thunk 中调度动作

src/features/todos/todosSlice.js
export const todosLoaded = todos => {
return {
type: 'todos/todosLoaded',
payload: todos
}
}

export async function fetchTodos(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

我们也可以对“待办事项已添加”动作做同样的事情

src/features/todos/todosSlice.js
export const todoAdded = todo => {
return {
type: 'todos/todoAdded',
payload: todo
}
}

export function saveNewTodo(text) {
return async function saveNewTodoThunk(dispatch, getState) {
const initialTodo = { text }
const response = await client.post('/fakeApi/todos', { todo: initialTodo })
dispatch(todoAdded(response.todo))
}
}

既然我们已经完成了,让我们对“颜色过滤器已更改”操作做同样的事情。

src/features/filters/filtersSlice.js
export const colorFilterChanged = (color, changeType) => {
return {
type: 'filters/colorFilterChanged',
payload: { color, changeType }
}
}

由于此操作是从<Footer>组件中分发的,因此我们需要在该组件中导入colorFilterChanged操作创建者并使用它。

src/features/footer/Footer.js
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'

import { availableColors, capitalize } from '../filters/colors'
import { StatusFilters, colorFilterChanged } from '../filters/filtersSlice'

// omit child components

const Footer = () => {
const dispatch = useDispatch()

const todosRemaining = useSelector(state => {
const uncompletedTodos = state.todos.filter(todo => !todo.completed)
return uncompletedTodos.length
})

const { status, colors } = useSelector(state => state.filters)

const onMarkCompletedClicked = () => dispatch({ type: 'todos/allCompleted' })
const onClearCompletedClicked = () =>
dispatch({ type: 'todos/completedCleared' })

const onColorChange = (color, changeType) =>
dispatch(colorFilterChanged(color, changeType))

const onStatusChange = status =>
dispatch({ type: 'filters/statusFilterChanged', payload: status })

// omit rendering output
}

export default Footer

请注意,colorFilterChanged操作创建者实际上接受两个不同的参数,然后将它们组合在一起以形成正确的action.payload字段。

这不会改变应用程序的工作方式或 Redux 数据流的行为 - 我们仍然在创建操作对象并分发它们。但是,我们不再在代码中直接编写操作对象,而是使用操作创建者在分发之前准备这些操作对象。

我们也可以将操作创建者与 thunk 函数一起使用,事实上,我们在上一节中将 thunk 包装在操作创建者中。我们专门将saveNewTodo包装在“thunk 操作创建者”函数中,以便我们可以传入text参数。虽然fetchTodos不接受任何参数,但我们仍然可以将其包装在操作创建者中。

src/features/todos/todosSlice.js
export function fetchTodos() {
return async function fetchTodosThunk(dispatch, getState) {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}
}

这意味着我们必须更改它在index.js中分发的位置,以调用外部 thunk 操作创建者函数,并将返回的内部 thunk 函数传递给dispatch

src/index.js
import store from './store'
import { fetchTodos } from './features/todos/todosSlice'

store.dispatch(fetchTodos())

到目前为止,我们使用function关键字编写 thunk,以清楚地说明它们的作用。但是,我们也可以使用箭头函数语法来编写它们。使用隐式返回可以缩短代码,尽管如果您不熟悉箭头函数,它可能会使代码更难阅读。

src/features/todos/todosSlice.js
// Same thing as the above example!
export const fetchTodos = () => async dispatch => {
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

类似地,如果我们愿意,我们可以缩短普通操作创建者。

src/features/todos/todosSlice.js
export const todoAdded = todo => ({ type: 'todos/todoAdded', payload: todo })

由您决定是否以这种方式使用箭头函数更好。

信息

有关操作创建者为何有用的更多详细信息,请参阅

记忆选择器

我们已经看到,我们可以编写“选择器”函数,这些函数接受 Redux state对象作为参数,并返回一个值。

const selectTodos = state => state.todos

如果我们需要派生一些数据怎么办?例如,也许我们想要一个只包含待办事项 ID 的数组。

const selectTodoIds = state => state.todos.map(todo => todo.id)

但是,array.map()始终返回一个新的数组引用。我们知道 React-Redux useSelector钩子将在每个分发的操作之后重新运行其选择器函数,如果选择器结果发生变化,它将强制组件重新渲染。

在这个例子中,调用useSelector(selectTodoIds)始终导致组件在每个操作之后重新渲染,因为它返回一个新的数组引用!

在第 5 部分中,我们看到我们可以将shallowEqual作为参数传递给useSelector。但是,这里还有另一个选择:我们可以使用“记忆”选择器。

记忆是一种缓存 - 特别是保存昂贵计算的结果,并在以后看到相同的输入时重用这些结果。

记忆化选择器函数是保存最近结果值的 selector,如果你用相同的输入多次调用它们,它们会返回相同的结果值。如果你用不同于上次的输入调用它们,它们会重新计算一个新的结果值,缓存它,并返回新的结果。

使用 createSelector 记忆化 Selector

Reselect 库 提供了一个 createSelector API,它将生成记忆化选择器函数createSelector 接受一个或多个“输入选择器”函数作为参数,再加上一个“输出选择器”,并返回新的选择器函数。每次你调用 selector

  • 所有“输入选择器”都用所有参数调用
  • 如果任何输入选择器返回值发生了变化,“输出选择器”将重新运行
  • 所有输入选择器结果都成为输出选择器的参数
  • 输出选择器的最终结果被缓存以备下次使用

让我们创建一个 selectTodoIds 的记忆化版本,并将其与我们的 <TodoList> 一起使用。

首先,我们需要安装 Reselect

npm install reselect

然后,我们可以导入并调用 createSelector。我们最初的 selectTodoIds 函数是在 TodoList.js 中定义的,但选择器函数更常见的是在相关的切片文件中编写。所以,让我们将其添加到 todos 切片中

src/features/todos/todosSlice.js
import { createSelector } from 'reselect'

// omit reducer

// omit action creators

export const selectTodoIds = createSelector(
// First, pass one or more "input selector" functions:
state => state.todos,
// Then, an "output selector" that receives all the input results as arguments
// and returns a final result value
todos => todos.map(todo => todo.id)
)

然后,让我们在 <TodoList> 中使用它

src/features/todos/TodoList.js
import React from 'react'
import { useSelector, shallowEqual } from 'react-redux'

import { selectTodoIds } from './todosSlice'
import TodoListItem from './TodoListItem'

const TodoList = () => {
const todoIds = useSelector(selectTodoIds)

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

这实际上与 shallowEqual 比较函数的行为略有不同。任何时候 state.todos 数组发生变化,我们都会创建一个新的 todo ID 数组作为结果。这包括对 todo 项目的任何不可变更新,例如切换它们的 completed 字段,因为我们必须为不可变更新创建一个新的数组。

提示

记忆化选择器只有在你实际从原始数据中推导出额外的值时才有用。如果你只是在查找和返回现有值,你可以将选择器保留为一个普通函数。

具有多个参数的选择器

我们的 todo 应用程序应该能够根据其完成状态过滤可见的 todos。让我们编写一个记忆化选择器,它返回该过滤后的 todos 列表。

我们知道我们需要整个 todos 数组作为我们输出选择器的一个参数。我们还需要传入当前完成状态过滤器值。我们将添加一个单独的“输入选择器”来提取每个值,并将结果传递给“输出选择器”。

src/features/todos/todosSlice.js
import { createSelector } from 'reselect'
import { StatusFilters } from '../filters/filtersSlice'

// omit other code

export const selectFilteredTodos = createSelector(
// First input selector: all todos
state => state.todos,
// Second input selector: current status filter
state => state.filters.status,
// Output selector: receives both values
(todos, status) => {
if (status === StatusFilters.All) {
return todos
}

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => todo.completed === completedStatus)
}
)
注意

请注意,我们现在在两个切片之间添加了一个导入依赖关系 - todosSlicefiltersSlice 导入了一个值。这是合法的,但要小心。**如果两个切片都尝试从彼此导入东西,最终可能会出现“循环导入依赖”问题,这会导致代码崩溃**。如果发生这种情况,请尝试将一些公共代码移动到自己的文件中,然后从该文件导入。

现在我们可以使用这个新的“过滤后的待办事项”选择器作为另一个选择器的输入,该选择器返回这些待办事项的 ID

src/features/todos/todosSlice.js
export const selectFilteredTodoIds = createSelector(
// Pass our other memoized selector as an input
selectFilteredTodos,
// And derive data in the output selector
filteredTodos => filteredTodos.map(todo => todo.id)
)

如果我们将 <TodoList> 切换为使用 selectFilteredTodoIds,我们应该能够标记几个待办事项为已完成

Todo app - todos marked completed

然后过滤列表以仅显示已完成的待办事项

Todo app - todos marked completed

然后,我们可以扩展我们的 selectFilteredTodos,使其也包含颜色过滤在选择中

src/features/todos/todosSlice.js
export const selectFilteredTodos = createSelector(
// First input selector: all todos
selectTodos,
// Second input selector: all filter values
state => state.filters,
// Output selector: receives both values
(todos, filters) => {
const { status, colors } = filters
const showAllCompletions = status === StatusFilters.All
if (showAllCompletions && colors.length === 0) {
return todos
}

const completedStatus = status === StatusFilters.Completed
// Return either active or completed todos based on filter
return todos.filter(todo => {
const statusMatches =
showAllCompletions || todo.completed === completedStatus
const colorMatches = colors.length === 0 || colors.includes(todo.color)
return statusMatches && colorMatches
})
}
)

请注意,通过将逻辑封装在这个选择器中,我们的组件无需更改,即使我们更改了过滤行为。现在我们可以同时按状态和颜色进行过滤

Todo app - status and color filters

最后,我们的代码中有几个地方正在查找 state.todos。在接下来的部分中,我们将对该状态的设计方式进行一些更改,因此我们将提取一个单独的 selectTodos 选择器并在所有地方使用它。我们也可以将 selectTodoById 移动到 todosSlice

src/features/todos/todosSlice.js
export const selectTodos = state => state.todos

export const selectTodoById = (state, todoId) => {
return selectTodos(state).find(todo => todo.id === todoId)
}
信息

有关我们为什么使用选择器函数以及如何使用 Reselect 编写记忆选择器的更多详细信息,请参见

异步请求状态

我们使用异步 thunk 从服务器获取初始待办事项列表。由于我们使用的是假的服务器 API,因此该响应会立即返回。在实际应用程序中,API 调用可能需要一段时间才能解析。在这种情况下,通常会在等待响应完成时显示某种加载指示器。

这通常在 Redux 应用程序中通过以下方式处理

  • 拥有某种“加载状态”值来指示请求的当前状态
  • 在进行 API 调用之前调度一个“请求开始”操作,该操作通过更改加载状态值来处理
  • 在请求完成时再次更新加载状态值,以指示调用已完成

然后,UI 层在请求正在进行时显示加载指示器,并在请求完成时切换为显示实际数据。

我们将更新我们的待办事项切片以跟踪加载状态值,并在 fetchTodos thunk 中调度额外的 'todos/todosLoading' 操作。

目前,我们 todos reducer 的 state 仅仅是 todos 数组本身。如果我们想在 todos 切片中跟踪加载状态,我们需要重新组织 todos 状态,使其成为一个包含 todos 数组和加载状态值的**对象**。这也意味着需要重写 reducer 逻辑以处理额外的嵌套。

src/features/todos/todosSlice.js
const initialState = {
status: 'idle',
entities: []
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
return {
...state,
entities: [...state.entities, action.payload]
}
}
case 'todos/todoToggled': {
return {
...state,
entities: state.entities.map(todo => {
if (todo.id !== action.payload) {
return todo
}

return {
...todo,
completed: !todo.completed
}
})
}
}
// omit other cases
default:
return state
}
}

// omit action creators

export const selectTodos = state => state.todos.entities

这里有几个重要的事情需要注意。

  • todos 数组现在在 todosReducer 状态对象中嵌套为 state.entities。 "entities" 这个词表示 "具有 ID 的唯一项目",这确实描述了我们的 todo 对象。
  • 这也意味着该数组在整个 Redux 状态对象中嵌套为 state.todos.entities
  • 现在,我们需要在 reducer 中执行额外的步骤来复制额外的嵌套级别,以实现正确的不可变更新,例如 state 对象 -> entities 数组 -> todo 对象。
  • 由于我们代码的其余部分**仅**通过选择器访问 todos 状态,**我们只需要更新 selectTodos 选择器** - 即使我们大幅重塑了状态,UI 的其余部分也将继续按预期工作。

加载状态枚举值

您还会注意到,我们已将加载状态字段定义为字符串枚举。

{
status: 'idle' // or: 'loading', 'succeeded', 'failed'
}

而不是 isLoading 布尔值。

布尔值将我们限制为两种可能性:"加载" 或 "未加载"。实际上,**请求可能处于许多不同的状态**,例如

  • 尚未开始
  • 正在进行中
  • 成功
  • 失败
  • 成功,但现在又回到了可能需要重新获取数据的状态

应用程序逻辑也可能只在某些操作的基础上在特定状态之间转换,而这使用布尔值更难实现。

因此,我们建议**将加载状态存储为字符串枚举值,而不是布尔标志**。

信息

有关为什么加载状态应该是枚举的详细说明,请参见

基于此,我们将添加一个新的 "加载" 操作,该操作将把我们的状态设置为 'loading',并更新 "已加载" 操作以将状态标志重置为 'idle'

src/features/todos/todosSlice.js
const initialState = {
status: 'idle',
entities: []
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
// omit other cases
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
return {
...state,
status: 'idle',
entities: action.payload
}
}
default:
return state
}
}

// omit action creators

// Thunk function
export const fetchTodos = () => async dispatch => {
dispatch(todosLoading())
const response = await client.get('/fakeApi/todos')
dispatch(todosLoaded(response.todos))
}

但是,在我们尝试在 UI 中显示它之前,我们需要修改伪服务器 API 以向我们的 API 调用添加人工延迟。打开 src/api/server.js,并查找此注释掉的代码行(大约在第 63 行)

src/api/server.js
new Server({
routes() {
this.namespace = 'fakeApi'
// this.timing = 2000

// omit other code
}
})

如果你取消注释该行,模拟服务器将在我们应用程序发出的每个 API 调用中添加 2 秒的延迟,这给了我们足够的时间来实际看到加载动画的显示。

现在,我们可以在 <TodoList> 组件中读取加载状态值,并根据该值显示加载动画。

src/features/todos/TodoList.js
// omit imports

const TodoList = () => {
const todoIds = useSelector(selectFilteredTodoIds)
const loadingStatus = useSelector(state => state.todos.status)

if (loadingStatus === 'loading') {
return (
<div className="todo-list">
<div className="loader" />
</div>
)
}

const renderedListItems = todoIds.map(todoId => {
return <TodoListItem key={todoId} id={todoId} />
})

return <ul className="todo-list">{renderedListItems}</ul>
}

在一个真实的应用程序中,我们还需要处理 API 失败错误和其他潜在情况。

以下是启用加载状态后应用程序的外观(要再次看到动画,请重新加载应用程序预览或在新标签页中打开它)

Flux 标准动作

Redux 存储本身并不关心你将哪些字段放入你的动作对象中。它只关心 action.type 是否存在并且是一个字符串。这意味着你可以将任何其他字段放入你想要的动作中。也许我们可以为“添加待办事项”动作使用 action.todo,或者 action.color,等等。

但是,如果每个动作都使用不同的字段名称来表示其数据字段,那么在提前知道每个 reducer 需要处理哪些字段可能很困难。

这就是 Redux 社区提出 “Flux 标准动作”约定 或“FSA”的原因。这是一种关于如何组织动作对象内部字段的建议方法,以便开发人员始终知道哪些字段包含哪种数据。FSA 模式在 Redux 社区中被广泛使用,事实上,你已经在整个教程中使用它了。

FSA 约定规定

  • 如果你的动作对象包含任何实际数据,那么动作的“数据”值应该始终放在 action.payload
  • 动作也可以有一个 action.meta 字段,其中包含额外的描述性数据
  • 动作可以有一个 action.error 字段,其中包含错误信息

所以,所有 Redux 动作都必须

  • 是一个普通的 JavaScript 对象
  • 有一个 type 字段

如果你使用 FSA 模式编写动作,那么动作可以

  • 包含一个 payload 字段
  • 包含一个 error 字段
  • 包含一个 meta 字段

详细解释:FSA 和错误

FSA 规范规定

可选的 error 属性可以在动作表示错误时设置为 trueerrortrue 的动作类似于被拒绝的 Promise。按照惯例,payload 应该是一个错误对象。如果 error 的值不是 true,包括 undefinednull,则该动作不能被解释为错误。

FSA 规范还反对为诸如“加载成功”和“加载失败”之类的事件使用特定的动作类型。

然而,在实践中,Redux 社区忽略了使用 action.error 作为布尔标志的想法,而是选择使用单独的动作类型,例如 'todos/todosLoadingSucceeded''todos/todosLoadingFailed'。这是因为检查这些动作类型比先处理 'todos/todosLoaded' 然后检查 if (action.error) 更容易。

您可以选择对您来说更有效的方法,但大多数应用程序使用单独的动作类型来表示成功和失败。

规范化状态

到目前为止,我们一直将 todos 存储在数组中。这是合理的,因为我们从服务器接收到的数据是数组,并且我们还需要循环遍历 todos 以在 UI 中将它们显示为列表。

然而,在更大的 Redux 应用程序中,通常将数据存储在规范化状态结构中。“规范化”意味着

  • 确保每个数据片段只有一份副本
  • 以允许通过 ID 直接查找项目的方式存储项目
  • 根据 ID 引用其他项目,而不是复制整个项目

例如,在博客应用程序中,您可能拥有指向 UserComment 对象的 Post 对象。可能会有许多由同一个人发布的帖子,因此如果每个 Post 对象都包含一个完整的 User,那么我们将拥有许多相同 User 对象的副本。相反,Post 对象将具有一个用户 ID 值作为 post.user,然后我们可以通过 ID 查找 User 对象,例如 state.users[post.user]

这意味着我们通常将数据组织为对象而不是数组,其中项目 ID 是键,项目本身是值,如下所示

const rootState = {
todos: {
status: 'idle',
entities: {
2: { id: 2, text: 'Buy milk', completed: false },
7: { id: 7, text: 'Clean room', completed: true }
}
}
}

让我们将 todos 切片转换为以规范化形式存储 todos。这将需要对我们的 reducer 逻辑进行一些重大更改,以及更新选择器

src/features/todos/todosSlice
const initialState = {
status: 'idle',
entities: {}
}

export default function todosReducer(state = initialState, action) {
switch (action.type) {
case 'todos/todoAdded': {
const todo = action.payload
return {
...state,
entities: {
...state.entities,
[todo.id]: todo
}
}
}
case 'todos/todoToggled': {
const todoId = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
completed: !todo.completed
}
}
}
}
case 'todos/colorSelected': {
const { color, todoId } = action.payload
const todo = state.entities[todoId]
return {
...state,
entities: {
...state.entities,
[todoId]: {
...todo,
color
}
}
}
}
case 'todos/todoDeleted': {
const newEntities = { ...state.entities }
delete newEntities[action.payload]
return {
...state,
entities: newEntities
}
}
case 'todos/allCompleted': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
newEntities[todo.id] = {
...todo,
completed: true
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/completedCleared': {
const newEntities = { ...state.entities }
Object.values(newEntities).forEach(todo => {
if (todo.completed) {
delete newEntities[todo.id]
}
})
return {
...state,
entities: newEntities
}
}
case 'todos/todosLoading': {
return {
...state,
status: 'loading'
}
}
case 'todos/todosLoaded': {
const newEntities = {}
action.payload.forEach(todo => {
newEntities[todo.id] = todo
})
return {
...state,
status: 'idle',
entities: newEntities
}
}
default:
return state
}
}

// omit action creators

const selectTodoEntities = state => state.todos.entities

export const selectTodos = createSelector(selectTodoEntities, entities =>
Object.values(entities)
)

export const selectTodoById = (state, todoId) => {
return selectTodoEntities(state)[todoId]
}

因为我们的state.entities字段现在是一个对象而不是数组,所以我们必须使用嵌套的对象展开运算符来更新数据,而不是数组操作。此外,我们不能像循环数组那样循环对象,因此在几个地方我们必须使用Object.values(entities)来获取待办事项数组,以便我们可以循环遍历它们。

好消息是,由于我们使用选择器来封装状态查找,因此我们的 UI 仍然不需要更改。坏消息是,reducer 代码实际上更长,更复杂。

这里部分问题是这个待办事项应用程序示例不是一个大型的真实世界应用程序。因此,规范化状态在这个特定应用程序中并不那么有用,而且很难看到潜在的好处。

幸运的是,在第 8 部分:使用 Redux Toolkit 的现代 Redux中,我们将看到一些方法可以大幅缩短管理规范化状态的 reducer 逻辑。

现在,需要理解的重要事项是

  • 规范化确实在 Redux 应用程序中普遍使用
  • 主要好处是可以通过 ID 查找单个项目,并确保状态中只存在一个项目的副本
信息

有关规范化在 Redux 中为什么有用的更多详细信息,请参见

Thunk 和 Promise

在本节中,我们还有最后一个模式要看。我们已经了解了如何在 Redux 存储中根据分派的 action 处理加载状态。如果我们需要在组件中查看 thunk 的结果怎么办?

每当您调用store.dispatch(action)时,dispatch实际上会返回action作为其结果。中间件可以修改该行为,并返回其他值。

我们已经看到 Redux Thunk 中间件允许我们将函数传递给dispatch,调用该函数,然后返回结果

reduxThunkMiddleware.js
const reduxThunkMiddleware = storeAPI => next => action => {
// If the "action" is actually a function instead...
if (typeof action === 'function') {
// then call the function and pass `dispatch` and `getState` as arguments
// Also, return whatever the thunk function returns
return action(storeAPI.dispatch, storeAPI.getState)
}

// Otherwise, it's a normal action - send it onwards
return next(action)
}

这意味着我们可以编写返回 promise 的 thunk 函数,并在组件中等待该 promise

我们已经拥有<Header>组件,它分派了一个 thunk 将新的待办事项条目保存到服务器。让我们在<Header>组件中添加一些加载状态,然后在等待服务器时禁用文本输入并显示另一个加载微调器

src/features/header/Header.js
const Header = () => {
const [text, setText] = useState('')
const [status, setStatus] = useState('idle')
const dispatch = useDispatch()

const handleChange = e => setText(e.target.value)

const handleKeyDown = async e => {
// If the user pressed the Enter key:
const trimmedText = text.trim()
if (e.which === 13 && trimmedText) {
// Create and dispatch the thunk function itself
setStatus('loading')
// Wait for the promise returned by saveNewTodo
await dispatch(saveNewTodo(trimmedText))
// And clear out the text input
setText('')
setStatus('idle')
}
}

let isLoading = status === 'loading'
let placeholder = isLoading ? '' : 'What needs to be done?'
let loader = isLoading ? <div className="loader" /> : null

return (
<header className="header">
<input
className="new-todo"
placeholder={placeholder}
autoFocus={true}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
{loader}
</header>
)
}

export default Header

现在,如果我们添加一个待办事项,我们将在标题中看到一个微调器

Todo app - component loading spinner

你学到了什么

正如你所见,Redux 应用中还有几种广泛使用的额外模式。这些模式不是必需的,可能需要在初始阶段编写更多代码,但它们提供了诸如使逻辑可重用、封装实现细节、提高应用程序性能以及更轻松地查找数据等好处。

信息

有关这些模式存在的原因以及 Redux 的使用方式的更多详细信息,请参阅

以下是我们的应用程序在完全转换为使用这些模式后的样子

总结
  • Action creator 函数封装了准备 action 对象和 thunk
    • Action creator 可以接受参数并包含设置逻辑,并返回最终的 action 对象或 thunk 函数
  • 记忆化的选择器有助于提高 Redux 应用程序的性能
    • Reselect 具有 createSelector API,可以生成记忆化的选择器
    • 如果给定相同的输入,记忆化的选择器将返回相同的結果引用
  • 请求状态应存储为枚举,而不是布尔值
    • 使用诸如 'idle''loading' 之类的枚举有助于一致地跟踪状态
  • "Flux Standard Actions" 是组织 action 对象的通用约定
    • Actions 使用 payload 用于数据,meta 用于额外的描述,以及 error 用于错误
  • 规范化的状态使通过 ID 查找项目变得更容易
    • 规范化的数据存储在对象中而不是数组中,以项目 ID 作为键
  • Thunk 可以从 dispatch 返回 promise
    • 组件可以等待异步 thunk 完成,然后执行更多工作

下一步

手动编写所有这些代码可能很耗时且困难。因此,我们建议您使用我们官方的 Redux Toolkit 包来编写您的 Redux 逻辑。

Redux Toolkit 提供了 API,帮助你以更少的代码编写所有典型的 Redux 使用模式。它还有助于防止常见的错误,例如意外地修改状态。

第 8 部分:现代 Redux 中,我们将介绍如何使用 Redux Toolkit 简化到目前为止我们编写的所有代码。