对Redux一头雾水?看完这篇就懂了
作者|Sunil Sandhu译者|王强编辑|王文婧前些日子,我们翻译了一篇 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)更新的。这些基本上就是我们发送新版本状态的机制。可能有点不知所云,我们详细说明一下。假设我们有一个存储对象,它的数组看起来像这样:list: [{‘id: 1, text: ‘clean the house’}]
。如果我们有一个将新项目添加到数组中的函数,那么我们的减速器将向存储解释新版本的存储具体是什么样子的。因此考虑这个list
数组的情况,我们就会获取list
的内容,并通过...
语法将其与要添加的新项目一起传播到新的 list 数组中。因此,我们用来添加新项目的 reducer 应该是这个样子的:list: [...list, newItem]
。所以前面我们说要为存储创建状态的新副本,而不是将新项目推送到现有的存储上,就是这个意思。
现在,为了让减速器知道要放入哪些新数据,他们需要访问负载(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 应用以你想要的方式工作。但它是可选的,如果你不想安装也没问题。
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 应用来说,我们需要三个功能:- 保存输入数据的功能;
- 添加项目的功能;
删除项目的功能。
现在,第一个功能(保存输入数据)实际上将在 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 的最佳实践,因为它为所有 动作类型 提供了一个中心位置,从而减少了我们需要更新的位置数量。鉴于减速器也将使用这些位置,因此我们可以确信名称总是正确的,毕竟它们都是来自于同一来源。下面来谈 减速器。
减速器这里有两个部分: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
是添加和删除函数。
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。
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
活动推荐用更低的成本带来用户更好的体验,是大前端的技术的演进主流思路之一。动态化、跨平台技术为降低研发成本,提高迭代效率带来可观的收益;前端中台、业务抽象复用为前端工程化指明了方向······更多大前端趋势解读尽在 ArchSummit 全球架构师峰会(北京站)2019。
目前 9 折限时直降 880 元!了解详情请联系票务经理灰灰:15600537884 (同微信)。