Redux 是目前 React 系統中最常用的數據管理工具,它落實並發揚了 Flux 的數據單向流動模式,被實踐證明為一種成熟可用的模式。
盡管承受着一些非議,Redux 在 React 數據管理界的地位仍然沒有被取代。我聽到的針對 Redux 最多的非議是它需要遵守的規則和步驟太多,讓人們覺得束手束腳。然而個人覺得這正是 Redux 的意思所在,項目大了,如果整體數據流動不遵守規則,就容易亂。數據單向流動模式配合 Redux 規范仍然是一個可行的方案,當然,完全擁抱 mutable 的 Mobx 和 Vuex 也有他們的優勢,關於他們之間的對比這里暫且不多做介紹。今天我們的重點是制作我們自己的 Redux,從而深入了解它的思想和原理。
Redux 的主要思想是讓系統中的數據按照統一的規則流動,這樣所有的操作就都有跡可循,任何 View 上對系統數據狀態的更改都要通過 Action 被 Dispatch 到 Store,通過 Reducer 定義的邏輯去更改 State,然后再去更新View。
我們可以看到 Redux 的核心是 Store,我們就從它入手來寫自己的 Redux。Store 需要有 State,需要提供 Dispatch 方法來接收 Action,需要根據使用者提供的 Reducer 響應 Action,還要能夠在 State 變化的時候通知到外部的觀察者。我們先來看看典型的 Action 和 Reducer:
Action:
{ type: ADD_TODO, text: 'Build my first Redux app' } Reducer: function todoApp(state, action) { switch(action.type) { case ADD_TODO: return Object.assign({}, state, { todos: [ ...state.todos, { text: action.text, completed: false } ] }) default: return state } }
我們可以看到,Action 是普通的對象,它需要有一個 type 屬性來指示要做的動作類型,Reducer 是一個方法,接收當前的 State 和要做的 Action,定義邏輯給出新的 State。
通過應用閉包和觀察者模式,我們的 Redux 核心 createStore 方法並不難寫:
export default function(reducer) { let state = undefined let listeners = [] function subscribe(listener) { listeners.push(listener) return function unsubscribe() { listeners.splice(listeners.indexOf(listener), 1) } } function dispatch(action) { state = reducer(state, action) listeners.map(listener => listener()) } function getState() { return state } return { dispatch, subscribe, getState } }
有了核心部分,接下來是如何讓 View 也就是 React components 拿到 state,能夠在 state 更新的時候被更新,以及能夠使用 dispatch 方法來發出 Action 指令。Redux 需要使用者將 App 包裹在 Redux 的 Provider 中,提供自己創建的 Store 作為屬性,再讓使用者用 connect 來包裹 component 為它拿到 state 數據和 dispatch 方法:
const store = createStore(rootReducer) ReactDOM.render(( <Provider store={store}> <App /> </Provider> ), document.getElementById('root')) function mapStateToProps(state) { return { count: state.count } } function mapDispatchToProps(dispatch) { return { increment: () => dispatch({type: 'increment'}) } } export default connect(mapStateToProps, mapDispatchToProps)(Counter)
為了讓 Component Tree 中任何位置的 Component 都能接收到信息,我們需要 React Context 的幫助,我們的 Provider 將使用者生成的 Store 注入 Context,connect 方法為 Component 拿到相應 Context 中的 Store 信息。以前的 React Context 在 Context 更新時不能確保系統中所有對 Context 的引用都得到更新,詳細情況可以參考 https://reactjs.org/docs/legacy-context.html#updating-context ,因而當時實現 State 更新的方式比較復雜,每個 Component 都需要獨立訂閱 Store。新的 React Context API 解決了 Context 更新問題,我們寫起來也就容易了很多,只要在 Provider 中訂閱更新就可以了:
Provider:
import React, { useState, useEffect } from 'react' export const MyReduxContext = React.createContext() export default function(props) { const { store } = props const [ provider, setProvider ] = useState({state: store.getState(), dispatch: store.dispatch}) useEffect(() => { return store.subscribe(() => setProvider({state: store.getState(), dispatch: store.dispatch})) }) return ( <MyReduxContext.Provider value={provider}> { props.children } </MyReduxContext.Provider> ) }
Connect:
import React from 'react' import { MyReduxContext } from './Provider'; export default function(mapStateToProps, mapDispatchToProps) { return function(WrappedComponent) { return function(props) { return ( <MyReduxContext.Consumer> { ({ state, dispatch }) => { return <WrappedComponent {...mapStateToProps(state, props)} {...mapDispatchToProps(dispatch, props)} /> } } </MyReduxContext.Consumer> ) } } }
這里我們用到了 React Hooks 的 useState 和 useEffect,注意 state 和 dispatch 都需要被放在 Context 中傳遞。connect 方法的兩個參數 mapStateToProps 和 mapDispatchToProps,正如他們的名字,是用來把 state 和 dispatch 轉化成 Component 需要的樣子。
Connect 方法是現在 Redux 提供的連接 Component 的方式,然而現在我們有了 React Hooks,是否有辦法自定義 Hooks 來連接呢?借用 useContext 我們可以很簡單地實現:
import { useContext } from 'react' import { MyReduxContext } from './Provider' export default function useStore() { const { state, dispatch } = useContext(MyReduxContext) return [ state, dispatch ] }
使用時也很簡單:
const [ state, dispatch ] = useStore() const { count } = mapStateToProps(state) const { increment, reset } = mapDispatchToProps(dispatch)
到此為止我們的 Redux 主流程工具已經完成,接下來我們來看 Redux 的重要概念 Middleware。Middleware是從 Redux 的流程規則中應運而生的,既然所有的 Action 都要流經 Store,那如果我們在 Store 中設置一個可插入的裝置,就可以讓人們根據需要加入各種管道方法,最常見的有記錄日志,報告錯誤,異步請求處理和路由等。
這個裝置需要將 middleware 們一個個插入到 Store 的 Dispatch 管道中,讓 Action 一個個地流經他們,最后才被真正的 Dispatch 給到 Reducer。為了靈活可靠地完成這個任務,我們的 Redux 需要做很多讓人頭暈的工作,准備好迎接挑戰了嗎?
首先,為了將 middleware 們組合起來,我們需要將他們的流程邏輯嵌套在一起成為一個新的 Dispatch 方法,這就需要用到 Compose 方法。
d = compose(a, b, c) d(x) === a(b(c(x)))
我們的 middleware 如果長這樣:
function middlewareWrapper(nextDispatch) { return function(action) { some logic ... nextDispatch(action); } }
那么想象一下 compose(...middlewares)(store.dispatch) 會得到什么?帶入上面的 compose 方法的定義仔細想一下。沒錯,就是 middleware 們內層函數的邏輯嵌套,等待被執行的一個新的 Dispatch 方法。
Compose 並不難實現,我們可以自己簡單寫一下。
function compose(...funcs) { return function(...parameters) { let returned = null for (let i = funcs.length - 1; i >= 0; i --) { let func = funcs[i] if (i === 0) { returned = func(...parameters) } else { returned = func(returned) } } return returned } }
知道了如何嵌套 middleware,我們就可以着手改造我們的 createStore,將 middlewares 作為額外參數傳入。
export default function createStore(reducer, middlewares) { let listeners = [] function subscribe(listener) { } function dispatch(action) { state = reducer(state, action) listeners.map(listener => listener()) } function getState() { return state } ------ const storeContext = { getState: getState, dispatch: (action) => dispatch(action) } const chain = middlewares.map(middleware => middleware(storeContext)) dispatch = compose(...chain)(store.dispatch) ------ return { dispatch, subscribe, getState } }
這里將 storeContext 傳給 middleware 是希望它們能夠拿到 store 的 state 和 dispatch,注意這里的 dispatch 指向的是嵌套后的新的 dispatch,既然多了一步 storeContext 的封裝,middleware 們也就又多了一層包裹,最終變成這樣:
function storeWrapper(store) { function middlewareWrapper(nextDispatch) { return function(action) { some logic ... nextDispatch(action) } } }
到此為止 middleware 的內部機制我們已經做好了,可是 Redux 傳入 middlewares 的方式並不是這樣的,為了使用起來更靈活,Redux 提供了 applyMiddleware 方法,它接收 middlewares 作為參數,返回一個接收 createStore 參數的包裝方法,將 createStore 包裝為一個新的 createStore 方法,新的方法給出的 dispatch 方法就是嵌套好 middlewares 的新的 dispatch。
因而我們的 Provider 處可以寫做:
<Provider store={applyMiddleware(loggerMiddleware, thunkMiddleware)(createStore)(counterReducer)}>
而從另一個角度,原有的 createStore 方法也支持接收 applyMiddleware 的返回值作為 enhancer 參數,而且還需要有另一個參數 preloadedState 作為 state 的初始值,最終就變成了:
<Provider store={createStore(counterReducer, preloadedState, applyMiddleware(loggerMiddleware, thunkMiddleware))}>
我們的 createStore 方法最終是這樣的:
export default function createStore(reducer, preloadedState, enhancer) { if (enhancer) { return enhancer(createStore)(reducer, preloadedState) } ------ let state = preloadedState let listeners = [] function subscribe(listener) { listeners.push(listener) return function unsubscribe() { listeners.splice(listeners.indexOf(listener), 1) } } function dispatch(action) { if (typeof action !== 'object') { return; } state = reducer(state, action) listeners.map(listener => listener()) } function getState() { return state } return { dispatch, subscribe, getState } }
applyMiddleware 是這樣的:
export default function applyMiddleware(...middlewares) { return function(createStore) { return function(reducer, preloadedState, enhancer) { const store = createStore(reducer, preloadedState, enhancer) // this dispatch used in storeConotext should point to the final dispatch, // or else middlewares will use the real store's dispatch which skips middlewares let dispatch = store.dispatch const storeContext = { getState: store.getState, dispatch: (action) => dispatch(action) } const chain = middlewares.map(middleware => middleware(storeContext)) dispatch = compose(...chain)(store.dispatch) // purpose of applyMiddleware is to make a enhanced dispatch which can walk through middlewares return { ...store, dispatch } } } }
這里多次應用了類似 React HOC 的包裝思想,讓整個設計靈活巧妙,但是從某種程度上增加了理解的難度,讓我想到 Redux Saga 的巧妙設計和最初的難以理解。
看這部分時如果有細節不清楚還可以參考 Redux 官方對於 applyMiddleware 的介紹。
有了 applyMiddleware,接下來讓我們寫一下兩個最常用的 middleware —— logger 和 thunk。
初步的 logger 非常簡單,就是增加一個 console 的邏輯:
export default function loggerMiddleware(storeContext) { return function(nextDispatch) { return function(action) { nextDispatch(action) console.log(storeContext.getState()) } } }
注意,我們把 console.log 放在 nextDispatch 的后面,是希望它拿到此次 dispatch 之后的 state,當然也可以將之前的記錄下來在后面一起 console 出來,就更接近真正的 Redux logger 的做法了。
Redux thunk 是要讓 dispatch 接受 Action Creator 作為參數,詳細內容可以參考 https://github.com/reduxjs/redux-thunk#why-do-i-need-this ,它的實現也很簡單,只要做一個類型判斷就可以了:
export default function thunkMiddleware(storeContext) { return function(nextDispatch) { return function(action) { if (typeof action === 'function') { const { dispatch, getState } = storeContext action(dispatch, getState) nextDispatch(action) } else { nextDispatch(action) } } } }