从零开始的react入门教程(八),redux起源与基础用法
壹 ❀ 引
我们在从零开始的react入门教程(七),react中的状态提升,我们为什么需要使用redux一文中介绍了react的状态提升,对于不同组件之间状态需要通信时,将状态提升至两个组件共有的最近父组件进行管理是官方较为推荐的做法。
但随之暴露的问题也非常明显,如果组件层级跨越较大,遥远的父组件将状态通知到目标组件时,可能需要通过多层组件进行数据传递,对于这些组件而言,数据传递很明显是没意义的。其次,父组件也可能会与其它组件通讯,所以它们也会寻找自己的父级进行状态管理,如果每个管理状态的组件是一个节点,你会发现此时的状态就像一张从上往下编制的蜘蛛网,而站在状态自身而言,数据可能来自UI,可能来自服务器,也可能是组件产生的临时数据等等,如何能按预期进行状态管理确实是一个头疼的问题。
在文章的结尾,我们引出了全局状态管理redux,相对分散去管理分布在各处的state,redux将提供一个全局的状态容器store,你在组件某处的状态更新会同步更新到store,而store的变化又会对应去更新需要感知状态变化的组件。这样说可能会有点宏观与模糊,没关系,我们会在后面解释它到底是怎么一个运转过程,至于redux与状态提升的运转差异,大家可能通过上篇文章结尾的图片对比直观感受到两者差异。
最后需要强调的是,虽然redux提供了全局状态管理,但事实上我们并不会将所有的状态都塞到store中,不然对于一个项目而言,你能想象到这样一个store能有多庞大,所以即便拥有store,我们真正去管理的也是那些需要全局通信,服务器交互以及多数据源相互影响类似的状态。
正如redux官方所言,不要为了用redux而用redux(You Might Not Need Redux),细化到组件中一个小小的状态,我们也不要因为有store的存在而滥用这份特权,对于状态通信本身就十分简单的场景,使用状态提升,函数回调或者emitter事件机制其实都是不错的备选方案,这一点在我们学习redux之前一定得注意。
最后的最后,假设你是redux初学者,如果你百度文档可能会发现redux以及react-redux,不用诧异,redux本身就是JavaScript的状态管理器,react-redux本质上是更好为react服务的定制库,思想相同,所以不用太疑惑我到底应该学谁,那么本文正式开始。
贰 ❀ redux起源
贰 ❀ 壹 从MVC说起
准确来说,redux这个词由 reducer+Flux 组合构成,这是因为redux在设计上借鉴了Flux部分核心思想并做了进一步的改进(Flux的作者也夸redux非常棒,所以没必要踩一捧一),当然因为历史问题,现在大家接触react首先想到的自然是redux而非Flux,在这里本文不会详细介绍Flux的用法,但还是会简单提及基本概念,回溯一下框架变迁过程。
任何接触过前端框架的同学,我想对于MVC框架一定不会陌生,其中M对应Modle也就是数据模型层,V对应view视图层,而C对应Controller控制器,作用是保证M与V的同步,一个简单的模型如下:
你会发现,理想状态下的MVC也是一个单向且闭环的运转过程,Controller会接收用户行为中传递的数据,并根据行为调用Model中的业务逻辑,与后端进行交互并对数据层做更新,而数据层的变化也会通知到View层让其重新渲染,这样用户就得到了行为反馈。
但比较遗憾的是,比如博主在早期使用angularjs的过程中,就存在很多View与Model直接通讯的场景,说直白点,修改Model不再是Controller的特权,View也成了修改Model的源头之一,比如大家熟知的双向数据绑定。这听起来好像也什么太大的影响,图示也仅是如此:
但站在整个项目层面,这个流转过程可能就是这样:
这样看来就比较混乱了,你甚至都不知道一个Model到底有多少个源头会影响到它,如果你刚入职一家公司接受了这个项目,我敢保证在初期开发过程中你会特别痛苦,因为你根本不知道会在未知的情况下写出怎样的bug,毕竟Model与View之间的关系缠绕的过于复杂了。
贰 ❀ 贰 Flux的诞生
或许说到这里,你对于react的单向数据流的好感会增加不少,严格把控控制数据变化的源头,面对复杂的数据交互场景确实能省不少事,而redux也只是增强了react中对于复杂状态管理的便捷性。当然这些观点都是我们站在多年之后对于业界巨佬推进框架发展所享受到便利的直观感受。让我们回到8年前也就是2013年,Flux框架的诞生了。
上图便是Flux完整的数据流,与MVC相比,最直观的差异在于,不管你是用户行为发起的数据更新,还是由视图层发起的视图更新,你都发起一个action
,而它们最终都汇总到了Dispatcher
并做了后续的数据更新操作。也就是说不管你的更新行为在哪,我都可以保证你们会执行相同的更新操作,这是一个单项的数据更新闭环。
而MVC不同在于,你可能在controller
到model
定义了部分更新规则,但站在双向数据绑定的视角,可能有多处视图都会更新同一处数据,如果保证它们之间的更新不会对彼此造成影响,比如我希望数据一致是有效的非假值,但你在某个视图用了双向绑定不小心把这个数据更新成了undefined
,然后你发现一个相隔十万八千里的组件渲染居然直接报错了,错综复杂的数据更新就像一张难以梳理的蜘蛛网,这就相当麻烦了(这里只是凸显单项数据流的特点,更严格的数据更新方式,双向数据绑定在某些场景使用起来也会特别便捷,比如表单,大家都有各自的优点)。
OK,让我们一一解释上图中名词的含义(注意,以下代码并不会正确执行,只是了解Flux数据更新的一个大概过程):
- Action,直观理解就是用户行为,它承载两个部分,一是行为类型,比如操作一个数字你是希望增加还是减少它,第二是伴随类型传递过去的值,你希望增加多少,它是
dispatcher
的驱动者:
// 行为类型,以及行为附带的数据
{
actionType: 'ADD_TODO',
text: 1
}
- Dispatcher(可以理解为MVC中的controller),管理动作的分发中心,将动作同步给Store,这样Store才能知道你此时行为是要做什么,行为附带的数据是什么,从而更新调用Store中对应的方法,达到更新状态的目的。Dispatcher结合Action是这样:
import {Dispatcher} from 'flux';
const AppDispatcher = new Dispatcher();
const TodoActions = {
update: function(text) {
// 派发的参数其实就是上面的action,包含了行为类型与数据
AppDispatcher.dispatch({
actionType: 'UPDATE_TODO',
text: text
});
}
};
正常项目看法中,一个action.js
文件就像上面代码这样,定义了多种action
函数。为了防止大家对于Dispatcher与Action的关系犯迷糊,我们简单来理一理上面这个函数,代码其实是定义了一个叫TodoActions
的对象,里面暴露了多个对View的接口,说直白点就是给View调用的方法,比如用户点击更新按钮,对应调用的其实是TodoActions.update()
,update
行为本质触发的其实是AppDispatcher.dispatch({...})
,做了一次动作派发,参数呢是一个action
对象,包含了我的行为类型是什么,传递的值又是什么,这样Store接受到了就能根据type以及value对应的去做状态更新操作。
- Store(对应MVC中的model),包含了状态数据以及更新状态的逻辑,不同的Store管理应用中不同的状态(注意,Flux中允许存在多个Store),比如:
import {Dispatcher} from 'flux';
const AppDispatcher = new Dispatcher();
var _todos = {};
// 更新的逻辑
function update(id, updates) {
_todos[id] = assign({}, _todos[id], updates);
};
// 这里的register也是官方提供的注册方法
AppDispatcher.register(function (action) {
var text;
// 根据action类型,调用对应的更新方法
switch (action.actionType) {
case 'UPDATE_TODO':
text = action.text.trim();
if (text !== '') {
update(action.id, { text: text });
}
break;
}
});
比如用户点击了一个更新按钮,触发了我们自己绑定的click
事件,事件里面真正调用的其实是TodoActions.update(数据)
,对应的就是dispatcher
,说白了就是派发了一个更新行为,派发出去后store
里面就监听到了,当然它肯定不知道这具体是个什么行为,于是根据行为的actionType
来判断我到底要做什么操作,根据类型判断后成功得知是更新操作,于是拿着传递来的新数据,调用了store
里面定义好的更新数据的方法。
- View,视图层,我们需要为组件上绑定一些交互方法(比如上面说到的click方法),为更新Store提供一个入口。
onClickUpdate() {
TodoActions.update({...});
}
<button onClick={this.onClickUpdate}>更新</button>
那么介绍到这里,我们综合上面介绍的概念,可以理解为用户操作了视图的某个交互功能,调用了绑定好的行为事件,此事件派发(dispatcher)了一个行为(action),然后被store监听到,对应的做了更新数据的操作,数据更新后又被反馈回view,完成了一次单项数据流的更新操作。所以综合来说,Flux的单向数据流执行过程便是Action --> Dispatcher --> Store --> View。
相对于MVC中view
直接和Model
通信,Flux
增加的就是以Action
来限制改变数据的源头,不管是谁引发的,你都得通过唯一途径做到状态更新,所以在多年前,Flux被吐槽是数据源把控更严格的MVC框架,伴随后续redux
的诞生,时间已证明这种思想的伟大之处。
那既然Flux
已经做的足够优秀,为何最终又诞生了redux
呢?其实对于上述所言的Flux
也存在一些小缺点。我们在前面说Flux支持定义多个store
,可能一个组件就有一个与之对应的store
(store掌管了组件的数据,以及更新数据的方法),虽然Flux解决了View与Model直接通信的问题,但它又产生了多个Store
之间相互依赖的问题,组件只要数据需要交互,那么彼此之间的store
必定会产生联系,外加上Store
更新还会存在异步以及谁先谁后的问题,这样一想好像数据交互又变得有些麻烦。
其次,我们可以看到Store
虽然作为数据管理层,但在Store
中其实包含了需要处理数据的所有逻辑,如果需要修改Store
的逻辑那么对应Store
状态也得进行完整的修改。但这些问题,都会在redux
中得到改善。
贰 ❀ 叁 redux的三大准则
在Flux
诞生的1年后,Dan Abramov在Flux的基础上创造了redux
,在同样满足单向数据流的基础上,redux
更提出了另外的三个准则:
- 单一数据源。
- 保持状态只读。
- 数据改变只能通过纯函数完成。
我们来解释下这三点准则,首先是唯一的数据源,相比Flux中使用多个Store
管理不同组件的状态,redux提倡使用唯一的Store
管理所有需要管理的State。理论上来说,redux
中的Store
是一个对象树,你在某个组件的的State
就像是这颗树的一个小分支,一片叶子。
关于状态只读,redux
中的状态只读不是说不让改,而是说修改只能通过派发action
的形式去更改,而且说是修改,其实是在原有状态的基础上返回一个新的状态给界面渲染。
对于第三点,这里所说的纯函数也就是reducer
了,这也是redux
数据流转里的一个重要概念,而所谓的纯函数,其实是只入参相同的情况下,不管我们调用几次,得到的必然是相同的结果,比如在redux中一个reducer
可能是这样:
reducer(state,action)
它接受当前的state以及一个action
并返回一个新的状态出去。而在Flux
中,状态更新逻辑是放在store
中做的处理,而且是直接修改原数据。但在redux
中,这部分更新操作将全权交给reducer
。
叁 ❀ redux基本用法
在使用redux
之前,还是让我们了解redux
的数据流:
与Flux
的数据流相比,你会发现大部分单词其实仍然相同,只是dispatcher
被替换成了reducer
,我们还是来一一解释这些名词,毕竟相对于Flux
而言,redux
的处理还是有一点变化。
- Action,与
Flux
的action含义相同,表示用户的行为,同样包含两个部分,行为类型以及行为附带的数据。
const action = {
type:'add',
payload:'1'
}
在Flux
中,dispatcher
扮演了派发action
的角色,然后store
接受行为与数据,并在store
中进行数据更新,而前面我们也说了,在redux
中数据更新的行为由reducer
来负责,问题来了,redux
中谁来派发action
呢?其实是有单独的API来完成这一操作,比如:
// 三方库redux提供的创建store的API
import {createStore} from 'redux';
// 这个Reducer.js是我们自己定义的状态更新方法
import reducer from 'Reducer.js';
// 假设这是初始化的状态
const state = 1;
// 依据初始化的状态以及状态的更新方法reducer来创建一个store
const store = createStore(reducer, state);
// store就包含了派发行为API dispatch
store.dispatch({
type:'add',
payload:'1'
})
也就是说,在Flux
中会专门引入Dispatcher
,以及在自定义的dispatcher.js
文件中定义每个派发不同action
类型的函数,redux
中虽然没有明确引用Dispatcher
这一行为,但本质上还是一样存在dispatch
这一操作,都是要走派发这一步的。
- Reducer,负责状态(数据)的更新,并返回一个全新的状态,在Flux中这一部分本来是
Store
负责,不过在redux
中可以理解成这部分的逻辑被抽离成专门的reducer
文件。
const reducer = (state, action) => {
switch (action.type) {
case 'add':
// 不是直接修改原状态,而是在原状态基础上直接返回一个新的状态
return state + action.payload;
default:
return state
}
}
// 得到一个全新的状态
const newState = reducer(1, {
type: 'add',
payload: 1
});
当然实际情况下,reducer
并不需要像上面这样手动调用执行,在上面介绍action
的代码中有一句const store = createStore(reducer, state);
这里的reducer
其实就是上面我们定义的reducer
函数了,通过createStore
方法,我们将如何更新状态的逻辑成功与初始状态进行了绑定,并得到了redux
中数据流中的store
。所以当我们调用store.dispatch(action)
时,reducer
会自动执行更新操作,至于原理是什么我们现在可以先不了解。
-
Store,在
Flux
中它包含了数据源,以及更新数据的逻辑。而在redux
中它同样表示数据,以及提供了行为派发以及监听等部分API,这里列举一下:store.dispatch
,用于action的派发,它会自动执行reducer,达到更新状态的目的。store.subscribe
,reducer更新并返回了新的状态,view怎么感知到变化然后重新渲染了呢?那么就得在view层使用这个方法了,比如在方法内部定义更新state的操作,这样只要reducer返回了新的状态,store.subscribe
就会执行,这样内部就可以执行this.setState
方法用于设置新的状态,state一变react自然也会渲染。store.unsubscribe
,与上面对应的一个方法,我们一般在componentDidMount
中执行store.subscribe
的监听,那么同理,我们会在componentWillUnmount
中执行store.unsubscribe
方法用于卸载前监听的方法。store.getState
,用于获取当前状态的方法,比如结合store.subscribe
我们就可以像下面这样:
onChange(){ this.setState(store.getState()) } componentDinMount(){ store.subscribe(this.onChange) } componentWillUnmount(){ store.unsubscribe(this.onChange) }
我们大致将redux
几个核心概念,以及需要使用的API介绍了一遍,接下来我们来使用redux
实现一个最简单的计数小组件,通过感受代码加深对于redux
的理解。
这里假设大家已经在本地安装了create-react-app
项目,若没有请参考从零开始的react入门教程(一),让我们从hello world开始一文中的准备工作。
以下文件均在src
目录下创建即可,首先我们来定义Action
,需要注意的是Action
分为两个文件来定义,一个文件专门用来定义type类型,一个文件用于定义生成action
对象的方法。那为啥type要单独定义呢?因为type除了action
要用之外,Reducer
也得根据type来执行不同的状态更新操作,单独抽离出来统一管理的好处是,如果你要修改type类型只用改一次,便于管理。
OK,我们先创建一个Actiontypes.js
文件,内容如下:
export const INCREMENT = 'increment';
export const DECREMENT = 'decrement';
再创建一个Actions.js
文件,用于存放派发相关的代码,内容如下:
// 这就是上面定义的type类型
import * as ActionTypes from './ActionTypes.js';
export const increment = (counterCaption) => {
return {
type: ActionTypes.INCREMENT,
counterCaption: counterCaption
};
};
export const decrement = (counterCaption) => {
return {
type: ActionTypes.DECREMENT,
// 当key与value同名时,可以简写成
counterCaption
};
};
Action
相关创建完毕,接下来我们定义Reducer
,还是在此目录下创建一个Reducer.js
文件,内容如下:
// 这里也用到了前面定义的type类型
import * as ActionTypes from './ActionTypes.js';
export default (state, action) => {
const {counterCaption} = action;
switch (action.type) {
case ActionTypes.INCREMENT:
return {...state, [counterCaption]: state[counterCaption]++};
case ActionTypes.DECREMENT:
return {...state, [counterCaption]: state[counterCaption]--};
default:
return state
}
}
继续添加Store.js
文件,用于创建Store
,代码如下:
import {createStore} from 'redux';
import reducer from './Reducer.js';
// 最初的状态,理论上来说,这个数据应该是后端返回,我们再塞进来的,后续再维护进行更新
const initValues = {
'First': 0,
'Second': 10,
};
const store = createStore(reducer, initValues);
export default store;
我们现在需要定义一个点加号数字加1,点减号数字减1的小组件。这个组件会被使用2次,因此上述Store
中我们定义了First
与Second
两条属性,用于作为两个组件的初始值。继续添加Counter.js
文件:
import React, { Component } from 'react';
import store from './Store.js';
import * as Actions from './Actions.js';
class Counter extends Component {
constructor(props) {
super(props);
// 初始化组件的state
this.state = this.getOwnState();
}
getOwnState = () => {
// 这里的this.props.caption其实就是前面说的First Second
return {
// 这里可以拿到当前的Store数据,并根据key取到对应的初始值
value: store.getState()[this.props.caption]
};
}
onIncrement = () => {
// Actions.increment返回的其实是一个action对象,注意这个函数其实只传递了一个参数,也就是上面提到的First Second类型
store.dispatch(Actions.increment(this.props.caption));
}
onDecrement = () => {
store.dispatch(Actions.decrement(this.props.caption));
}
// 用于更新state
onChange = () => {
this.setState(this.getOwnState());
}
shouldComponentUpdate(nextProps, nextState) {
// 如果state的value变了,通知组件更新
return nextState.value !== this.state.value;
}
componentDidMount() {
// 监听Store变化,Store变了我们就让组件的state也跟着变
store.subscribe(this.onChange);
}
componentWillUnmount() {
store.unsubscribe(this.onChange);
}
render() {
const { value } = this.state;
const { caption } = this.props;
return (
<div>
<button onClick={this.onIncrement}>+</button>
<button onClick={this.onDecrement}>-</button>
<span>{caption} count: {value}</span>
</div>
);
}
}
export default Counter;
因为现在其实有两个简单的计数组件,我们还需要一个求和的组件,用于求和上面两个计数器修改后数字的和。继续添加Summary.js
文件:
import React, { Component } from 'react';
import store from './Store.js';
class Summary extends Component {
constructor(props) {
super(props);
this.state = this.getOwnState();
}
onChange = ()=> {
this.setState(this.getOwnState());
}
getOwnState() {
// 拿到当前的Store
const state = store.getState();
let sum = 0;
for (const key in state) {
if (state.hasOwnProperty(key)) {
// 求和
sum += state[key];
}
}
return { sum };
}
shouldComponentUpdate(nextProps, nextState) {
return nextState.sum !== this.state.sum;
}
componentDidMount() {
store.subscribe(this.onChange);
}
componentWillUnmount() {
store.unsubscribe(this.onChange);
}
render() {
const {sum} = this.state;
return (
<div>Total Count: {sum}</div>
);
}
}
export default Summary;
最后,在index.js
中添加如下代码,并在控制台执行yarn start
:
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import Counter from './Counter.js';
import Summary from './Summary.js';
class ControlPanel extends Component {
render() {
return (
<div>
<Counter caption="First" />
<Counter caption="Second" />
<hr />
<Summary />
</div>
);
}
}
ReactDOM.render(
<ControlPanel />,
document.getElementById('root')
);
我们可以尝试让数字增加或者减少,可以发现对应的总和也会对应变化,这就是结合redux
后一个简单的例子。
对于初次接受redux
的人来说,即便这个例子很简单,可能在短时间内也难以完全理清整个运转过程,所以最好的建议还是根据上述例子,跑起来后断点跟踪整个执行过程,从点击时间开始,如何拿到action
,如何派发并通知的reducer
,更新Store后又是怎么让react组件视图感知到数据的变化,所以还是建议多看看多思考一下。
那么到这里,本文介绍了redux
起源,以及redux
基本用法, 在熟悉redux
的玩法后,我们在下篇文章继续介绍react-redux
。那么本文结束。
肆 ❀ 参
深入浅出React和Redux 第三章