React技术栈耕耘 —— Redux

Redux 是近年来提出的 Flux 思想的一种实践方案,在它之前也有 reflux 、 fluxxor 等高质量的作品,但短短几个月就在 GitHub 上获近万 star 的成绩让这个后起之秀逐渐成为 Flux 的主流实践方案。

正如 Redux 官方所称,React 禁止在视图层直接操作 DOM 和异步行为 ( removing both asynchrony and direct DOM manipulation ),来拆开异步和变化这一对冤家。但它依然把状态的管理交到了我们手中。Redux 就是我们的状态管理小管家。

安利的话先暂时说到这,本次我们聊聊 React-Redux 在沪江前端团队中的实践。

0. 放弃

你没有看错,在开始之前我们首先谈论一下什么情况下不应该用 Redux。

所谓杀鸡焉用宰牛刀,任何技术方案都有其适用场景。作为一个思想的实践方案,Redux 必然会为实现思想立规矩、铺基础,放在复杂的 React 应用里,它会是“金科玉律”,而放在结构不算复杂的应用中,它只会是“繁文缛节”。

如果我们将要构建的应用无需多层组件嵌套,状态变化简单,数据单一,那么就应放弃 Redux ,选用单纯的 React 库 或其他 MV* 库。毕竟,没有人愿意雇佣一个收费比自己收入还高的财务顾问。

1. 思路

首先,我们回顾一下 Redux 的基本思路

redux flow

当用户与界面交互时,交互事件的回调函数会触发 ActionCreators ,它是一个函数,返回一个对象,该对象携带了用户的动作类型和修改 Model 必需的数据,这个对象也被我们称作 Action 。

以 TodoList 为例,添加一个 Todo 项的 ActionCreator 函数如下所示(如果不熟悉 ES6 箭头函数请移步这里):

const addTodo = text => ({
    type: 'ADD_TODO',
    text
});

在上例中,addTodo 就是 ActionCreator 函数,该函数返回的对象就是 Action 。

其中 type 为 Redux 中约定的必填属性,它的作用稍后我们会讲到。而 text 则是执行 “添加 Todo 项“ 这个动作必需的数据。

当然,不同动作所需要的数据也不尽相同,如 “删除Todo” 动作,我们就需要知道 todo 项的 id,“拉取已有的Todo项” 动作,我们就需要传入一个数组( todos )。形如 text 、 id 、 todos 这类属性,我们习惯称呼其为 “ payload ” 。

现在,我们得到了一个 “栩栩如生” 的动作。它足够简洁,但担任 Model 的 store 暂时还不知道如何感知这个动作从而改变数据结构。

为了处理这个关键问题,Reducer 巧然登场。它仍然是一个函数,而且是没有副作用的纯函数。它只接收两个参数:state 和 action ,返回一个 newState 。

没错,state 就是你在 React 中熟知的 state,但根据 Redux 三原则 之一的 “单一数据源” 原则,Reducer 幽幽地说:“你的 state 被我承包了。”

于是,单一数据源规则实施起来,是规定用 React 的顶层容器组件( Container Components )的 state 来存储单一对象树,同时交给 Redux store 来管理。

这里区分一下 state 和 Redux store:state 是真正储存数据的对象树,而 Redux store 是协调 Reducer、state、Action 三者的调度中心。

而如此前所说,Reducer 此时手握两个关键信息:旧的数据结构(state),还有改变它所需要的信息 (action),然后聪明的 Reducer 算盘一敲,就能给出一个新的 state ,从而更新数据,响应用户。下面依然拿 TodoList
举例(不熟悉 “...” ES6 rest/spread 语法请先看这里):

//整个 todoList 最原始的数据结构。
const initState = {
    filter: 'ALL',
    todos: []
};
//Reducer 识别动作类型为 ADD_TODO 时的处理函数
const handleAddTodo = (state, text) => {
    const todos = state.todos;
    const newState = {...state, {
        todos: [
            ...todos, {
            text,
            completed: false
        }]
    }};
    return newState;
};
//Reducer 函数
const todoList = (state = initState, action) => {
    switch (action.type) {
        case 'ADD_TODO':
            return handleAddTodo(state, action.text);
        default:
            return state;
    }
}

当接收到一个 action 时,Reducer 从 action.type 识别出该动作是要添加 Todo 项,然后路由到相应的处理方案,接着根据 action.text 完成了处理,返回一个 newState 。过程之间,整个应用的 state 就从 state => newState 完成了状态的变更。

这个过程让我们很自然地联想到去银行存取钱的经历,显然我们应该告诉柜台操作员要存取钱,而不是遥望着银行的金库自言自语。

Reducer 为我们梳理了所有变更 state 的方式,那么 Redux store 从无到有,从有到变都应该与 Reducer 强关联。

因此,Redux 提供了 createStore 函数,他的第一个参数就是 Reducer ,用以描绘 state 的更改方式。第二个是可选参数 initialState ,此前我们知道,这个 initialState 参数也可以传给 Reducer 函数。放在这里做可选参数的原因是为同构应用提供便捷。

//store.js
import reducer from './reducer';
import { createStore } from 'redux';
export default createStore(reducer);

createStore 函数最终返回一个对象,也就是我们所说的 store 对象。主要提供三个方法:getState、dispatch 和 subscribe。 其中 getState() 获得 state 对象树。dispatch(actionCreator) 用以执行 actionCreators,建起从 action 到 store 的桥梁。

仅仅完成状态的变更可不算完,我们还得让视图层跟上 store 的变化,于是 Redux 还为 store 设计了 subscribe 方法。顾名思义,当 store 更新时,store.subscribe() 的回调函数会更新视图层,以达到 “订阅” 的效果。

在 React 中,有 react-redux 这样的桥接库为 Redux 的融入铺平道路。所以,我们只需为顶层容器组件外包一层 Provider 组件、再配合 connect 函数处理从 store 变更到 view 渲染的相关过程。

import store from './store';
import {connect, Provider} from 'react-redux';
import React from 'react';
import ReactDOM from 'react-dom';
import Page from '../components/page'; //业务组件
// 把 state 映射到 Container 组件的 props 上的函数
const mapStateToProps = state => { 
    return {
        ...state
    }
}
const Container = connect(mapStateToProps)(Page); //顶层容器组件
ReactDOM.render(
    <Provider store={store}>
        <Container />
    </Provider>,
    document.getElementById("root")
);

而顶层容器组件往下的子组件只需凭借 props 就能一层层地拿到 store 数据结构的数据了。就像这样:

store props

至此,我们走了一遍完整的数据流。然而,在实际项目中,我们面临的需求更为复杂,与此同时,redux 和 react 又是具有强大扩展性的库,接下来我们将结合以上的主体思路,谈谈我们在实际开发中会遇到的一些细节问题。

2. 细节

应用目录

清晰的思路须辅以分工明确的文件模块,才能让我们的应用达到更佳的实践效果,同时,统一的结构也便于脚手架生成模板,提高开发效率。

以下的目录结构为团队伙伴多次探讨和改进而来(限于篇幅,这里只关注 React 应用的目录。):

appPage
├── components
│   └── wrapper
│       ├── component-a
│       │   ├── images
│       │   ├── index.js
│       │   └── index.scss
│       ├── component-a-a
│       ├── component-a-b
│       ├── component-b
│       └── component-b-a
├── react
│   ├── reducer
│   │   ├── index.js
│   │   ├── reducerA.js
│   │   └── reducerB.js
│   ├── action.js
│   ├── actionTypes.js
│   ├── bindActions.js
│   ├── container.js
│   ├── model.js
│   ├── param.js
│   └── store.js  
└── app.js

入口文件 app.js 与顶层组件 react/container.js

这块我们基本上保持和之前思路上的一致,用 react-redux 桥接库提供的 Provider 与函数 connect 完成 Redux store 到 React state 的转变。

细心的你会在 Provider 的源码中发现,它最终返回的还是子组件(本例中就是顶层容器组件 “Container“ )。星星还是那个星星,Container 还是那个 Container,只是多了一个 Redux store 对象。

而 Contaier 作为 业务组件 Wrapper 的 高阶组件 ,负责把 Provider 赋予它的 store 通过 store.getState() 获取数据,转而赋值给 state 。然后又根据我们定义的 mapStateToProps 函数按一定的结构将 state 对接到 props 上。 mapStateToProps 函数我们稍后详说。如下所见,这一步主要是 connect 函数干的活儿。

//入口文件:app.js
import store from './react/store';
import Container from './react/container'; 
import React from 'react';
import ReactDOM from 'react-dom';
import {Provider} from 'react-redux';
ReactDOM.render(
    <Provider store={store}>
        <Container />
    </Provider>,
    document.getElementById("root")
);
//顶层容器组件:react/container.js
import mapStateToProps from './param';
import {connect} from 'react-redux';
import Wrapper from '../components/wrapper';
export default connect(mapStateToProps)(Wrapper);

业务组件 component/Wrapper.js 与 mapStateToProps

这两个模块是整个应用很重要的业务模块。作为一个复杂应用,将 state 上的数据和 actionCreator 合理地分发到各个业务组件中,同时要易于维护,是开发的关键。

首先,我们设计 mapStateToProps 函数。需要谨记一点:拿到的参数是 connect 函数交给我们的根 state,返回的对象是最终 this.props 的结构。

和 Redux 官方示例不同的是,我们为了可读性,将分发 action 的函数也囊括进这个结构中。这也是得益于 bindActions 模块,稍后我们会讲到。

//mapStateToProps:react/param.js
import bindActions from './bindActions';
const mapStateToProps = state => {
    let {demoAPP} = state; // demoAPP 也是 reducer 中的同名函数
    // 分发 action 的函数
    let {initDemoAPP, setDemoAPP} = bindActions;
    // 分发 state 上的数据
    let {isLoading, dataForA, dataForB} = demoAPP;
    let {dataForAA1, dataForAA2, dataForAB} = dataForA; 
    // 返回的对象即为 Wrapper 组件的 this.props
    return {
        initDemoAPP, // Wrapper 组件需要发送一个 action 初始化页面数据
        isLoading, //  Wrapper 组件需要 isLoading 用于展示
        paramsComponentA: {
            dataForA, // 组件 A 需要 dataForA 用于展示
            paramsComponentAA: {
                setDemoAPP, // 组件 AA 需要发送一个 action 修改数据
                dataForAA1,
                dataForAA2
            },
            paramsComponentAB: {
                dataForAB
            }
        },
        paramsComponentB: {
            dataForB,
            paramsComponentBA: {}
        }
    }
}
export default mapStateToProps;

这样,我们这个函数就准备好履行它分发数据和组件行为的职责了。那么,它又该如何 “服役” 呢?

敏锐的你一定察觉到刚才我们设计的结构中,以 “ params ” 开头的属性既没起到给组件展示数据的作用,又没有为组件发送 action 的功能。它们便是我们分发以上两种功能属性的关键。

我们先来看看业务组件 Wrapper :

//业务组件组件:components/wrapper.js
import React, { Component } from 'react';
import ComponentA from '../component-a';
import ComponentB from '../component-b';
export default class Example extends Component {
    constructor(props) {
        super(props);
    }
    componentDidMount() {
        this.props.initDemoAPP(); //拉取业务数据
    }
    render() {
        let {paramsComponentA, paramsComponentB, isLoading} = this.props;

        if (isLoading) {
            return (<span>App is loading ...</span>);
        }
        return (
            <div>
                {/* 为组件分发参数 */}
                <ComponentA {...paramsComponentA}/>
                <ComponentB {...paramsComponentB}/>
            </div>
        );
    }
}

现在,param 属性们为我们展示了它扮演的角色:在组件中实际分发数据和方法的快递小哥。这样,即使项目越变越大,组件嵌套越来越多,我们也能在 param.js 模块中,清晰地看到我们的组件结构。需求更改的时候,我们也能快速地定位和修改,而不用对着堆积如山的组件模块梳理父子关系。

相信你应该能猜到剩下的子组件们怎么取到数据了,这里限于篇幅就不贴出它们的代码了。

Action 模块: react/action.js、react/actionType.js 和 react/bindActions.js

在前面的介绍中,我们提到:一个 ActionCreator 长这样:

const addTodo = text => ({
    type: 'ADD_TODO',
    text
});

而在 Redux 中,真正让其分发一个 action ,并让 store 响应该 action,依靠的是 dispatch 方法,即:

store.dispatch(addTodo('new todo item'));

交互动作一多,就会变成:

store.dispatch(addTodo('new todo item1'));
store.dispatch(deleteTodo(0));
store.dispatch(compeleteTodo(1));
store.dispatch(clearTodos());
//...

而容易想到:抽象出一个公用函数来分发 action (这里粗略写一下我的思路,简化方式并不唯一)

const {dispatch} = store;
const dispatcher = (actionCreators, dispatch) => {
    // ...校验参数
    let bounds = {};
    let keys = Object.keys(actionCreators);
    for (let key of keys) {
        bounds[key] = (...rest) => {
            dispatch(actionCreators[key].apply(null, rest));
        }
    }
    return bounds;
}
//简化后的使用方式
const disp = dispatcher({
    addTodo,
    deleteTodo,
    compeleteTodo
    //...
}, dispatch);
disp.addTodo('new todo item1');
disp.deleteTodo(0);
//...

而细心的 Redux 已经为我们提供了这个方法 —— bindActionCreator

所以,我们的 bindActions.js 模块就借用了 bindActionCreator 来简化 action 的分发:

// react/bindActions.js
import store from './store.js';
import {bindActionCreators} from 'redux';
import * as actionCreators from './action';
let {dispatch} = store;
export default bindActionCreators({ ...actionCreators}, dispatch);

不难想象,action 模块里就是一个个 actionCreator :

// react/action.js
import * as types from '/actionType.js';
export const setDemoAPP = payload => ({
    type: types.SET_DEMO_APP,
    payload
});
// 其他 actionCreators ...

为了更好地合作,我们单独为 action 的 type 划分了一个模块 —— actionTypes.js 里面看起来会比较无聊:

// react/actionTypes.js
export const SET_DEMO_APP = "SET_DEMO_APP";
// 其他 types ...

react/reducers/ 和 react/store.js

前面我们说到,reducer 的作用就是区别 action type 然后更新 state ,这里不再赘述。可上手实际项目的时候,你会发现 action 类型和对应处理方式多起来会让单个 reducer 迅速庞大。

为此,我们就得想方设法将其按业务逻辑拆分,以免难以维护。但是如何把拆分后的 Reducer 组合起来呢 Redux 再次为我们提供便捷 —— combineReducers 。

只有单一 Reducer 时,想必代码结构你也了然:

import * as actionTypes from '../actionTypes';
let initState = {
    isLoading: true
};
// 对应 state.demoAPP
const demoAPP = (state = initState, action) => {
    switch (action.type) {
        case actionTypes.SET_DEMO_APP:
            return {
                isLoading: false,
                ...action.payload
            };
        default:
            return state;
    }
}
export default demoAPP; // 把它转交给 createStore 函数

我们最终得到的 state 结构是:

  • state
    • demoAPP

当有多个 reducer 时:

import * as actionTypes from '../actionTypes';
import { combineReducers } from 'redux';
let initState = {
    isLoading: true
};
// 对应 state.demoAPP
const demoAPP = (state = initState, action) => {
    switch (action.type) {
        case actionTypes.SET_DEMO_APP:
            return {
                isLoading: false,
                ...action.payload
            };
        default:
            return state;
    }
}
// 对应 state.reducerB
const reducerB = (state = {}, action) => {
    switch (action.type) {
        case actionTypes.SET_REDUCER_B:
            return {
                isLoading: false,
                ...action.payload
            };
        default:
            return state;
    }
}
const rootReducer = combineReducers({demoAPP,reducerB});
export default rootReducer;

我们最终得到的 state 结构是:

  • state
    • demoAPP
    • reducerB

想必你已经想到更进一步,把这些 Reducer 拆分到相应的文件模块下:

// react/reducers/index.js 
import demoAPP from './demoAPP.js';
import reducerB from './reducerB.js';
const rootReducer = combineReducers({demoAPP,reducerB});
export default rootReducer;

接着,我们来看 store 模块:

// react/store.js
import rootReducer from './reducers';
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
const initialState = {};
const finalCreateStore = compose(
    applyMiddleware(thunk)
)(createStore);
export default finalCreateStore(rootReducer, initialState);

怎么和想象的不一样?不应该是这样吗:

// react/store.js
import rootReducer from './reducers';
import { createStore } from 'redux';
export default createStore(rootReducer);

这里引入 redux 中间件的概念,你只需知道 redux 中间件的作用就是 在 action 发出以后,给我们一个再加工 action 的机会 就可以了。

为什么要引入 redux-thunk 这个中间件呢?

要知道,我们此前所讨论的都是同步过程。实际项目中,只要遇到请求接口的场景(当然不只有这种场景)就要去处理异步过程。

前面我们知道,dispatch 一个 ActionCreator 会立即返回一个 action 对象,用以更新数据,而中间件赋予我们再处理 action 的机会。

试想一下,如果我们在这个过程中,发现 ActionCreator 返回的并不是一个 action 对象,而是一个函数,然后通过这个函数请求接口,响应就绪后,我们再 dispatch 一个 ActionCreator ,这次我们真的返回一个 action ,然后携带接口返回的数据去更新 state 。 这样一来不就解决了我们的问题吗?

当然,这只是基本思路,关于 redux 的中间件设计,又是一个有趣的话题,有兴趣我们可以再开一篇专门讨论,这里点到为止。

回到我们的话题,经过

const finalCreateStore = compose(
    applyMiddleware(thunk)
)(createStore);
export default finalCreateStore(rootReducer, initialState);

这样包装一遍 store 后,我们就可以愉快地使用异步 action 了:

// react/action.js
import * as types from './actionType.js';
import * as model from './model.js';
// 同步 actionCreator
export const setDemoAPP = payload => ({
    type: types.SET_DEMO_APP,
    payload
});
// 异步 actionCreator
export const initDemoAPP = () => dispatch => {
    model.getBaseData().then(response => {
        let {status, data} = response;
        if (status === 0) {
            //请求成功且返回数据正常
            dispatch(setDemoAPP(data));
        }
    }, error => {
        // 处理请求异常的情况
    });
}

这里我们用 promise 方式来处理请求,model.js 模块如你所想是一些接口请求 promise,就像这样:

export const getBaseData () => {
    return $.getJSON('/someAPI');
}

你也可以参阅我们往期介绍的其他方式。

最后,我们再来完善一下之前的流程:

redux flow
redux flow

3.结语

Redux 的 API 一只手都能数得完,源码更是精炼,加起来不超过500行。但它给我们带来的,不啻是一套复杂应用解决方案,更是 Flux 思想的精简表达。此外,你还可以从中体会到函数式编程的乐趣。

一千个观众心中有一千个哈姆莱特,你脑海里的又是哪一个呢?

参考

《Redux 官方文档》
《深入 React 技术栈》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容