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 的基本思路
當用戶與界面交互時,交互事件的回調函數會觸發 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 數據結構的數據了。就像這樣:
至此,我們走了一遍完整的數據流。然而,在實際項目中,我們面臨的需求更為復雜,與此同時,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');
}
你也可以參閱我們往期介紹的其他方式。
最后,我們再來完善一下之前的流程:

3.結語
Redux 的 API 一只手都能數得完,源碼更是精煉,加起來不超過500行。但它給我們帶來的,不啻是一套復雜應用解決方案,更是 Flux 思想的精簡表達。此外,你還可以從中體會到函數式編程的樂趣。
一千個觀眾心中有一千個哈姆萊特,你腦海里的又是哪一個呢?
參考
《Redux 官方文檔》
《深入 React 技術棧》