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

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

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

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

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

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

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

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

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

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

先来解释一些 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
  };
};

输出就会是:

考虑到这一点,我们本质上只是访问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 文件夹

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

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

动作

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 项目,就会在控制台看到以下内容:

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

switch (action.type) {
    case ADD_ITEM:
      state = {
        list: [...state.list, action.payload]
      };
      return state;

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

现在请记住,这个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 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/0pmabL4ZVvVVzwTlVPVZ

扫码关注云+社区

领取腾讯云代金券