对 Redux 一头雾水?看完这篇就懂了

前些日子,我们翻译了一篇 React 和 Vue 的对比文章:《我用 React 和 Vue 构建了同款应用,来看看哪里不一样》。最近,文章作者又撰写了一篇与 Redux 对比的后续,我们也翻译了这篇续文以飨读者。

首先,学习 Redux 可能会很困难

当你终于学会了如何使用 React,也有了自己去构建一些应用的信心,那会是一种非常棒的感觉。你学会了管理状态,一切看起来井井有条。但是,很有可能这就到了你该学习 Redux 的时候了。

这可能是因为你正在开发的应用变得越来越大,你发现自己在到处传递状态,还需要一种更好的方法来管理数据。或者也可能是,你发现一大堆招聘信息都写着除了要会 React 以外,还得会 Redux。不管是哪种原因,了解如何使用 Redux 都是非常重要的知识,因此你应该努力去掌握它。

但是要搞懂 Redux 的原理就得研究一大堆新的代码,实在很让人头痛。我个人还觉得常见的文档(包括 Redux 的官方文档)展示的 Redux 用法实在太多了,所以入门起来真的不容易。

从某种意义上说,这是一件好事,因为它鼓励你以自认为合适的方式使用 Redux,不会有人跟你说“你应该用这种方法来做,否则你这开发者就太逊了”。但拥有这种美好的感觉的前提是,你得知道自己到底在用 Redux 做些什么事情。

那么我们该怎样学习 Redux 呢?

在我之前对比 React 和 Vue 的文章中,使用了名为 ToDo 的一款待办事项列表应用做了演示。本文会继续使用这种方法,只不过这次的主角换成了 Redux。

图0:对 Redux 一头雾水?看完这篇就懂了

下面是 Redux 应用的文件夹结构,左边是 React 版本的对比。

图1:对 Redux 一头雾水?看完这篇就懂了

先来解释一些 Redux 的基础知识

Redux 基于三大原则来处理数据流:

1. 存储

存储(Store)也被称为单一可信源(single source of truth)。它在本质上只是你以某种状态初始化的对象,然后每当我们要更新它时,我们都会用新版本覆盖原有的存储。总之,你可能已经在 React 应用中用到了这些理论,通常人们认为最佳实践是重新创建状态而不是突变它。为了进一步解释这种区别我们举个例子,如果我们有一个数组,并且想要将一个新项目推送进去,我们更新存储时不会直接把新项目塞进去,而是会用包含新项目的数组新版本覆盖原来的存储。

2. Reducer

于是,我们的存储是通过“减速器”(Reducer)更新的。这些基本上就是我们发送新版本状态的机制。可能有点不知所云,我们详细说明一下。假设我们有一个存储对象,它的数组看起来像这样:list: [{‘id: 1, text: ‘clean the house’}]。如果我们有一个将新项目添加到数组中的函数,那么我们的减速器将向存储解释新版本的存储具体是什么样子的。因此考虑这个 list 数组的情况,我们就会获取 list 的内容,并通过…语法将其与要添加的新项目一起传播到新的 list 数组中。因此,我们用来添加新项目的 reducer 应该是这个样子的:list: […list, newItem]。所以前面我们说要为存储创建状态的新副本,而不是将新项目推送到现有的存储上,就是这个意思。

3. 动作

现在,为了让 Reducer 知道要放入哪些新数据,他们需要访问负载(payload)。这个负载通过所谓 ” 动作 “(Action)的操作发送到减速器。就像我们创建的所有函数一样,动作通常可以在应用的组件内通过 props 访问。因为这些动作位于我们的组件中,所以我们可以向它们传递参数——也就是负载。

理解上述内容后,我们就可以这样理解 Redux 的工作机制了:应用可以访问动作。这些动作会携带应用数据(通常也称为有效负载)。动作具有与减速器共享的类型。每当动作类型被触发时,它就会拾取负载并通知存储,告诉后者新版存储应该是什么样的——这里我们指的是数据对象在更新后应该是什么样子。

Redux 的理论模型还有其他内容,例如动作创建者和动作类型等,但是“To Do”应用不需要那些元素。

这里的 Redux 设置可能是你学习它的一个很好的起点,当你更加熟悉 Redux 后,你可能会想要更进一步。考虑到这一点,尽管我前面说过 Redux 文档可能让人有点不知所措,但是当你要创建自己的设置时,应该好好看看那些文档介绍的所有不同方法,作为你灵感的源泉。

将 Redux 添加到 React 应用。

于是我们还是用 Create React App 创建 React 应用,方法都是一样的。然后使用 yarn 或 npm 安装两个包:redux 和 react-redux,然后就可以开始了!还有一个称为 redux-devtools-extension 的开发依赖项,它可以确保你的 Redux 应用以你想要的方式工作。但它是可选的,如果你不想安装也没问题。

下面具体解释下这些样板是做什么的

首先查看应用的根文件 main.js:

	
import React from "react";

import ReactDOM from "react-dom";

import { Provider } from "react-redux";

import configureStore from "./redux/store/configureStore";

import App from "./App";

const store = configureStore();

ReactDOM.render(

  <Provider store={store}>

    <App />

  </Provider>,

  document.getElementById("root")

);

在这里,我们有五个 import。前两个是用于 React 的,我们不会再讨论它们;而第五个导入只是我们的 App 组件。我们将重点关注第三和第四个导入。第三个导入是 Provider,本质上是通向我们 Redux 存储(前文所述)的网关。它的具体工作机制更复杂些,因为我们需要选择要访问存储的有哪些组件,稍后我们将讨论其原理。

如你所见,我们用 Provider 组件包装了 App/ 组件。从上面的截图中,你还会注意到,我们的 Provider 带了一个存储 prop,我们将 store 变量传递进这个 prop。第四个导入 configure-Store 实际上是我们已经导入的函数,然后将其输出返回到 store 变量,如下:const store = configureStore();。

现在你可能已经猜到,这个 configureStore 基本上就是我们的存储配置。这包括我们要传递的初始状态。这是我们自己创建的文件,稍后我将详细介绍。简而言之,我们的 main.js 文件会导入存储,并用它包装根 App 组件,从而提供对它的访问。

然而还需要更多样板,所以我们往上走,看看根 App 组件中的其他代码:

	
import React from "react";

import { connect } from "react-redux";

import appActions from "./redux/actions/appActions";

import ToDo from "./components/ToDo";

import "./App.css";

const App = (props) => {

  return <ToDo {...props} />;

};

const mapStateToProps = (state) => {

  return {

    list: state.appReducer.list

  };

};

const mapDispatchToProps = {

  ...appActions

};

export default connect(

  mapStateToProps,

  mapDispatchToProps

)(App);

于是我们有另一个包含五个导入的文件。第一个是 React,第四个是 React 组件,第五个是 css 文件,因此我们不必再讨论它们了。还记得前面提到的如何为组件提供对存储的访问权限吗?这就是第二个导入,connect 的用途。

查看上面的代码,你会看到,我们导出的不是 App 组件而是 connect,这基本上是一种咖喱函数。咖喱函数本质上是返回另一个函数的函数。connect 在这里所做的是获取 mapState-ToProps 和 mapDispatchToProps 的内容,然后获取 App 组件,并将 mapStateToProps 和 mapDispatch-ToProps 的内容添加到其中,最后返回带有新功能的 App 组件。大概就是这样,但是 mapStateToProps 和 mapDispatch-ToProps 这些东西的内容是什么呢?

其实 mapStateToProps 从存储中获取状态,并将其向下传递为连接的 App 组件的 prop。本例中我们给它赋予 list 键,因为它遵循了我们在存储内部指定的命名约定。不过我们不需要遵循此约定,而且可以随意调用它——总之,只要我们要访问这部分状态,我们在应用中要引用的内容就是 list。现在你知道了 mapStateToProps 是一个将 state 作为参数的函数。本例中,state 就是我们的 store 对象。作为参考,如果我们将 console.log(‘store’,store) 放在 mapStateToProps 内,

	
const mapStateToProps = (state) => {

  return {

    list: state.appReducer.list

  };

};

输出就会是:

图2:对 Redux 一头雾水?看完这篇就懂了

考虑到这一点,我们本质上只是访问 store 的某些部分,并通过 props 将这些部分附加到 App 中。本例中,我们可以从控制台看到我们的状态是一个名为 appReducer 的对象,其中包含一个 list 数组。因此,我们通过 mapStateTo-Props 函数将其附加到 App 组件上,该函数返回一个具有 list 键和 state.appReducer.list 值的对象。看起来都很啰嗦还让人头晕,但希望这些内容能帮助你理解背后的逻辑。

那么 mapDispatchToProps 呢?这里就要提到 App.js 文件中的第三个导入,即 appActions。这是我们创建的另一个文件,稍后将深入研究。现在只需知道 mapDispatchToProps 是一个普通对象,它将获取我们将要创建的 动作 并将它们作为 props 传递到我们连接的 App 组件中。用 Redux 术语来说,Dispatch 指的是对一个动作的分派,也就是我们正在执行一个函数的优美的说法。因此 mapDispatchToProps 就像 mapFunctionsToProps 或 mapActionsToProps。但是 React 文档将其称为 mapDispatch-ToProps,因此我们在这里遵循这条命名约定。

这里要提醒一件事:在一个较大的典型 React 应用中,mapStateToProps 函数在要返回的对象内部可能有许多不同的键 / 值对。这也可能来自 Redux 应用中 store 的许多不同的 reducer,因为如果需要,你可以为存储提供访问点。这同样适用于 mapDispatchToProps;虽然我们简单的 To Do 应用只有一个文件来处理动作(appActions),但较大的应用可能有多个文件来处理针对应用各个部分的动作。你的 mapDispatchToProps 文件可能会从许多位置获取动作,然后将它们作为 props 传递到你的 App 组件。同样,你需要自己决定该怎样组织你的应用。

我们已经研究了从 Redux 溢出到根文件中的主要样板,现在来看一下 Redux 文件夹中的情况,最后再谈如何将它们全部整合到我们的 React 子组件内部(包括所有非根 App.js 组件的内容)。

Redux 文件夹

这里有很多内容要讲。首先再看一下应用的文件结构:

图3:对 Redux 一头雾水?看完这篇就懂了

我们将按照上面截图中的文件顺序来讨论。

动作

	
import { ADD_ITEM, DELETE_ITEM } from "../actionTypes";

const redux_add = (todo) => ({

  type: ADD_ITEM,

  payload: todo

});

const redux_delete = (id) => ({

  type: DELETE_ITEM,

  payload: id

});

const appActions = {

  redux_add,

  redux_delete

};

export default appActions;

actions/appActions.js
如前所述,appActions 就是我们导入到 App.js 中的文件。其中包含从应用中携带数据(也称为负载)的函数。对于这里的 To Do 应用来说,我们需要三个功能:

1. 保存输入数据的功能;
2. 添加项目的功能;
3. 删除项目的功能。

现在,第一个功能(保存输入数据)实际上将在 ToDo 组件内部本地处理。我们也可以选择用“Redux 方式”来处理,但我想强调的是并不是所有事情都必须通过 Redux 来做,如果你觉得使用 Redux 没什么意义,那就用不着它。本例中,我只想在组件级别处理输入数据,同时在中央级别使用 Redux 维护实际的“待办事项”列表。因此继续介绍所需的其他两个功能:添加和删除项目。

这些功能只是获取负载而已。为了添加新的待办事项,我们需要传递的负载就是新的 To Do 项目,因此我们的函数最终看起来像这样:

	
const redux_add = (todo) => ({

  type: ADD_ITEM,

  payload: todo

})

appActions.js
在这里,该函数有一个参数,我用它调用 todo,并返回一个具有 type 和 payload 的对象。我们将 todo 参数的值分配给 payload 键。你可能已经注意到了,这里的 type 实际上是从 actionTypes 文件夹中导入的变量——稍后会具体介绍动作类型。

我们还有 redux_delete 函数,该函数将 id 作为其负载,以便让减速器知道要删除哪个 To Do 项目。最后,我们有一个 appActions 对象,该对象将 redux_add 和 redux_delete 函数用作键和值。这也可以写成:

	
const appActions = {

    redux_add: redux_add,

    redux_delete: redux_delete

}

你可能觉得这样更好。另外要说的是,这里的命名不是唯一的,例如 appActions 和函数前缀 redux_,这只是我自己的命名约定。

动作类型

	
export const ADD_ITEM = "ADD_ITEM";

export const DELETE_ITEM = "DELETE_ITEM";

actionTypes/index.js
你可能还记得前文提到过的一种情况,那就是减速器和动作可以通过一种方式知道如何与彼此交互——这就是 类型(type) 的用途。我们的 减速器 也将访问这些 操作类型。如你所见,这些只是变量,其名称与其要分配的字符串相匹配。

这部分并不是必需的,你可以根据需要完全避免创建这个文件和模式。但这是 Redux 的最佳实践,因为它为所有 动作类型 提供了一个中心位置,从而减少了我们需要更新的位置数量。鉴于减速器也将使用这些位置,因此我们可以确信名称总是正确的,毕竟它们都是来自于同一来源。下面来谈 Reducer。

Reducer

这里有两个部分:appReducer 和 rootReducer。在较大的应用中,你可能有很多不同的减速器。这些都将被拉入你的 rootReducer 中。在本例中,考虑到我们的应用很小,我们可以只用一个减速器来处理。但我决定用两个,因为你可能会习惯这种做法。另外这里的命名都是我的习惯,你可以给自己的减速器随意取名。

下面来看看 appReducer:

	
import { ADD_ITEM, DELETE_ITEM } from "../actionTypes";

const initialState = {

  list: [{ id: 1, text: "clean the house" }, { id: 2, text: "buy milk" }]

};

export default function appReducer(state = initialState, action) {

  switch (action.type) {

    case ADD_ITEM:

      state = {

        list: [...state.list, action.payload]

      };

      return state;

    case DELETE_ITEM:

      state = {

        list: state.list.filter((todo) => todo.id !== action.payload)

      };

      return state;

    default:

      return state;

  }

}

reducers/appReducer.js
首先我们看到,我们正在导入之前 动作 用过的 动作类型。接下来的是 initialState 变量,它是状态。这就是我们用来初始化存储的方式,以便我们有一些初始状态。如果你不需要任何初始状态,则可以在自己的项目中用一个空对象——同样,具体项目具体分析。

接下来是 appReducer 函数,它带有两个参数:第一个是 state 参数,这是我们开始的状态。在本例中,我们使用默认参数将第一个参数默认为 initialState 对象。这样就不必再传递任何内容了。第二个参数是 action。现在,每当触发 appActions.js 文件中的一个函数时,就会触发这个 appReducer 函数——稍后讨论如何触发这些函数,但现在我们只知道这些函数最终会在 ToDo.js 文件中结束。总之,每次触发这些函数时,appReducer 都会运行一系列 switch 语句,来查找与传入的 action.type 匹配的语句。为了了解被触发的数据长什么样, 这里 console.log 出我们的 action,如下所示:

	
export default function appReducer(state = initialState, action) {

  switch (action.type) {

    case ADD_ITEM:

      state = {

        list: [...state.list, action.payload]

      };

      return state;

    case DELETE_ITEM:

      state = {

        list: state.list.filter((todo) => todo.id !== action.payload)

      };

      return state;

    default:

      return state;

  }

}

现在的应用中,假设我们在输入字段中输入“take out the trash”并按 + 按钮来创建新的 To Do 项目,就会在控制台看到以下内容:

图4:对 Redux 一头雾水?看完这篇就懂了

现在除了负载外,我们可以看到 action 还有“ADD_ITEM”的 type。这与 switch 语句具有的 ADD_ITEM 变量匹配:

	
switch (action.type) {

    case ADD_ITEM:

      state = {

        list: [...state.list, action.payload]

      };

      return state;

当存在匹配项时,它将执行此操作,告诉存储应如何设置其新状态。在本例中,我们要告诉存储,状态现在应该等于一个 list 数组,其中包含之前的 list 数组的内容以及传入的新 payload,再看看控制台的内容:

图4:对 Redux 一头雾水?看完这篇就懂了

现在请记住,这个 action 带有负载——这部分由我们在 appActions.js 中看到的动作处理。我们的 减速器 会根据 action.type 匹配的内容来选择 动作 并处理。

现在看一下 rootReducer:

	
import { combineReducers } from "redux";

import appReducer from "./appReducer";

const rootReducer = combineReducers({

  appReducer

});

export default rootReducer;

reducers/index.js
第一个导入是 combineReducers。这是一个 Redux 辅助函数,它收集了你的所有减速器并将它们变成一个对象,然后可以将其传递给 store 中的 createStore 函数,稍后具体介绍。第二个导入是我们先前创建和讨论的 appReducer 文件。

如前所述,我们的应用非常简单,因此实际上并不需要这个步骤。但为了学习的目的,我决定保留这一步。

存储

然后看一下 configureStore.js 文件:

	
import { createStore } from "redux";

import rootReducer from "../reducers";

export default function configureStore() {

  return createStore(rootReducer);

}

store/configureStore.js
这里的第一个导入是 createStore,它保存你应用的完整状态。你只能拥有一个存储。你可以有许多具有自己 initialState 的减速器。关键是要了解这里的区别,尽管本质上你可以拥有许多提供某种形式状态的 减速器,但是你只能有一个 存储 从 减速器 中提取所有数据。

这里的第二个导入是 rootReducer,之前已经介绍过。你将看到创建了一个名为 configure-Store 的简单函数,该函数将 createStore 导入作为函数返回,这个函数将 rootReducer 作为其唯一参数。

同样,这部分也可以跳过去,只需在根 index.js 文件中创建存储即可。我之所以保留在这里,是因为你可能需要为 存储 做许多配置,从设置中间件到启用其他 Redux 开发工具等。这种情况非常典型,但现在全介绍一遍太啰嗦,因此我从 configureStore 中移除了这个应用不需要的内容。

好的,现在我们已经在 Redux 文件夹中设置好了所有内容,并将 Redux 连接到了 index.js 文件和根 App.js 组件。下面该做什么呢?

在应用中触发 Redux 函数

现在快大功告成了。我们已经完成了所有设置,连接的组件可以通过 mapStateToProps 访问存储,还可以通过 mapDispatchToProps 作为 props 访问动作。我们访问这些 props 的方法和 React 中的常见做法一样,下面仅供参考:

	
const ToDo = (props) => {

  const { list, redux_add, redux_delete } = props;

ToDo.js
这三个 props 与我们传入的相同:list 包含 state,而 redux_add 和 redux_delete 是添加和删除函数。

然后,我们按需使用它们即可。在本例中,我用的函数与我之前的 vanilla React 应用中用过的一样,区别只是这里使用 useState hook 通过某种 setList() 函数在本地更新状态。我们用所需的负载调用 redux_add 或 redux_delete 函数。具体来看看:

	
const createNewToDoItem = () => {

    //validate todo

    if (!todo) {

      return alert("Please enter a todo!");

    }

    const newId = generateId();

    redux_add({ id: newId, text: todo });

    setTodo("");

  };

新增项目

	
const deleteItem = (todo) => {

    redux_delete(todo.id);

  };

删除项目
看一下 deleteItem 函数,过一遍更新应用状态的各个步骤。

redux_delete 从我们要删除的 To Do 项目中获取 ID。

看一下 appActions.js 文件,会看到传入的 ID 成为 payload 的值:

	
const redux_delete = (id) => ({

  type: DELETE_ITEM,

  payload: id

});

appActions.js
然后我们在 appReducer.js 文件中看到,只要在 switch 语句中命中 DELETE_ITEM 类型,它就会返回状态的新副本,该副本具有从负载中滤出的 ID:

	
case DELETE_ITEM:

      state = {

        list: state.list.filter((todo) => todo.id !== action.payload)

      };

      return state;

appReducer.js
随着新状态更新完毕,我们应用中的 UI 也会更新。

Redux 研究完成!

我们已经研究了如何将 Redux 添加到 React 项目、如何配置存储、如何创建携带数据的动作以及如何创建用于更新存储的减速器。我们还研究了如何将应用连接到 Redux,以便访问所有的组件。我希望这些内容能帮到你,并让你更好地理解 Redux 应用的模样。

本文示例应用的 GitHub 链接: https://github.com/sunil-sandhu/redux-todo-2019

原文链接:
https://medium.com/javascript-in-plain-english/i-created-the-exact-same-app-with-react-and-redux-here-are-the-differences-6d8d5fb98222

本文文字及图片出自 InfoQ

余下全文(1/3)
分享这篇文章:

请关注我们:

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注