0%

一些 Redux + react-redux 的基础知识

很多时候,用是知道怎么用的,但其中的逻辑和原理却不甚了解,多看,多理解。


Redux 基本原则

针对 Flux 这种框架理念,有很多种实现,比如 Redux、Reflux、Fluxible 等(redux 只是其中之一哦!),不过 Redux 有许多优势以及针对 flux 不足作出的优化。Flux 的基本原则是「单向数据流」,Redux 在此基础上强调了三个原则,介绍如下。

数据源唯一

其实就是指把所有状态数据放到唯一的一个 store 上。为什么呢?因为多个 store 首先会有潜在的数据冗余的问题,难以保证数据一致性;其次多个 store 的存在容易产生依赖问题,虽然最终能有解决方案(eg. Flux 的 waitFor),但依赖关系的存在增加了应用的复杂度,容易带来新问题。

Redux 中这个唯一的 store 上的状态是一个树形的对象,每个组件只是其中一部分的数据,后面再讲如何设计。

状态只读

状态只读并不意味着状态不能改(要是不能改的话,UI=render(state)就完全不会变了),只是说我状态的修改不是直接改状态上的值,而是创建新的状态对象给 Redux,由 Redux 完成新状态的组装。

数据改变只能通过纯函数完成

这个「改变数据」的「纯函数」就是规约函数 Reducer 啦,Redux 的起名也正代表了 Reducer + Flux。普通函数的使用举例如下,iterator 类型数据有 reduce 函数,它可以对元素依次进行规约,得到一个目标。

1
2
3
4
//                                   ⬇上次规约结果  ⬇本次规约元素
[1, 2, 3, 4].reduce(function reducer(accumulation, item) {
return accumulation + item;
}, 0);

Redux 中对 Reducer 的使用也类似,参数中上次规约的结果就是上个状态 state,本次规约的元素则是此时接收到的 action 对象,函数要做的就是无副作用(纯函数)地根据它俩返回新的状态,可以对比下 Flux 中的处理,就能理解「副作用」。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Flux 中的处理
CounterStore.dispatchToken = AppDispatcher.register((action) => {
if (action.type === ActionTypes.INCREMENT) {
// 副作用
counterValues[action.counterCaption]++;
counterStore.emit(CHANGE_EVENT);
} else if (action.type === ActionTypes.DECREMENT) {
counterValues[action.counterCaption]--;
counterStore.emit(CHANGE_EVENT);
}
});

// Redux
function reducer = (state, action) => {
const { counterCaption } = action;
switch(action.type) {
case ActionTypes.INCREMENT:
return {...state, [counterCaption]: state[counterCaption] + 1};
case ActionTypes.DECREMENT:
return {...state, [counterCaption]: state[counterCaption] - 1};
default:
return state;
}
}

这里我们的 reducer 只负责「计算状态」,而不用负责「存储状态」,这也是和 Flux 有区别的地方。Redux 本身并没有额外强大的功能,反而像是给 Flux 添加了许多限制,不过正是这种限制才更加有利我们开发。

Redux 基础例子

用 Redux 重构下之前的例子,看看 Redux 的特别之处吧!

action

和 Flux 类似,把 action 分为类型和构造函数两个文件,前者类似,后者则大不一样。Flux 函数中创建了 action 并立刻 dispatch(传送门),Redux 中则只是纯粹地返回了一个 action 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/Actions.js
import * as ActionTypes from './ActionTypes.js'

export const increment = (counterCaption) => {
return {
type: ActionTypes.INCREMENT,
counterCaption: counterCaption
};
};

export const decrement = (counterCaption) => {
return {
type: ActionTypes.DECREMENT,
counterCaption: counterCaption
};
};

dispatcher ✕ createStore √

Flux 中需要全局定义一个 Dispatcher(传送门),Redux 中则没有这个东西。Dispatcher 存在的作用就是把一个 action 对象分发给多个注册了的 Store,既然 Redux 让全局只有一个 Store,那么再创造一个 Dispatcher 就意义不大了。所以 Redux 中将分发这一功能,从一个 Dispatcher 对象简化为 Store 对象上的一个函数 dispatch,毕竟只有一个 Store,要分发也是分发给这个 Store,那我就调用 Store 上一个表示分发的函数就行了,非常合理。

1
2
3
4
5
6
7
8
9
10
11
12
// src/Store.js
import { createStore } from 'Redux';
import reducer from './Reducer.js';

const initValues = {
'First': 0,
'Second': 10,
'Third': 20
};

const store = createStore(reducer, initValues);
export default store;

这里的 createStore 接收两个参数,第一个表示更新状态的 reducer,第二个表示初始状态,第三个是个可选的代表 Store Enhancer 的参数(后面讲)。

这里我们传入的 reducer 就是上面提到过的那个,它和 Flux 中 Store 注册的回调函数类似,都是一个 switch 语句,不同之处在于 Redux 额外传入了 state,把 state 的存储交给 Redux 框架本身,让 reducer 只关心如何更新 state。PS: redux 中一定不要修改这个参数 state,这不好。

view

顶层组件 src/views/ControlPanel.js 的代码和 Flux 那边一样(传送门),具体的 src/views/Counter.js 则不一样,区别有许多。

首先是初始化时 state 的来源不同、store 的数量不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/views/Counter.js

import store from '../Store.js';

class Counter extends Component {
constructor(props) {
super(props);
// ...
this.state = this.getOwnState();
}

getOwnState() {
return {
value: store.getState()[this.props.caption];
}
}
}

其次是保持状态同步,这里事件监听可以用 store 原生的。(其实这边 mount 的 subscribe 代码可以写到 constructor 中,单独拎到 did mount 中只是为了和 unmount 对称而已)。

1
2
3
4
5
6
7
8
9
10
11
onChange() {
this.setState(this.getOwnState());
}

componentDidMount() {
store.subscribe(this.onChange);
}

componentWillUnmount() {
store.unsubscribe(this.onChange);
}

再然后是触发状态变动,这里区别在于 action 构造函数,Flux 中构造同时派发,Redux 中仅构造,因此需要先调用构造函数,再调用 store 的 dispatch 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
onIncrement() {
store.dispatch(Actions.increment(this.props.caption));
}

onDecrement() {
store.dispatch(Actions.decrement(this.props.caption));
}

render() {
return (
<div>
<button onClick={this.onIncrement}> + </button>
<button onClick={this.onDecrement}> - </button>
<span>{ this.props.caption } count: { this.state.value }</span>
</div>
);
}

再看下 Summary 组件,这里要根据唯一的 store 中状态进行计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/views/Summary.js

// ...

getOwnState() {
const state = store.getState();
let sum = 0;
for(const key in state) {
if(state.hasOwnProperty(key)) {
sum += state[key];
}
}
return { sum };
}

改进一:容器组件和傻瓜组件

上面的每个组件都完成了两件事情,一个是和 redux store 打交道(初始化、监听 store 改变、触发 store 改变),另一个是根据当前 props 和 state 渲染 UI。为了实践专一性原则,我们可以根据上述功能将组件划分为两个组件。在这种划分维度下,二者是父子组件的关系,其中和 redux 打交道的部分我们称之为外层的「容器组件」(Container Component),专注渲染的部分我们称之为内层的「傻瓜组件」(Dumb Component)。(不过聪明和笨也是相对的,外层虽要处理各种状态,但也都是有套路、可抽离复用的;内层虽只负责渲染,但其纯函数的性质还蛮有大智若愚之感的)。

其实还蛮好理解的,把状态相关都丢给外层,把视图相关都丢给内层,二者通过 props 来传递数据即可。(拆分其实和 Redux 无关,Flux 中也有,这里提及只是为了引出后面的 react-redux 库)。

拿上面的 Counter 组件来举个例子拆分下吧。

1
2
3
4
5
6
7
8
9
// src/views/Counters.js 傻瓜组件
class Counter extends Component {
render() {
const { caption, onIncrement, onDecrement, value } = this.props;
return (
// ...
);
}
}

傻瓜组件完全木有 state,所有数据都来自 props,也只有一个 render 函数,被称作「无状态组件」。

1
2
3
4
5
6
7
8
9
10
11
// src/views/Container.js
class CounterContainer extends Component {
render() {
return <Counter
caption={this.props.caption}
onIncrement={this.onIncrement}
onDecrement={this.onDecrement}
value={this.state.value}
/>
}
}

容器组件的 render 只需要负责渲染傻瓜组件即可,外层调用者看到的只是容器组件,并不关心傻瓜组件。Summary 组件的拆分也类似。

改进二:组件 Context

Redux 中只有一个 Store,我们在许多地方都通过 import 拿到了它,但这种导入方式会有问题。比如当我们定义一个组件时,或者使用 npm 引入第三方组件时,我们不知道组件会在哪里被使用,因此也无法预先知道唯一 Store 的定义位置了,这种直接导入的方式不利于组件复用。

最好的做法是,只有一个地方直接导入 Store,其余组件避免直接导入 Store。对于前者,很容易想到把最顶层 React 组件作为导入处;对于后者,我们能想到最直接的获取 store 的方法,就是通过 props。通过 props 的方式需要层层传递,很麻烦,这时就不得不说 React 提供的 Context 功能了。

Context 是上下文环境,它能让一个树状组件上「所有组件」都能「访问一个共同的对象」。它需要上级组件和下级组件配合使用,前者要声明自己支持 context,提供一个返回 context 的函数;后者要宣称自己要使用 context,通过 this.context 访问这个共同环境的对象。

综合改进(手写版)

综合起来,我们的目标是「在顶层容器引入 store、声明 context」供「所有子组件通过 context 直接取用 store」。每个应用的顶层元素都不一样,因此我们把它抽象成一个特殊的 React 组件,作为通用的 context 提供者,我们称之为「Provider」。

如何实现 Provider 呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/Provider.js
import { PropTypes, Component } from 'React';
class Provider extends Component {

// getChildContext 函数返回一个代表 context 的对象
// 为了让 Provider 足够通用,并不在此处导入 store,而是要求 Provider 的使用者通过 props 传递进来 store
getChildContext() {
return { store: this.props.store };
}

// render 函数就是把 <Provider></Provider> 内的组件渲染出来,并不做额外的事情
render() {
return this.props.children;
}
}

// 为了让 Provider 能够被 React 认可为一个 Context 提供者,需要声明一个属性
Provider.childContextTypes = {
store: PropTypes.object
};

顶层组件如何使用 Provider 呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/index.js
import store from './Store.js';
import Provider from './Provider.js';

// 先前的顶层组件 ControlPanel 被包在新的顶层组件 Provider 中
// context 这下就可以覆盖住整个应用中的所有组件了
// ControlPanel 组件本身不需要做任何变动,和之前一样,只是渲染三个 Counter
ReactDOM.render(
<Provider store={store}>
<ControlPanel/>
</Provider>,
document.getElementById('root')
);

傻瓜组件如何使用 Provider 呢?

1
2
// src/views/Counter.js
// Counter 是个无状态组件,不需要和 store 直接打交道,因此本身也不用做任何变动

容器组件如何使用 Provider 呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/views/CounterContainer.js,其他代码见上方 #改进一 处
// ...

// 为了能让 CounterContainer 访问 context,需要声明一个属性并且使其值与 Provider 的对应一个属性值相等
CounterContainer.contextTypes = {
store: PropTypes.object
};

// CounterContainer 中所有对 store 的访问其实是 this.context.store,因此要映射一下
getOwnState() {
return {
value: this.context.store.getState()[this.props.caption]
};
}

// 由于我们自定义了构造函数,因此需要使用第二个参数 context
constructor(props, context) {
// 调用 super 时一定要带上 context 参数,才能让 React 组件初始化实例中的 context
// 否则组件的其他地方就用不了 this.context
super(props, context);
// ...
}

// // 上方也可以改写为
// constructor() {
// super(...arguments);
// }

总结一下,React 的 Context 相当于一个全局对象,全局对象容易在不经意的地方被改变,按理说应该避免使用。但由于 Redux 的 Store 封装得很好,没有提供直接修改状态的功能,部分地克服了作为全局对象的缺点;且 Redux 中一个应用只有一个 Store,也防止了滥用的情况,因此,使用 Context 来传递 Store 是一个不错的选择。

综合改进(调库版)

上面通过一个手写的 Provider + 组件拆分实践了两种改进方法(还记得是哪两种吗?),不难发现这其中是有套路的,把套路抽象出来复用,就成了库,有个很不错的库,叫 react-redux。

react-redux 对上述两种改进方法的实践,分别如下:

  • connect:连接容器组件和傻瓜组件
  • Provider:提供包含 store 的 context

下面分别介绍下 react-redux 提供的这俩功能。

connect

我们的手工实践中,自己定义了一层 CounterContainer,这个库则没有定义此组件,而是直接导出了如下语句。

1
export default connect(mapStateToProps, mapDispatchToProps)(Counter);

这里的 connect 函数接收两个参数,返回一个新函数;新函数接收一个参数,返回容器组件。想知道 connect 的参数含义,就要从「理解容器组件做了什么事」来入手,也就是通过目的反推实现起来需要的依赖,那么容器组件做了啥呢?

  • 把 store 上的状态转化为内层傻瓜组件的 props
  • 把内层傻瓜组件的用户行为转化为派送给 store 的动作

其实两边都是映射,前者是傻瓜组件的输入,是 state 到 props 的映射(外层 state 以 props 中变量的形式出现在内层);后者是傻瓜组件的输出,是 dispatch 到 props 的映射(外层 dispatch 以 props 中函数的形式出现在内层)。知道了这俩,就很好理解 connect 中传递的 mapStateToProps 和 mapDispatchToProps 了,如下。

1
2
3
4
5
6
// 外层 state 中的数据,取出 key 为内层 props 的 caption 值的值,作为内层 props 的 value 值
function mapStateToProps(state, ownProps) {
return {
value: state[ownProps.caption]
};
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 内层多了两个 props:OnIncrement、OnDecrement
// 前者调用了外层的 dispatch 去派发一个 Actions.increment 的 action,参数为内层 props 的 caption
// 后者调用了外层的 dispatch 去派发一个 Actions.decrement 的 action,参数为内层 props 的 caption
function mapDispatchToProps(dispatch, ownProps) {
return {
onIncrement: () => {
dispatch(Actions.increment(ownProps.caption));
},
onDecrement: () => {
dispatch(Actions.decrement(ownProps.caption));
}
};
}

Provider

react-redux 的 Provider 和我们之前手写的几乎一样,不过更加严谨了些。我们只限制 store 属性是一个 object,react-redux 中则要求 store 不光是一个 object,还必须要包括三个函数:subscribe、dispatch、getState。

除此之外,react-redux 定义了 Provider 的 componentWillReceiveProps(快回忆下它的作用!传送门),此生命周期函数会在每次重新渲染的时候被调用到,react-redux 在这个生命周期函数中会检查此次渲染时代表 store 的 props 和上一次是否一样,如果不一样则警告,避免多次渲染用了不同的 Redux Store。每个应用只能有一个 Redux Store,整个 Redux 的生命周期中都应该保持 Store 的唯一性。

以上就是简单的 redux 知识 + react-redux 知识了!后面要温习的还有很多,下次继续吧~