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 技術棧》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容