十二、Redux 和挂钩

在上一章中,我们学习了 React 类组件,以及如何从现有的基于类组件的项目迁移到基于挂钩的项目。然后,我们了解了两种解决方案之间的权衡,并讨论了应该何时以及如何迁移现有项目。

在本章中,我们将把上一章中创建的 ToDo 应用转换为 Redux 应用。首先,我们将学习什么是 Redux,包括 Redux 的三个原则。我们还将了解在应用中使用 Redux 何时有意义,以及它不适合于每个应用。此外,我们将学习如何使用 Redux 处理状态。之后,我们将学习如何将 Redux 与挂钩一起使用,以及如何将现有的 Redux 应用迁移到挂钩。最后,我们将学习 Redux 的权衡,以便能够决定哪种解决方案最适合特定用例。在本章结束时,您将完全了解如何使用挂钩编写 Redux 应用。

本章将介绍以下主题:

  • 什么是 Redux,何时以及为什么应该使用它
  • 用 Redux 处理状态
  • 使用带挂钩的 Redux
  • 迁移 Redux 应用
  • 学习 Redux 的权衡

技术要求

应该已经安装了 Node.js 的最新版本(v11.12.0 或更高版本)。Node.js 的npm包管理器也需要安装。

本章的代码可以在 GitHub 存储库中找到:https://github.com/PacktPublishing/Learn-React-Hooks/tree/master/Chapter12

请查看以下视频以查看代码的运行情况:

http://bit.ly/2Mm9yoC

Please note that it is highly recommended that you write the code on your own. Do not simply run the code examples that have been provided. It is important that write the code yourself in order for you to be able to learn and understand properly. However, if you run into any issues, you can always refer to the code example.

现在,让我们从这一章开始。

什么是 Redux?

正如我们之前了解到的,应用中有两种状态:

  • 本地状态:例如处理输入字段数据
  • 全局状态:例如存储当前登录的用户

在本书之前,我们使用状态挂钩处理局部状态,使用还原挂钩处理更复杂的状态(通常是全局状态)。

Redux 是一种解决方案,可用于处理 React 应用中的各种状态。它提供一个状态树对象,其中包含所有应用状态。这与我们在 blog 应用中使用 Reducer 挂钩所做的类似。传统上,Redux 还经常用于存储本地状态,这使得状态树非常复杂。

Redux 基本上由五个元素组成:

  • 存储:包含状态,是描述应用完整状态的对象-{ todos: [], filter: 'all' }
  • 动作:描述状态修改的对象-{ type: 'FILTER_TODOS', filter: 'completed' }
  • 动作创建者:创建动作对象的函数-(filter) => ({ type: 'FILTER_TODOS', filter })
  • 减速机:取当前state值和action对象,并返回新状态的函数-(state, action) => { ... }
  • 连接器:通过注入 Redux 状态和动作创建者作为道具,将现有组件连接到 Redux 的高阶组件—connect(mapStateToProps, mapDispatchToProps)(Component)

在 Redux 生命周期中,存储包含状态,定义 UI。用户界面通过连接器连接到 Redux 商店。用户与 UI 交互后触发动作,并发送到简化器。然后简化器更新存储中的状态。

我们可以在下图中看到 Redux 生命周期的可视化:

Visualization of the Redux life cycle

如您所见,我们已经了解了其中的三个组件:存储(状态树)、操作和还原器。Redux 就像一个更高级版本的简化器挂钩。不同之处在于,对于 Redux,我们总是将状态分派给单个 reducer,因此更改单个状态。Redux 的实例不应超过一个。通过此限制,我们可以确保整个应用状态包含在单个对象中,这允许我们仅从 Redux 存储重建整个应用状态。

由于有一个包含所有状态的单一存储,我们可以通过在崩溃报告中保存 Redux 存储来轻松调试错误状态,或者我们可以在调试期间自动重播某些操作,这样我们就不需要手动输入文本并反复单击按钮。此外,Redux 提供了中间件,简化了我们处理异步请求的方式,例如从服务器获取数据。现在我们了解了 Redux 是什么,在下一节中,我们将学习 Redux 的三个基本原则。

Redux 的三个原则

reduxapi 非常小,实际上只包含少数函数。使 Redux 如此强大的是在使用库时应用于代码的特定规则集。这些规则允许编写易于扩展、测试和调试的可伸缩应用。

Redux 基于三个基本原则:

  • 真理的单一来源
  • 只读状态
  • 状态更改是用纯函数处理的

真理的单一来源

这一重复原则规定,数据应始终具有单一的真实来源。这意味着全局数据来自单个 Redux 存储,而本地数据来自(例如)某个状态挂钩。每种数据只有一个来源。因此,应用变得更容易调试,并且更不容易出错。

只读状态

使用 Redux,无法直接修改应用状态。只有通过分派操作才能更改状态。此原则使状态更改可预测:如果未发生任何操作,应用状态将不会更改。此外,操作一次处理一个,因此我们不必处理竞争条件。最后,动作是普通的 JavaScript 对象,这使它们易于序列化、记录、存储或重放。因此,调试和测试 Redux 应用变得非常容易。

状态更改是用纯函数处理的

纯函数是在给定相同输入的情况下,始终返回相同输出的函数。Redux 中的 Reducer 函数是纯函数,因此,给定相同的状态和操作,它们将始终返回相同的新状态。

例如,以下简化器是一个不纯函数,因为使用相同的输入多次调用该函数会导致不同的输出:

let i = 0
function counterReducer (state, action) {
    if (action.type === 'INCREMENT') {
        i++
    }
    return i
}

console.log(counterReducer(0, { type: 'INCREMENT' })) // prints 1
console.log(counterReducer(0, { type: 'INCREMENT' })) // prints 2

要将此 reducer 转换为纯函数,我们必须确保它不依赖于外部状态,并且只使用其参数进行计算:

function counterReducer (state, action) {
    if (action.type === 'INCREMENT') {
        return state + 1
    }
    return state
}

console.log(counterReducer(0, { type: 'INCREMENT' })) // prints 1
console.log(counterReducer(0, { type: 'INCREMENT' })) // prints 1

使用纯函数作为减缩器可以使它们具有可预测性,并且易于测试和调试。对于 Redux,我们需要小心始终返回新状态,而不是修改现有状态。因此,例如,我们不能在数组状态上使用Array.push(),因为它会修改现有数组;我们必须使用Array.concat()来创建一个新的数组。对象也是如此,我们必须使用 rest/spread 语法来创建新对象,而不是修改现有对象。例如,{ ...state, completed: true }

现在我们已经了解了 Redux 的三个基本原则,我们可以通过在 ToDo 应用中使用 Redux 实现状态处理,进而在实践中使用 Redux。

用 Redux 处理状态

使用 Redux 进行状态管理实际上与使用 Reducer 挂钩非常相似。我们首先定义 state 对象,然后是 actions,最后是 reducer。Redux 中的另一个模式是创建返回动作对象的函数,即所谓的动作创建者。此外,我们需要用Provider组件包装整个应用,并将组件连接到 Redux 商店,以便能够使用 Redux 状态和动作创建者。

安装 Redux

首先,我们必须安装 Redux、React-Redux 和 Redux-Thunk。让我们看看每个人各自做了什么:

  • Redux 本身只处理 JavaScript 对象,因此它提供了存储,处理动作和动作创建者,并处理还原器。
  • React-Redux 提供连接器,以便将 Redux 连接到我们的 React 组件。
  • Redux Thunk 是一个中间件,允许我们在 Redux 中处理异步请求。

使用Redux结合React将全局状态管理卸载到Redux,而React处理应用和本地状态的呈现:

Illustration of how React and Redux work together

要安装 Redux 和 React Redux,我们将使用npm。执行以下命令:

> npm install --save redux react-redux redux-thunk

现在已经安装了所有必需的库,我们可以开始设置 Redux 存储。

定义状态、操作和还原器

开发 Redux 应用的第一步是定义状态,然后是要更改状态的操作,最后是执行状态修改的 reducer 函数。在我们的 ToDo 应用中,我们已经定义了状态、动作和简化器,以便使用简化器挂钩。这里,我们简单地回顾一下我们在上一章中定义的内容。

状态

ToDo 应用的完整状态对象由两个键组成:一个 ToDo 项数组和一个字符串,该字符串指定当前选择的filter值。初始状态如下所示:

{
    "todos": [
        { "id": 1, "title": "Write React Hooks book", "completed": true },
        { "id": 2, "title": "Promote book", "completed": false }
    ],
    "filter": "all"
}

我们可以看到,在 Redux 中,state 对象包含对我们的应用重要的所有状态。在这种情况下,应用状态由一个数组todos和一个filter组成。

行动

我们的应用接受以下五个操作:

  • FETCH_TODOS:获取新的待办事项列表—{ type: 'FETCH_TODOS', todos: [] }
  • ADD_TODO:插入新的待办事项—{ type: 'ADD_TODO', title: 'Test ToDo app' }
  • TOGGLE_TODO:切换待办事项的completed值-{ type: 'TOGGLE_TODO', id: 'xxx' }
  • REMOVE_TODO:删除待办事项-{ type: 'REMOVE_TODO', id: 'xxx' }
  • FILTER_TODOS:过滤待办事项-{ type: 'FILTER_TODOS', filter: 'completed' }

还原剂

我们定义了三个简化器,一个用于州的每个部分,另一个用于组合其他两个简化器的应用简化器。过滤器简化器等待FILTER_TODOS动作,然后相应地设置新过滤器。todos reducer 侦听其他与 todo 相关的操作,并通过添加、删除或修改元素来调整 todos 数组。应用 reducer 然后将两个 reducer 合并,并将操作传递给它们。在定义了创建 Redux 应用所需的所有元素之后,我们现在可以设置 Redux 存储

设置 Redux 存储

为了一开始就保持简单,并展示 Redux 是如何工作的,我们现在不打算使用连接器。我们将简单地用 Redux 替换state对象和dispatch函数,该函数以前由简化器挂钩提供。

现在让我们设置 Redux 商店:

  1. 编辑src/App.js,从 Redux 库导入useState挂钩和createStore函数:
import React, { useState, useEffect, useMemo } from 'react'
import { createStore } from 'redux' 
  1. 在 import 语句下面和App函数定义之前,我们将初始化 Redux 存储。我们首先定义初始状态:
const initialState = { todos: [], filter: 'all' }
  1. 接下来,我们将使用createStore函数来定义 Redux 存储,通过使用现有appReducer函数并传递initialState对象:
const store = createStore(appReducer, initialState)

Please note that in Redux, it is not best practice to initialize the state by passing it to createStore. However, with a Reducer Hook, we need to do it this way. In Redux, we usually initialize state by setting default values in the reducer functions. We are going to learn more about initializing state via Redux reducers later in this chapter.

  1. 现在,我们可以从商店获得dispatch功能:
const { dispatch } = store
  1. 下一步是在App功能中删除以下简化器吊钩定义:
    const [ state, dispatch ] = useReducer(appReducer, { todos: [], filter: 'all' })

它被一个简单的状态挂钩取代,它将存储我们的 Redux 状态:

    const [ state, setState ] = useState(initialState)
  1. 最后,为了使状态挂钩与 Redux 存储状态保持同步,我们定义了一个效果挂钩:
    useEffect(() => {
        const unsubscribe = store.subscribe(() => setState(store.getState()))
        return unsubscribe
    }, [])

正如我们所见,该应用仍然以与以前完全相同的方式运行。Redux 的工作原理与 Reducer 挂钩非常相似,但具有更多功能。但是,在如何定义动作和减缩器方面有一些细微的差别,我们将在下面的章节中了解这些差别。

示例代码

示例代码可在Chapter12/chapter12_1文件夹中找到。

只需运行npm install安装所有依赖项并启动npm start应用,然后在浏览器中访问http://localhost:3000(如果它没有自动打开)。

定义动作类型

创建完整 Redux 应用的第一步是定义所谓的操作类型。它们将用于在动作创建者中创建动作,并在还原器中处理动作。这里的想法是在定义或比较动作的type属性时避免输入错误。

现在让我们定义动作类型:

  1. 创建一个新的src/actionTypes.js文件。
  2. 在新创建的文件中定义并导出以下常量:
export const FETCH_TODOS = 'FETCH_TODOS'
export const ADD_TODO = 'ADD_TODO'
export const TOGGLE_TODO = 'TOGGLE_TODO'
export const REMOVE_TODO = 'REMOVE_TODO'
export const FILTER_TODOS = 'FILTER_TODOS'

现在我们已经定义了动作类型,可以开始在动作创建者和还原器中使用它们了。

定义动作创建者

定义动作类型之后,我们需要定义动作本身。在此过程中,我们将定义返回动作对象的函数。这些函数称为动作创建者,其中有两种类型:

  • 同步动作创建者:这些只是返回一个动作对象
  • 异步动作创建者:它们返回一个async函数,该函数稍后将分派一个动作

我们将从定义同步动作创建者开始,然后学习如何定义异步动作创建者。

定义同步动作创建者

我们已经在前面的src/App.js中定义了 action creator 函数。现在我们可以从App组件复制它们,确保我们调整type属性以使用动作类型常量,而不是静态字符串。

现在让我们定义同步动作创建者:

  1. 创建一个新的src/actions.js文件。
  2. 导入创建操作所需的所有操作类型:
import {
    ADD_TODO, TOGGLE_TODO, REMOVE_TODO, FILTER_TODOS
} from './actionTypes'
  1. 现在,我们可以定义并导出 action creator 函数:
export function addTodo (title) {
    return { type: ADD_TODO, title }
}

export function toggleTodo (id) {
    return { type: TOGGLE_TODO, id }
}

export function removeTodo (id) {
    return { type: REMOVE_TODO, id }
}

export function filterTodos (filter) {
    return { type: FILTER_TODOS, filter }
}

如我们所见,同步动作创建者只需创建并返回动作对象。

定义异步动作创建者

下一步是为fetchTodos动作定义一个异步动作创建者。在这里,我们将使用async/await构造。

我们现在将使用一个async函数来定义fetchTodos动作创建者:

  1. src/actions.js中,首先导入FETCH_TODOS动作类型和fetchAPITodos功能:
import {
    FETCH_TODOS, ADD_TODO, TOGGLE_TODO, REMOVE_TODO, FILTER_TODOS
} from './actionTypes'
import { fetchAPITodos } from './api'
  1. 然后,定义一个新的 action creator 函数,该函数将返回一个async函数,该函数将获取dispatch函数作为参数:
export function fetchTodos () {
    return async (dispatch) => {
  1. 在这个async函数中,我们现在将调用 API 函数,dispatch我们的操作:
        const todos = await fetchAPITodos()
        dispatch({ type: FETCH_TODOS, todos })
    }
}

正如我们所看到的,异步动作创建者返回一个函数,该函数将在以后调度动作。

调整商店

为了让我们能够在 Redux 中使用异步动作创建者函数,我们需要加载redux-thunk中间件。该中间件检查动作创建者是否返回了函数,而不是普通对象,如果是这种情况,则执行该函数,同时将dispatch函数作为参数传递给它。

现在让我们调整存储以允许异步操作创建者:

  1. 创建一个新的src/configureStore.js文件。
  2. 从 Redux 导入createStoreapplyMiddleware函数:
import { createStore, applyMiddleware } from 'redux'
  1. 接下来,导入thunk中间件和appReducer功能:
import thunk from 'redux-thunk'

import appReducer from './reducers'
  1. 现在,我们可以定义存储并将thunk中间件应用到它:
const store = createStore(appReducer, applyMiddleware(thunk))
  1. 最后,我们出口store
export default store

使用redux-thunk中间件,我们现在可以分派稍后将分派动作的函数,这意味着我们的异步动作创建者现在可以正常工作了。

调整简化器

如前所述,Redux 异径管与异径管挂钩的不同之处在于它们具有某些约定:

  • 每个 reducer 都需要通过在函数定义中定义默认值来设置其初始状态
  • 每个 reducer 都需要返回未处理操作的当前状态

我们现在将调整现有的简化器,使其符合这些惯例。第二个约定已经实现,因为我们在前面定义了一个 app reducer,以避免具有多个分派函数。

在 Redux 还原器中设置初始状态

因此,我们将关注第一个约定,即通过在函数参数中定义默认值来设置初始状态,如下所示:

  1. 编辑src/reducers.js并从 Redux 导入combineReducers功能:
import { combineReducers } from 'redux'
  1. 然后将filterReducer重命名为filter,并设置默认值:
function filter (state = 'all', action) {
  1. 接下来,编辑todosReducer并在此处重复相同的过程:
function todos (state = [], action) {
  1. 最后,我们将使用combineReducers函数来创建appReducer函数。我们现在可以执行以下操作,而不是手动创建函数:
const appReducer = combineReducers({ todos, filter })
export default appReducer

正如我们所看到的,Redux 减速机与减速机挂钩非常相似。Redux 甚至提供了一个功能,允许我们将多个 reducer 功能组合成一个应用 reducer!

连接部件

现在,是时候介绍连接器和容器组件了。在 Redux 中,我们可以使用connect高阶组件将现有组件连接到 Redux,将状态和动作创建者作为道具注入其中。

Redux 定义了两种不同类型的组件:

  • 表象成分:React 组件,我们一直在定义它们
  • 容器组件:将呈现组件连接到 Redux 的 React 组件

容器组件使用连接器将 Redux 连接到表示组件。此连接器接受两个功能:

  • mapStateToProps(state):取当前 Redux 状态,返回要传递给组件的道具对象;用于将状态传递给组件
  • mapDispatchToProps(dispatch):从 Redux 存储中获取dispatch函数,返回要传递给组件的道具对象;用于将动作创建者传递给组件

现在,我们将为现有的表示组件定义容器组件:

  1. 首先,我们为所有呈现组件创建一个新的src/components/文件夹。

  2. 然后,我们将所有现有组件文件复制到src/components/文件夹中,并调整以下文件的导入语句:AddTodo.jsApp.jsHeader.jsTodoFilter.jsTodoItem.jsTodoList.js

连接 AddTodo 组件

我们现在将开始将我们的组件连接到 Redux 商店。呈现组件可以保持与以前相同。我们只创建新的组件容器组件来包装呈现组件,并将某些道具传递给它们。

现在连接AddTodo组件:

  1. 为所有容器组件创建一个新的src/containers/文件夹。
  2. 创建一个新的src/containers/ConnectedAddTodo.js文件。
  3. 在这个文件中,我们从react-redux导入connect函数,从redux导入bindActionCreators函数:
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
  1. 接下来,我们导入addTodo动作创建者和AddTodo组件:
import { addTodo } from '../actions'
import AddTodo from '../components/AddTodo'
  1. 现在,我们将定义mapStateToProps函数。由于该组件不处理 Redux 中的任何状态,因此我们只需在此处返回一个空对象:
function mapStateToProps (state) {
    return {}
}
  1. 然后,我们定义了mapDispatchToProps函数。这里我们使用bindActionCreators将动作创建者包装为dispatch函数:
function mapDispatchToProps (dispatch) {
    return bindActionCreators({ addTodo }, dispatch)
}

此代码与手动包装动作创建者基本相同,如下所示:

function mapDispatchToProps (dispatch) {
    return {
        addTodo: (...args) => dispatch(addTodo(...args))
    }
}
  1. 最后,我们使用connect函数将AddTodo组件连接到 Redux:
export default connect(mapStateToProps, mapDispatchToProps)(AddTodo)

现在,我们的AddTodo组件已成功连接到 Redux 商店。

连接 TodoItem 组件

接下来,我们将连接TodoItem组件,以便在下一步的TodoList组件中使用它。

现在连接TodoItem组件:

  1. 创建一个新的src/containers/ConnectedTodoItem.js文件。
  2. 在这个文件中,我们从react-redux导入connect函数,从redux导入bindActionCreators函数:
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
  1. 接下来,我们导入toggleTodoremoveTodo动作创建者,以及TodoItem组件:
import { toggleTodo, removeTodo } from '../actions'
import TodoItem from '../components/TodoItem'
  1. 同样,我们只从mapStateToProps返回一个空对象:
function mapStateToProps (state) {
    return {}
}
  1. 这一次,我们将两个动作创建者绑定到dispatch函数:
function mapDispatchToProps (dispatch) {
    return bindActionCreators({ toggleTodo, removeTodo }, dispatch)
}
  1. 最后,我们连接组件并将其导出:
export default connect(mapStateToProps, mapDispatchToProps)(TodoItem)

现在,我们的TodoItem组件已成功连接到 Redux 商店。

连接 TodoList 组件

连接TodoItem组件后,我们现在可以在TodoList组件中使用ConnectedTodoItem组件。

现在连接TodoList组件:

  1. 编辑src/components/TodoList.js,对导入语句进行如下调整:
import ConnectedTodoItem from '../containers/ConnectedTodoItem'
  1. 然后,将函数返回的组件重命名为ConnectedTodoItem
    return filteredTodos.map(item =>
        <ConnectedTodoItem {...item} key={item.id} />
    )
  1. 现在,创建一个新的src/containers/ConnectedTodoList.js文件。
  2. 在这个文件中,我们只从react-redux导入connect函数,因为这次我们不打算绑定动作创建者:
import { connect } from 'react-redux'
  1. 接下来,我们导入TodoList组件:
import TodoList from '../components/TodoList'
  1. 现在,我们定义mapStateToProps函数。这次我们使用 destructuring 从state对象中获取todosfilter,并返回它们:
function mapStateToProps (state) {
    const { filter, todos } = state
    return { filter, todos }
}
  1. 接下来,我们定义mapDispatchToProps函数,其中我们只返回一个空对象,因为我们不会将任何动作创建者传递给TodoList组件:
function mapDispatchToProps (dispatch) {
    return {}
}
  1. 最后,我们连接并导出连接的TodoList组件:
export default connect(mapStateToProps, mapDispatchToProps)(TodoList)

现在,我们的TodoList组件已成功连接到 Redux 商店。

调整 TodoList 组件

现在我们已经连接了TodoList组件,我们可以将滤波器逻辑从App组件移动到TodoList组件,如下所示:

  1. src/components/TodoList.js中导入useMemo挂钩:
import React, { useMemo } from 'react'
  1. 编辑src/components/App.js,删除以下代码:
    const filteredTodos = useMemo(() => {
        const { filter, todos } = state
        switch (filter) {
            case 'active':
                return todos.filter(t => t.completed === false)

            case 'completed':
                return todos.filter(t => t.completed === true)

            default:
            case 'all':
                return todos
        }
    }, [ state ])
  1. 现在,编辑src/components/TodoList.js,并在此处添加filteredTodos代码。请注意,我们从状态对象中删除了解构,因为组件已经接收到作为道具的filtertodos值。我们还相应地调整了依赖项数组:
    const filteredTodos = useMemo(() => {
        switch (filter) {
            case 'active':
                return todos.filter(t => t.completed === false)

            case 'completed':
                return todos.filter(t => t.completed === true)

            default:
            case 'all':
                return todos
        }
    }, [ filter, todos ])

现在,我们的过滤逻辑在TodoList组件中,而不是App组件中。让我们继续连接其余的组件。

连接 TodoFilter 组件

接下来是TodoFilter组件。在这里,我们将同时使用mapStateToPropsmapDispatchToProps

现在连接TodoFilter组件:

  1. 创建一个新的src/containers/ConnectedTodoFilter.js文件。
  2. 在这个文件中,我们从react-redux导入connect函数,从redux导入bindActionCreators函数:
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
  1. 接下来,我们导入filterTodos动作创建者和TodoFilter组件:
import { filterTodos } from '../actions'
import TodoFilter from '../components/TodoFilter'
  1. 我们使用 destructuring 从state对象获取filter,然后返回它:
function mapStateToProps (state) {
    const { filter } = state
    return { filter }
}
  1. 接下来,我们绑定并返回filterTodos动作创建者:
function mapDispatchToProps (dispatch) {
    return bindActionCreators({ filterTodos }, dispatch)
}
  1. 最后,我们连接组件并将其导出:
export default connect(mapStateToProps, mapDispatchToProps)(TodoFilter)

现在,我们的TodoFilter组件已成功连接到 Redux 商店。

连接应用组件

现在唯一需要连接的组件是App组件。在这里,我们将注入fetchTodosaction creator,并更新组件,使其使用所有其他组件的连接版本。

现在连接App组件:

  1. 编辑src/components/App.js,调整以下导入语句:
import ConnectedAddTodo from '../containers/ConnectedAddTodo'
import ConnectedTodoList from '../containers/ConnectedTodoList'
import ConnectedTodoFilter from '../containers/ConnectedTodoFilter'
  1. 此外,调整从功能返回的以下组件:
            return (
                <div style={{ width: 400 }}>
                    <Header />
                    <ConnectedAddTodo />
                    <hr />
                    <ConnectedTodoList />
                    <hr />
                    <ConnectedTodoFilter />
                </div>
            )
  1. 现在,我们可以创建连接的组件。创建一个新的src/containers/ConnectedApp.js文件。
  2. 在这个新创建的文件中,我们从react-redux导入connect函数,从redux导入bindActionCreators函数:
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
  1. 接下来,我们导入fetchTodos动作创建者和App组件:
import { fetchTodos } from '../actions'
import App from '../components/App'
  1. 我们已经在其他组件中处理了我们状态的各个部分,因此没有必要将任何状态注入到我们的App组件中:
function mapStateToProps (state) {
    return {}
}
  1. 然后,我们绑定并返回fetchTodos动作创建者:
function mapDispatchToProps (dispatch) {
    return bindActionCreators({ fetchTodos }, dispatch)
}
  1. 最后,我们连接App组件并将其导出:
export default connect(mapStateToProps, mapDispatchToProps)(App)

现在,我们的App组件已成功连接到 Redux 商店。

设置提供程序组件

最后,我们必须设置一个Provider组件,它将为 Redux 存储提供一个上下文,连接器将使用该上下文。

现在我们来设置Provider组件:

  1. 编辑src/index.js,从react-redux导入Provider组件:
import { Provider } from 'react-redux'
  1. 现在,从containers文件夹导入ConnectedApp组件,导入configureStore.js创建的 Redux 存储:
import ConnectedApp from './containers/ConnectedApp'
import store from './configureStore'
  1. 最后,将第一个参数调整为ReactDOM.render,将ConnectedApp组件包装为Provider组件,如下所示:
ReactDOM.render(
 <Provider store={store}>
 <ConnectedApp />
 </Provider>,
    document.getElementById('root')
)

现在,我们的应用将以与以前相同的方式工作,但所有内容都连接到 Redux 商店!正如我们所见,Redux 需要比简单使用 React 更多的样板代码,但它有很多优点:

  • 更容易处理异步操作(使用redux-thunk中间件)
  • 集中操作处理(无需在组件中定义操作创建者)
  • 用于绑定动作创建者和组合还原器的有用函数
  • 减少了出错的可能性(例如,通过使用动作类型,我们可以确保没有输入错误)

但是,也存在以下缺点:

  • 需要大量样板代码(动作类型、动作创建者和连接的组件)
  • 在单独的文件中映射状态/动作创建者(不在组件中,需要它们的地方)

第一点是优势与劣势并存;动作类型和动作创建者确实需要更多的样板代码,但它们也使以后更容易更新动作相关的代码。第二点,以及所连接组件所需的样板代码,可以通过使用挂钩将组件连接到 Redux 来解决。在本章的下一节中,我们将在 Redux 中使用挂钩。

示例代码

示例代码可在Chapter12/chapter12_2文件夹中找到。

只需运行npm install安装所有依赖项并启动npm start应用,然后在浏览器中访问http://localhost:3000(如果它没有自动打开)。

使用带挂钩的 Redux

在将 todo 应用转换为基于 Redux 的应用之后,我们现在使用高阶组件,而不是挂钩,以便访问 Redux 状态和动作创建者。这是开发 Redux 应用的传统方法。但是,在最新版本的 Redux 中,可以使用挂钩而不是高阶组件!我们现在将用挂钩替换现有的连接器。

Even with Hooks, the Provider component is still required in order to provide the Redux store to other components. The definition of the store and the provider can stay the same when refactoring from connect() to Hooks.

React-Redux 的最新版本提供了各种挂钩,作为connect()高阶组件的替代。使用这些挂钩,您可以订阅 Redux 存储,并在不包装组件的情况下分派操作。

使用调度挂钩

useDispatch挂钩返回对 Redux 存储提供的dispatch函数的引用。它可用于分派从动作创建者返回的动作。其 API 如下所示:

const dispatch = useDispatch()

我们现在将使用分派挂钩来用挂钩替换现有的容器组件。

You do not need to migrate your whole Redux application at once in order to use Hooks. It is possible to selectively refactor certain components—meaning that they will use Hooks—while still using connect() for other components.

在学习了如何使用分派挂钩之后,让我们继续迁移现有组件,以便它们使用分派挂钩。

使用 AddTodo 组件的挂钩

现在我们已经了解了调度挂钩,让我们通过在AddTodo组件中实现它来了解它的作用。

现在让我们将AddTodo组件迁移到挂钩:

  1. 首先删除src/containers/ConnectedAddTodo.js文件。
  2. 现在,编辑src/components/AddTodo.js文件并从react-redux导入useDispatch挂钩:
import { useDispatch } from 'react-redux'
  1. 另外,导入addTodo动作创建者:
import { addTodo } from '../actions'
  1. 现在,我们可以从函数定义中删除道具:
export default function AddTodo () {
  1. 然后,定义分派挂钩:
    const dispatch = useDispatch()
  1. 最后,调整处理函数并调用dispatch()
    function handleAdd () {
        if (input) {
            dispatch(addTodo(input))
            setInput('')
        }
    }
  1. 现在,剩下要做的就是将ConnectedAddTodo组件替换为src/components/App.js中的AddTodo组件。首先,调整导入语句:
import AddTodo from './AddTodo'
  1. 然后,调整渲染组件:
    return (
        <div style={{ width: 400 }}>
            <Header />
            <AddTodo />

正如您所看到的,我们的应用仍然以与以前相同的方式工作,但我们现在使用挂钩将组件连接到 Redux!

使用应用组件的挂钩

接下来,我们将更新我们的App组件,以便它直接分派fetchTodos操作。现在让我们将App组件迁移到挂钩:

  1. 首先删除src/containers/ConnectedApp.js文件。

  2. 现在,编辑src/components/App.js文件并从react-redux导入useDispatch挂钩:

import { useDispatch } from 'react-redux'
  1. 另外,导入fetchTodos动作创建者:
import { fetchTodos } from '../actions'
  1. 现在,我们可以从函数定义中删除道具:
export default function App () {
  1. 然后,定义分派挂钩:
    const dispatch = useDispatch()
  1. 最后,调整效果挂钩并调用dispatch()
    useEffect(() => {
        dispatch(fetchTodos())
    }, [ dispatch ])
  1. 现在,剩下要做的就是将ConnectedApp组件替换为src/index.js中的App组件。首先,调整导入语句:
import App from './components/App'
  1. 然后,调整渲染组件:
ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>,
    document.getElementById('root')
)

正如我们所看到的,使用挂钩比定义一个单独的容器组件更简单、更简洁。

对 TodoItem 组件使用挂钩

现在我们要将TodoItem组件升级为使用挂钩,现在就迁移它:

  1. 首先删除src/containers/ConnectedTodoItem.js文件。
  2. 现在编辑src/components/TodoItem.js文件,从react-redux导入useDispatch挂钩:
import { useDispatch } from 'react-redux'
  1. 另外,导入toggleTodoremoveTodo动作创建者:
import { toggleTodo, removeTodo } from '../actions'
  1. 现在,我们可以从函数定义中删除与 action creator 相关的道具。新代码应如下所示:
export default function TodoItem ({ title, completed, id }) {
  1. 然后,定义分派挂钩:
    const dispatch = useDispatch()
  1. 最后,调整处理函数调用dispatch()
    function handleToggle () {
        dispatch(toggleTodo(id))
    }

    function handleRemove () {
        dispatch(removeTodo(id))
    }
  1. 现在,剩下要做的就是将ConnectedTodoItem组件替换为src/components/TodoList.js中的TodoItem组件。首先,调整导入语句:
import TodoItem from './TodoItem'
  1. 然后,调整渲染组件:
    return filteredTodos.map(item =>
        <TodoItem {...item} key={item.id} />
    )

现在,TodoItem组件使用挂钩而不是容器组件。接下来,我们将学习选择器挂钩。

使用选择器挂钩

Redux 提供的另一个非常重要的挂钩是选择器挂钩。它允许我们通过定义选择器函数从 Redux 存储状态获取数据。此挂钩的 API 如下所示:

const result = useSelector(selectorFn, equalityFn)

selectorFn是一个与mapStateToProps功能类似的功能。它将获取完整状态对象作为其唯一参数。每当组件呈现时,以及每当调度操作时(且状态与前一状态不同),都会执行选择器函数。

需要注意的是,从一个选择器挂钩返回具有多个状态部分的对象将在每次调度操作时强制重新渲染。如果需要从存储中请求多个值,我们可以执行以下操作:

  • 使用多个选择器挂钩,每个挂钩从状态对象返回一个字段
  • 使用reselect或类似的库来创建一个记忆选择器(我们将在下一节中介绍)
  • react-redux中的shallowEqual功能用作equalityFn

我们现在将在 ToDo 应用中实现选择器挂钩,特别是在TodoListTodoFilter组件中。

对 TodoList 组件使用挂钩

首先,我们将实现一个选择器挂钩来获取TodoList组件的所有todos,如下所示:

  1. 首先删除src/containers/ConnectedTodoList.js文件。
  2. 现在编辑src/components/TodoList.js文件,从react-redux导入useSelector挂钩:
import { useSelector } from 'react-redux'
  1. 现在,我们可以从函数定义中删除所有道具:
export default function TodoList () {
  1. 然后,我们定义了两个选择器挂钩,一个用于filter值,一个用于todos值:
    const filter = useSelector(state => state.filter)
    const todos = useSelector(state => state.todos)
  1. 现在,剩下要做的就是将ConnectedTodoList组件替换为src/components/App.js中的TodoList组件。首先,调整导入语句:
import TodoList from './TodoList'
  1. 然后,调整渲染组件:
    return (
        <div style={{ width: 400 }}>
            <Header />
            <AddTodo />
            <hr />
            <TodoList />

组件的其余部分可以保持不变,因为我们存储状态部分的值的名称与以前相同。

对 TodoFilter 组件使用挂钩

最后,我们将在TodoFilter组件中实现选择器和分派挂钩,因为我们需要突出显示当前过滤器(来自选择器挂钩的状态),并分派一个操作来更改过滤器(分派挂钩)。

现在让我们为TodoFilter组件实现挂钩:

  1. 首先,删除src/containers/ConnectedTodoFilter.js文件。
  2. 我们也可以删除src/containers/文件夹,因为它现在是空的。
  3. 现在编辑src/components/TodoFilter.js文件,从react-redux导入useSelectoruseDispatch挂钩:
import { useSelector, useDispatch } from 'react-redux'
  1. 另外,导入filterTodos动作创建者:
import { filterTodos } from '../actions'
  1. 现在,我们可以从函数定义中删除所有道具:
export default function TodoFilter () {
  1. 然后,定义分派和选择器挂钩:
    const dispatch = useDispatch()
    const filter = useSelector(state => state.filter)
  1. 最后,调整处理函数调用dispatch()
    function handleFilter () {
        dispatch(filterTodos(name))
    }
  1. 现在,剩下要做的就是将ConnectedTodoFilter组件替换为src/components/App.js中的TodoFilter组件。首先,调整导入语句:
import TodoFilter from './TodoFilter'
  1. 然后,调整渲染组件:
    return (
        <div style={{ width: 400 }}>
            <Header />
            <AddTodo />
            <hr />
            <TodoList />
            <hr />
            <TodoFilter />
        </div>
    )

现在,我们的 Redux 应用充分利用了挂钩而不是容器组件!

示例代码

示例代码可在Chapter12/chapter12_3文件夹中找到。

只需运行npm install安装所有依赖项并启动npm start应用,然后在浏览器中访问http://localhost:3000(如果它没有自动打开)。

创建可重用选择器

在定义选择器时,就像我们现在所做的那样,每次渲染组件时都会创建选择器的新实例。如果选择器函数不执行任何复杂操作,也不维护内部状态,则这是可以的。否则,我们需要使用可重用的选择器,我们现在将学习这些选择器。

设置重新选择

为了创建可重用的选择器,我们可以使用reselect库中的createSelector函数。首先,我们必须通过npm安装库。执行以下命令:

> npm install --save reselect

现在,reselect库已经安装,我们可以使用它创建可重用的选择器。

记忆仅依赖于状态的选择器

如果我们想要记忆选择器,并且选择器仅取决于状态(而不是道具),我们可以在组件外部声明选择器,如下所示:

  1. 编辑src/components/TodoList.js文件,从reselect导入createSelector功能:
import { createSelector } from 'reselect'
  1. 然后,在组件定义之前,我们为状态的todosfilter部分定义选择器:
const todosSelector = state => state.todos
const filterSelector = state => state.filter

If selectors are used by many components, it might make sense to put them in a separate selectors.js file, and import them from there. For example, we could put the filterSelector in a separate file, and then import it in TodoList.js, as well as TodoFilter.js.

  1. 现在,在定义组件之前,我们为过滤后的 TODO 定义一个选择器,如下所示:
const selectFilteredTodos = createSelector(
  1. 首先,我们指定要重用的其他两个选择器:
    todosSelector,
    filterSelector,
  1. 现在,我们指定一个筛选选择器,从useMemo挂钩复制代码:
    (todos, filter) => {
        switch (filter) {
            case 'active':
                return todos.filter(t => t.completed === false)

            case 'completed':
                return todos.filter(t => t.completed === true)

            default:
            case 'all':
                return todos
        }
    }
)
  1. 最后,我们在选择器挂钩中使用定义的选择器:
export default function TodoList () {
    const filteredTodos = useSelector(selectFilteredTodos)

现在我们已经为过滤的 TODO 定义了一个可重用的选择器,过滤 TODO 的结果将被记录,如果状态没有改变,则不会重新计算。

示例代码

示例代码可在Chapter12/chapter12_4文件夹中找到。

只需运行npm install安装所有依赖项并启动npm start应用,然后在浏览器中访问http://localhost:3000(如果它没有自动打开)。

使用商店挂钩

React-Redux 还提供了一个useStore挂钩,它返回对 Redux 存储本身的引用。这是传递给Provider组件的相同store对象。其 API 如下所示:

const store = useStore()

最好避免直接使用商店挂钩。使用分派或选择器挂钩通常更有意义。但是,在某些特殊情况下,如更换简化器,可能需要使用此挂钩。

在本节中,我们学习了如何在现有的 Redux 应用中用挂钩替换连接器。现在,我们将学习一种策略,它允许我们有效地将现有的 Redux 应用迁移到挂钩。

迁移 Redux 应用

在某些 Redux 应用中,本地状态也存储在 Redux 状态树中。在其他情况下,React 类组件状态用于存储本地状态。在这两种情况下,迁移现有 Redux 应用的方法如下:

  • 用状态挂钩替换简单的本地状态,如输入字段值
  • 复杂局部状态更换为简化器挂钩
  • 在 Redux 存储中保留全局状态(跨多个组件使用的状态)

在上一章中,我们已经学习了如何迁移 React 类组件。在上一节中,我们学习了如何从 Redux 连接器迁移到使用选择器和分派挂钩。我们现在将展示一个将 Redux 本地状态迁移到基于挂钩的方法的示例。

假设我们现有的 todo 应用将输入字段状态存储在 Redux 中,如下所示:

{
    "todos": [],
    "filter": "all",
    "newTodo": ""
}

现在,无论何时输入文本,我们都需要分派一个操作,通过调用所有的 reducer 来计算新的状态,然后更新 Redux 存储状态。您可以想象,如果我们有许多输入字段,这可能会导致相当高的性能。我们不应该在 Redux 中存储newTodo字段,而应该使用状态挂钩来存储此本地状态,因为它仅由一个组件内部使用。我们在AddTodo的实现过程中已经正确地做到了这一点我们的示例应用中的组件。

现在我们已经了解了如何将现有的 Redux 应用迁移到 hook,我们可以继续讨论 Redux 的权衡。

Redux 的权衡

最后,让我们总结一下在 web 应用中使用 Redux 的优缺点。首先,让我们从积极的方面开始:

  • 提供了一个特定的项目结构,允许我们以后轻松地扩展和修改代码
  • 代码中出现错误的可能性更小
  • 性能优于对状态使用 React 上下文
  • 使App组件更简单(将状态管理和操作创建者转移到 Redux)

Redux 非常适合处理复杂状态更改的大型项目,以及跨多个组件使用的状态。

但是,使用 Redux 也有缺点:

  • 需要编写样板代码
  • 项目结构变得更加复杂
  • Redux 需要一个包装器组件(Provider)将应用连接到应用商店

因此,Redux 不应用于简单的项目。在这些情况下,一个简化器挂钩可能就足够了。有了 Reducer 挂钩,就不需要包装器组件来将我们的应用连接到 state store。此外,如果我们使用多个 Reducer 挂钩,那么将操作发送到特定的 Reducer,而不是全局应用 Reducer,性能会稍高一些。然而,缺点在于必须处理多个分派函数,并保持各种状态的同步。我们也不能使用中间件,包括对异步操作的支持。如果状态更改很复杂,但只是某个组件的局部更改,那么使用 Reducer 挂钩可能是有意义的,但是如果状态在多个组件中使用,或者与整个应用相关,那么我们肯定应该将其存储在 Redux 中。

如果您的组件不执行以下操作,则可能不需要 Redux:

  • 使用网络
  • 保存或加载状态
  • 与其他非子组件共享状态

在这种情况下,使用 State 或 Reducer 挂钩而不是 Redux 是有意义的。

总结

在本章中,我们首先了解了 Redux 是什么,以及何时和为什么应该使用它。然后,我们学习了 Redux 的三个原则。接下来,我们在实践中使用 Redux 来处理 ToDo 应用中的状态。我们还学习了同步和异步动作创建者。然后,我们学习了如何使用带有挂钩的 Redux,以及如何将现有 Redux 应用迁移到基于挂钩的解决方案。最后,我们了解了使用 Redux 和 Reducer 挂钩的利弊。

在下一章也是最后一章中,我们将学习如何使用 MobX 处理状态。我们将学习什么是 MobX,以及如何用 React 的传统方式使用它。然后,我们将学习如何使用带有挂钩的 MobX,我们还将了解如何将现有 MobX 应用迁移到基于挂钩的解决方案。

问题

为了回顾我们在本章学到的知识,请尝试回答以下问题:

  1. Redux 应该用于什么样的状态?
  2. Redux 由哪些元素组成?
  3. Redux 的三个原则是什么?
  4. 为什么我们要定义动作类型?
  5. 我们如何将组件连接到 Redux?
  6. 我们可以在 Redux 中使用哪些挂钩?
  7. 我们为什么要创建可重用的选择器?
  8. 我们如何迁移 Redux 应用?
  9. Redux 的权衡是什么?
  10. 我们什么时候应该使用 Redux?

进一步阅读

如果您对我们在本章中所学概念的更多信息感兴趣,请阅读以下阅读材料: