❞
寫完了react-redux,我們可以寫個demo來測試一下:使用react-create-app建立一個專案,刪掉無用的檔案,並建立store.js、reducer.js、react-redux.js來分別寫我們redux和react-redux的程式碼,index.js是專案的入口檔案,在App.js中我們簡單的寫一個計數器,點選按鈕就派發一個dispatch,讓store中的count加一,頁面上顯示這個count。最後檔案目錄和程式碼如下:
// store.jsexport const createStore = (reducer) => { let currentState = {} let observers = [] //觀察者佇列 function getState() { return currentState } function dispatch(action) { currentState = reducer(currentState, action) observers.forEach(fn => fn()) } function subscribe(fn) { observers.push(fn) } dispatch({ type: '@@REDUX_INIT' }) //初始化store資料 return { getState, subscribe, dispatch }}//reducer.jsconst initialState = { count: 0}export function reducer(state = initialState, action) { switch(action.type) { case 'plus': return { ...state, count: state.count + 1 } case 'subtract': return { ...state, count: state.count - 1 } default: return initialState }}//react-redux.jsimport React from 'react'import PropTypes from 'prop-types'export class Provider extends React.Component { // 需要宣告靜態屬性childContextTypes來指定context物件的屬性,是context的固定寫法 static childContextTypes = { store: PropTypes.object } // 實現getChildContext方法,返回context物件,也是固定寫法 getChildContext() { return { store: this.store } } constructor(props, context) { super(props, context) this.store = props.store } // 渲染被Provider包裹的元件 render() { return this.props.children }}export function connect(mapStateToProps, mapDispatchToProps) { return function(Component) { class Connect extends React.Component { componentDidMount() { //從context獲取store並訂閱更新 this.context.store.subscribe(this.handleStoreChange.bind(this)); } handleStoreChange() { // 觸發更新 // 觸發的方法有多種,這裡為了簡潔起見,直接forceUpdate強制更新,讀者也可以通過setState來觸發子元件更新 this.forceUpdate() } render() { return ( <Component // 傳入該元件的props,需要由connect這個高階元件原樣傳回原元件 { ...this.props } // 根據mapStateToProps把state掛到this.props上 { ...mapStateToProps(this.context.store.getState()) } // 根據mapDispatchToProps把dispatch(action)掛到this.props上 { ...mapDispatchToProps(this.context.store.dispatch) } /> ) } } //接收context的固定寫法 Connect.contextTypes = { store: PropTypes.object } return Connect }} //index.jsimport React from 'react'import ReactDOM from 'react-dom'import App from './App'import { Provider } from './react-redux'import { createStore } from './store'import { reducer } from './reducer'ReactDOM.render( <Provider store={createStore(reducer)}> <App /> </Provider>, document.getElementById('root'));//App.jsimport React from 'react'import { connect } from './react-redux'const addCountAction = { type: 'plus'}const mapStateToProps = state => { return { count: state.count }}const mapDispatchToProps = dispatch => { return { addCount: () => { dispatch(addCountAction) } }}class App extends React.Component { render() { return ( <div className="App"> { this.props.count } <button onClick={ () => this.props.addCount() }>增加</button> </div> ); }}export default connect(mapStateToProps, mapDispatchToProps)(App)執行專案,點選增加按鈕,能夠正確的計數,OK大成功,我們整個redux、react-redux的流程就走通了
三. redux Middleware實現
上面redux和react-redux的實現都比較簡單,下面我們來分析實現稍困難一些的「redux中介軟體」。所謂中介軟體,我們可以理解為攔截器,用於對某些過程進行攔截和處理,且中介軟體之間能夠串聯使用。在redux中,我們中介軟體攔截的是dispatch提交到reducer這個過程,從而增強dispatch的功能。
我查閱了很多redux中介軟體相關的資料,但最後發現沒有一篇寫的比官方文件清晰,文件從中介軟體的需求到設計,從概念到實現,每一步都有清晰生動的講解。下面我們就和文件一樣,以一個記錄日誌的中介軟體為例,一步一步分析redux中介軟體的設計實現。
我們思考一下,如果我們想在每次dispatch之後,列印一下store的內容,我們會如何實現呢:
1. 在每次dispatch之後手動列印store的內容store.dispatch({ type: 'plus' })console.log('next state', store.getState())
這是最直接的方法,當然我們不可能在專案裡每個dispatch後面都貼上一段列印日誌的程式碼,我們至少要把這部分功能提取出來。
2. 封裝dispatchfunction dispatchAndLog(store, action) { store.dispatch(action) console.log('next state', store.getState())}
我們可以重新封裝一個公用的新的dispatch方法,這樣可以減少一部分重複的程式碼。不過每次使用這個新的dispatch都得從外部引一下,還是比較麻煩。
3. 替換dispatchlet next = store.dispatchstore.dispatch = function dispatchAndLog(action) { let result = next(action) console.log('next state', store.getState()) return result}
如果我們直接把dispatch給替換,這樣每次使用的時候不就不需要再從外部引用一次了嗎?對於單純列印日誌來說,這樣就足夠了,但是如果我們還有一個監控dispatch錯誤的需求呢,我們固然可以在列印日誌的程式碼後面加上捕獲錯誤的程式碼,但隨著功能模組的增多,程式碼量會迅速膨脹,以後這個中介軟體就沒法維護了,我們希望不同的功能是「獨立的可拔插的」模組。
4. 模組化// 列印日誌中介軟體function patchStoreToAddLogging(store) { let next = store.dispatch //此處也可以寫成匿名函式 store.dispatch = function dispatchAndLog(action) { let result = next(action) console.log('next state', store.getState()) return result }} // 監控錯誤中介軟體function patchStoreToAddCrashReporting(store) { //這裡取到的dispatch已經是被上一個中介軟體包裝過的dispatch, 從而實現中介軟體串聯 let next = store.dispatch store.dispatch = function dispatchAndReportErrors(action) { try { return next(action) } catch (err) { console.error('捕獲一個異常!', err) throw err } }}
我們把不同功能的模組拆分成不同的方法,通過在方法內「獲取上一個中介軟體包裝過的store.dispatch實現鏈式呼叫」。然後我們就能通過呼叫這些中介軟體方法,分別使用、組合這些中介軟體。
patchStoreToAddLogging(store)patchStoreToAddCrashReporting(store)
到這裡我們基本實現了可組合、拔插的中介軟體,但我們仍然可以把程式碼再寫好看一點。我們注意到,我們當前寫的中介軟體方法都是先獲取dispatch,然後在方法內替換dispatch,這部分重複程式碼我們可以再稍微簡化一下:我們不在方法內替換dispatch,而是返回一個新的dispatch,然後讓迴圈來進行每一步的替換。
5. applyMiddleware改造一下中介軟體,使其返回新的dispatch而不是替換原dispatch
function logger(store) { let next = store.dispatch // 我們之前的做法(在方法內直接替換dispatch): // store.dispatch = function dispatchAndLog(action) { // ... // } return function dispatchAndLog(action) { let result = next(action) console.log('next state', store.getState()) return result }}
在Redux中增加一個輔助方法applyMiddleware ,用於新增中介軟體
function applyMiddleware(store, middlewares) { middlewares = [ ...middlewares ] //淺拷貝陣列, 避免下面reserve()影響原陣列 middlewares.reverse() //由於迴圈替換dispatch時,前面的中介軟體在最裡層,因此需要翻轉陣列才能保證中介軟體的呼叫順序 // 迴圈替換dispatch middlewares.forEach(middleware => store.dispatch = middleware(store) )}
然後我們就能以這種形式增加中介軟體了:
applyMiddleware(store, [ logger, crashReporter ])
寫到這裡,我們可以簡單地測試一下中介軟體。我建立了三個中介軟體,分別是logger1、thunk、logger2,其作用也很簡單,列印logger1 -> 執行非同步dispatch -> 列印logger2,我們通過這個例子觀察中介軟體的執行順序
//index.jsimport React from 'react';import ReactDOM from 'react-dom';import App from './App';import { Provider } from './react-redux'import { createStore } from './store'import { reducer } from './reducer'let store = createStore(reducer)function logger(store) { let next = store.dispatch return (action) => { console.log('logger1') let result = next(action) return result }}function thunk(store) { let next = store.dispatch return (action) => { console.log('thunk') return typeof action === 'function' ? action(store.dispatch) : next(action) }}function logger2(store) { let next = store.dispatch return (action) => { console.log('logger2') let result = next(action) return result }}function applyMiddleware(store, middlewares) { middlewares = [ ...middlewares ] middlewares.reverse() middlewares.forEach(middleware => store.dispatch = middleware(store) )}applyMiddleware(store, [ logger, thunk, logger2 ])ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById('root'));
發出非同步dispatch
function addCountAction(dispatch) { setTimeout(() => { dispatch({ type: 'plus' }) }, 1000)}dispatch(addCountAction)
輸出結果
可以看到,控制檯先輸出了中介軟體logger1的列印結果,然後進入thunk中介軟體列印了'thunk',等待一秒後,非同步dispatch被觸發,又重新走了一遍logger1 -> thunk -> logger2。到這裡,我們就基本實現了可拔插、可組合的中介軟體機制,還順便實現了redux-thunk。
6. 純函式之前的例子已經基本實現我們的需求,但我們還可以進一步改進,上面這個函式看起來仍然不夠"純",函式在函式體內修改了store自身的dispatch,產生了所謂的"副作用",從函數語言程式設計的規範出發,我們可以進行一些改造,借鑑react-redux的實現思路,我們可以把applyMiddleware作為高階函式,用於增強store,而不是替換dispatch:
先對createStore進行一個小改造,傳入heightener(即applyMiddleware),heightener接收並強化createStore。
// store.jsexport const createStore = (reducer, heightener) => { // heightener是一個高階函式,用於增強createStore //如果存在heightener,則執行增強後的createStore if (heightener) { return heightener(createStore)(reducer) } let currentState = {} let observers = [] //觀察者佇列 function getState() { return currentState } function dispatch(action) { currentState = reducer(currentState, action); observers.forEach(fn => fn()) } function subscribe(fn) { observers.push(fn) } dispatch({ type: '@@REDUX_INIT' })//初始化store資料 return { getState, subscribe, dispatch }}
中介軟體進一步柯里化,讓next通過引數傳入
const logger = store => next => action => { console.log('log1') let result = next(action) return result}const thunk = store => next =>action => { console.log('thunk') const { dispatch, getState } = store return typeof action === 'function' ? action(store.dispatch) : next(action)}const logger2 = store => next => action => { console.log('log2') let result = next(action) return result}
改造applyMiddleware
const applyMiddleware = (...middlewares) => createStore => reducer => { const store = createStore(reducer) let { getState, dispatch } = store const params = { getState, dispatch: (action) => dispatch(action) //解釋一下這裡為什麼不直接 dispatch: dispatch //因為直接使用dispatch會產生閉包,導致所有中介軟體都共享同一個dispatch,如果有中介軟體修改了dispatch或者進行非同步dispatch就可能出錯 } const middlewareArr = middlewares.map(middleware => middleware(params)) dispatch = compose(...middlewareArr)(dispatch) return { ...store, dispatch }}//compose這一步對應了middlewares.reverse(),是函數語言程式設計一種常見的組合方法function compose(...fns) { if (fns.length === 0) return arg => arg if (fns.length === 1) return fns[0] return fns.reduce((res, cur) =>(...args) => res(cur(...args)))}
程式碼應該不難看懂,在上一個例子的基礎上,我們主要做了兩個改造
使用compose方法取代了middlewares.reverse(),compose是函數語言程式設計中常用的一種組合函式的方式,compose內部使用reduce巧妙地組合了中介軟體函式,使傳入的中介軟體函式變成 (...arg) => mid3(mid1(mid2(...arg)))這種形式不直接替換dispatch,而是作為高階函式增強createStore,最後return的是一個新的store7.洋蔥圈模型之所以把洋蔥圈模型放到後面來講,是因為洋蔥圈和前邊中介軟體的實現並沒有很緊密的關係,為了避免讀者混淆,放到這裡提一下。我們直接放出三個列印日誌的中介軟體,觀察輸出結果,就能很輕易地看懂洋蔥圈模型。
const logger1 = store => next => action => { console.log('進入log1') let result = next(action) console.log('離開log1') return result}const logger2 = store => next => action => { console.log('進入log2') let result = next(action) console.log('離開log2') return result}const logger3 = store => next => action => { console.log('進入log3') let result = next(action) console.log('離開log3') return result}
執行結果
由於我們的中介軟體是這樣的結構:
logger1( console.log('進入logger1') logger2( console.log('進入logger2') logger3( console.log('進入logger3') //dispatch() console.log('離開logger3') ) console.log('離開logger2') ) console.log('離開logger1'))
因此我們可以看到,中介軟體的執行順序實際上是這樣的:
進入log1 -> 執行next -> 進入log2 -> 執行next -> 進入log3 -> 執行next -> next執行完畢 -> 離開log3 -> 回到上一層中介軟體,執行上層中介軟體next之後的語句 -> 離開log2 -> 回到中介軟體log1, 執行log1的next之後的語句 -> 離開log1
四. 總結 & 致謝❝
這就是所謂的"洋蔥圈模型"
❞
其實全文看下來,讀者應該能夠體會到,redux、react-redux以及redux中介軟體的實現並不複雜,各自的核心程式碼不過十餘行,但在這寥寥數行程式碼之間,蘊含了一系列程式設計思想與設計正規化 —— 觀察者模式、裝飾器模式、中介軟體原理、函式柯里化、函數語言程式設計。我們閱讀原始碼的意義,也就在於理解和體會這些思想。
全篇成文前後經歷一個月,主要參考資料來自同事分享以及多篇相關文章,在此特別感謝龍超大佬和於中大佬的分享。在考據細節的過程中,也得到了很多素未謀面的朋友們的解惑,特別是感謝Frank1e大佬在中介軟體柯里化理解上給予的幫助。真是感謝大家Thanks♪(・ω·)ノ