reselect与redux

1. reselect简介

reselect是一个与redux配合使用的一个模块,它可根据state上的多个已有的属性来生成一个新的属性,
这可以为简化代码逻辑带来很大的帮助。比如我们在state上有users这个属性,而在每个user上又有性别
这个属性,那么我们可以使用reselect来为我们分别生成性别为男和女的新属性,而且也不需要加入新的
reducer和action。

2. 一个简单的例子

现在我们有一个简单的文章系统,大概是这样的:

articles_1.png

文件目录大概这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
|- actions/
|--- types.js //存放所有action.type
|--- index.js //action creators
|- components/
|--- app.js //入口组件
|--- containers/
|------ post_list.js //显示已被选中的文章
|------ post_selectors.js //所有文章,点击checkbox会加入被选中的文章列表
|- reducers/
|--- all_post_reducer.js //返回所有文章
|--- index.js //combinedReducer
|--- select_post_reducer.js //返回被选中或者被反选的文章
|-index.js //主入口

代码:

/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import reduxThunk from 'redux-thunk';

import App from './components/app';
import reducers from './reducers';

const createStoreWithMiddleware = applyMiddleware(
reduxThunk
)(createStore);

ReactDOM.render(
<Provider store={createStoreWithMiddleware(reducers)}>
<App />
</Provider>
, document.querySelector('.container'));

/components/app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React, { Component } from 'react';

import PostSelector from './containers/post_selectors';
import PostList from './containers/post_list'

export default class App extends Component {
render() {
return (
<div>
<PostList />
<PostSelector />
</div>
);
}
}

/components/containers/post_selectors.js

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'

import { getPosts, selectPost, deselectPost } from '../../actions/index';

class PostSelector extends Component {
componentWillMount(){
this.props.getPosts();
}

handleCheck(postId, event){
if(event.target.checked){
this.props.selectPost(postId)
}else{
this.props.deselectPost(postId)
}
}

renderPostList(){
return this.props.posts.map(post => {
return (
<li className="list-group-item" key={post.id}>
<input
type="checkbox"
value={this.props.selectedIds.includes(post.id)}
onChange={this.handleCheck.bind(this, post.id)}
/>
{post.title}
</li>
)
})
}

render(){
//console.log(this.props.posts)
if(this.props.posts){
return (
<div>
<h2>文章列表</h2>
<ul className="list-group">
{this.renderPostList()}
</ul>
</div>
)
}else{
return <h2>Loading...</h2>
}
}
}

function mapStateToProps(state){
return {
posts: state.posts,
selectedIds: state.selectedIds
}
}

function mapDispatchToProps(dispatch){
return bindActionCreators({
getPosts,
selectPost,
deselectPost
}, dispatch)
}

export default connect(mapStateToProps, mapDispatchToProps)(PostSelector);

/components/containers/post_list.js

1
2
3
4
5
6
7
8
9
10
11
import React, { Component } from 'react';

export default class PostList extends Component {
render(){
return (
<div>
<h2>已选中的文章</h2>
</div>
)
}
}

/actions/types.js

1
2
3
export const GETPOSTS = 'GETPOSTS';
export const SELECTPOST = 'SELECTPOST';
export const DESELECTPOST = 'DESELECTPOST';

/actions/index.js

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
28
29
30
31
32
33
34
import axios from 'axios';
import _ from 'lodash';

import { GETPOSTS, SELECTPOST, DESELECTPOST } from './types';

export function getPosts(){
return dispatch => {
const fetchUrl = 'http://jsonplaceholder.typicode.com/posts';
axios.get(fetchUrl).then(response=>{
if(response.status == 200){
dispatch({
type: GETPOSTS,
payload: response.data.slice(0, 5)
})
}
})
}
}

export function selectPost(postId){
return dispatch => {
dispatch({
type: SELECTPOST,
payload: postId
})
}
}

export function deselectPost(postId){
return {
type: DESELECTPOST,
payload: postId
}
}

/reducers/index.js

1
2
3
4
5
6
7
8
9
10
import { combineReducers } from 'redux';
import all_post_reducer from './all_post_reducer';
import select_post_reducer from './select_post_reducer';

const rootReducer = combineReducers({
posts: all_post_reducer,
selectedIds: select_post_reducer
});

export default rootReducer;

/reducers/all_post_reducer.js

1
2
3
4
5
6
7
8
9
10
11
import { GETPOSTS, SELECTPOST, DESELECTPOST } from '../actions/types';

export default function(state=[], action){
switch(action.type){
case GETPOSTS:
return action.payload;

default:
return state;
}
}

/reducers/select_post_reducer.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { GETPOSTS, SELECTPOST, DESELECTPOST } from '../actions/types';

export default function(state=[], action){
switch(action.type){
case SELECTPOST:
return [...state, action.payload];

case DESELECTPOST:
return state.filter(id=> id!=action.payload);

default:
return state;
}
}

代码概述:

这是一个很简单的react-redux项目,通过两个action creator来向reducer发送action,并且
生成新的state,这里state中共有两个属性,1,包含所有文章的数组,这里可以看出来,我们使用的是jsonplaceholder
中的post并且只取了前5个。2,包含所有已经被选的文章Id数组,这个数组只包含文章的Id,大概是这样的
[1, 2, 4]。用户可以通过选择或反选文章左侧的checkbox来进行选择或者反选文章,其实就是往被选文章数组内
添加当前文章Id或者是从被选文章数组内移除当前文章的Id。

一点题外话

我在写这个小例子的时候除了个小状况,而且,我觉得应该很多人都出过这种状况吧。当时我差不多写好的时候,
发现不管是选择还是反选文章前的checkbox,被选文章的数组始终是个空数组,经过一番排查,发现错误很诡异
action creator方法确实执行了,但是reducer却没有反应,而且浏览器没有报任何错误,换句话说,reducer
没有接收到action creator发出的action。经过长(da)时(shen)间(de)调(zhi)试(dian),最终发现bug的所在:
我在组件中调用actioncreator 方法的时候忘了加 this.props 这么做会导致在action creator被
调用的时候,它调用的不是props上的那个redux包装过的action creator,而是调用的import进来的那个包装前的。
要知道,在bindActionCreator执行的时候,里面的方法已经跟redux绑定在了一起,但是之前那个import进来的
只是一个纯函数,可以执行,然后return一个action object,并不会dispatch给reducer。这个错误我觉的应该
不是我一个人犯过,所以大家看了以后,如果你的action creator可以正常以运行但是reducer却接不到任何一个
action,而且浏览器不报任何错误,那么多半就是这个原因了,检查一下你在运行action creator的时候是不是
忘了加this.props吧。

3. 补全post_list

可以看出来,post_list根本就没写完,目前,它只能显示一个h2标签。我们需要它在有文章被勾选时显示这个文章,
再文章被反选时把这个文章移除。那么很明显,它需要是一个Smart Component,就是说它需要和state tree相关联。

state tree已绑定的属性

目前我们的state tree上已经绑定了这些:

  1. 所有的文章
  2. 一个包含已选中文章Id的数组

那么目前看来,单纯用redux来解决的话大概有两个解决方案:

  1. 在state tree上新增加一个数组,里面包含所有被选中的文章(不是只有文章Id),然后把这个属性绑在post_list上。
    这样一来我们需要重写post_selectors.js这个组件,在选中或者反选时调用一个新的action creator,而这个action
    creator的作用就是根据参数传入的Id来对所有文章的数组进行过滤,把Id为参数传入的那个文章作为payload传给reducer,
    最后reducer把那个文章加入到新书组内并且返回新的数组。

  2. 在post_list的componentWillMount时对所有文章的数组进行过滤,只保留Id包含在被选文章Id数组内的文章,然后对
    其进行渲染。

问题

这两种解决办法都有一定的问题,第一种做法使得逻辑变得复杂,因为要在state上加入新的属性,并且需要加入新的
action creator以及reducer。第二种方法虽然看似简单,但是却使得组件的复用性变差了,如果我之后想做一个新的post_list
里面只显示没有选中的文章,那么就没办法服用这个组件,因为componentWillMount方法的逻辑不同。

4. 解决方案

在redux生态系统中,有这么个东西,叫做reselect,GitHub 连接,这个
模块能很好的解决我们目前的问题。首先,我们知道post_list上需要的文章并不是新的文章,而是由所有文章和已选中文章Id的
数组进行过滤后得到的。如果state tree上的某个属性是由其他属性进行某种逻辑运算后得到的,那么我们就可以考虑使用reselect。

使用reselect

首先为了让文件目录更清晰,我们在根目录下新增加一个文件夹,叫selectors用来存放所有的selector,至于selector是什么,
我们马上就知道了,目前的文件目录大概是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|- actions/
|--- types.js //存放所有action.type
|--- index.js //action creators
|- components/
|- selectors/ //包含所有selector
|--- select_post_selector.js //负责生成被选中的文章
|--- app.js //入口组件
|--- containers/
|------ post_list.js //显示已被选中的文章
|------ post_selectors.js //所有文章,点击checkbox会加入被选中的文章列表
|- reducers/
|--- all_post_reducer.js //返回所有文章
|--- index.js //combinedReducer
|--- select_post_reducer.js //返回被选中或者被反选的文章
|-index.js //主入口

/selectors/select_post_selector.js

1
2
3
4
5
6
7
8
9
10
import { createSelector } from 'reselect';

const allPostSelector = state => state.posts;
const selectedIdSelector = state => state.selectedIds;

const getSelectedPosts = (posts, selectedIds) => {
return posts.filter(post => selectedIds.includes(post.id));
};

export default createSelector(allPostSelector, selectedIdSelector, getSelectedPosts);

大概说一下,allPostSelector一个函数,它会在state tree上找到posts这个属性,也就是所有文章的数组,
selectedIdSelector和allPostSelector类似,会在state tree上找到selectedIds属性,也就是那个
包含所有被选文章Id的数组。

getSelectedPosts也是一个函数,它接收多个参数,然后返回根据这些参数生成的新属性,这个属性最后会绑定
到state tree上。

然后我们需要在post_list这一组件中使用这个用reselect生成的新属性
/components/containers/post_list.js

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
28
29
30
31
32
33
34
import React, { Component } from 'react';
import { connect } from 'react-redux';

import PostSelector from '../../selectors/select_post_selector';

class PostList extends Component {

renderPostLists(){
return this.props.selectedPosts.map(post=>{
return (
<li className="list-group-item" key={post.id}>{post.title}</li>
)
})
}

render(){
return (
<div>
<h2>已选中的文章</h2>
<ul className="list-group">
{this.renderPostLists()}
</ul>
</div>
)
}
}

function mapStateToProps(state){
return {
selectedPosts: PostSelector(state)
}
}

export default connect(mapStateToProps)(PostList);

这个组件其实基本和使用redux对state进行绑定后的Smart组件没什么区别,唯一一点值得
注意的是第30行,selectedPosts: PostSelector(state)这里。我们只是用刚才写好
的selector对state进行加工,用已有的属性(所有post和被选postId)来生成这个新的属性。

5. 小结

运行一下,一切都没问题,而且这样一来,post_list这个组件也可以很好的复用了。值得注意的
是,现在这个小例子只是为了向大家简单介绍一下reselect这个模块,或许你感觉不到使用reselect
的必要性,但是,要知道这个例子中的state tree结构非常简单,但是当在实际开发中,业务逻辑往往
会变得十分复杂,这也就导致了state tree结构也会变得复杂,通过这种方式来简化state tree将会
是一种非常好的优化手段。