redux真的不復雜——原始碼解讀

村上春樹發表於2019-03-03

前言

閱讀物件:使用過redux,對redux實現原理不是很理解的開發者。

在我實習入職培訓的時候,給我培訓的老哥就跟我說過,redux的核心原始碼很簡潔,建議我有空去看一下,提升對redux系列的理解。

入職一個多月了,已經參與了公司的不少專案,redux也使用了一段時間,對於redux的理解卻一直沒有深入,還停留在“知道怎麼用,但是不知道其核心原理”的階段。

所以就在github上拉了redux的原始碼,看了一會,發現東西確實不多,比較簡潔。

redux本身的功能是什麼

在專案中,我們往往不會純粹的使用redux,而是會配合其他的一些工具庫提升效率,比如react-redux,讓react應用使用redux更容易,類似的也有wepy-redux,提供給小程式框架wepy的工具庫。

但是在本文中,我們討論的範圍就純粹些,僅僅討論redux本身

redux本身有哪些作用?我們先來快速的過一下redux的核心思想(工作流程):

  • 將狀態統一放在一個state中,由store來管理這個state。
  • 這個store按照reducer的“shape”(形狀)建立。
  • reducer的作用是接收到action後,輸出一個新的狀態,對應地更新store上的狀態。
  • 根據redux的原則指導,外部改變state的最佳方式是通過呼叫store的dispatch方法,觸發一個action,這個action被對應的reducer處理,完成state更新。
  • 可以通過subscribe在store上新增一個監聽函式。每當呼叫dispatch方法時,會執行所有的監聽函式。
  • 可以新增中介軟體(中介軟體是幹什麼的我們後面講)處理副作用。

在這個工作流程中,redux需要提供的功能是:

  • 建立store,即:createStore()
  • 建立出來的store提供subscribedispatchgetState這些方法。
  • 將多個reducer合併為一個reducer,即:combineReducers()
  • 應用中介軟體,即applyMiddleware()

沒錯,就這麼多功能,我們看下redux的原始碼目錄:

redux的原始碼目錄

確實也就這麼多,至於compose,bindActionCreators,則是一些工具方法。

下面我們就逐個來看看createStorecombineReducersapplyMiddlewarecompose的原始碼實現。

建議開啟連結:redux原始碼地址,參照本文的解釋閱讀原始碼。

createStore的實現

這個函式的大致結構是這樣:

function createStore(reducer, preloadedState, enhancer) {
    if(enhancer是有效的){  // 這個我們後面會解釋,可以先忽略
        return enhancer(createStore)(reducer, preloadedState)
    } 
    
    let currentReducer = reducer // 當前store中的reducer
    let currentState = preloadedState // 當前store中儲存的狀態
    let currentListeners = [] // 當前store中放置的監聽函式
    let nextListeners = currentListeners // 下一次dispatch時的監聽函式
    // 注意:當我們新新增一個監聽函式時,只會在下一次dispatch的時候生效。
    
    //...
    
    // 獲取state
    function getState() {
        //...
    }
    
    // 新增一個監聽函式,每當dispatch被呼叫的時候都會執行這個監聽函式
    function subscribe() {
        //...
    }
    
    // 觸發了一個action,因此我們呼叫reducer,得到的新的state,並且執行所有新增到store中的監聽函式。
    function dispatch() {
        //...
    }
   
    //...
    
    //dispatch一個用於初始化的action,相當於呼叫一次reducer
    //然後將reducer中的子reducer的初始值也獲取到
    //詳見下面reducer的實現。
    
    
    return {
        dispatch,
        subscribe,
        getState,
        //下面兩個是主要面向庫開發者的方法,暫時先忽略
        //replaceReducer,
        //observable
    }
}
複製程式碼

可以看出,createStore方法建立了一個store,但是並沒有直接將這個store的狀態state返回,而是返回了一系列方法,外部可以通過這些方法(getState)獲取state,或者間接地(通過呼叫dispatch)改變state。

至於state呢,被存在了閉包中。(不理解閉包的同學可以先去了解一下先)

我們再來詳細的看看每個模組是如何實現的(為了讓邏輯更清晰,省略了錯誤處理的程式碼):

getState
function getState() {
    return currentState
}
複製程式碼

簡單到髮指。其實這很像物件導向程式設計中封裝只讀屬性的方法,只提供資料的getter方法,而不直接提供setter。(雖然這裡返回的是一個state的引用,你可以直接修改state,但是一般來說,redux不建議這樣做。)

subscribe
function subscribe(listener) {
    // 新增到監聽函式陣列,
    // 注意:我們新增到了下一次dispatch時才會生效的陣列
    nextListeners.push(listener)
    
    let isSubscribe = true //設定一個標誌,標誌該監聽器已經訂閱了
    // 返回取消訂閱的函式,即從陣列中刪除該監聽函式
    return function unsubscribe() {
        if(!isSubscribe) {
            return // 如果已經取消訂閱過了,直接返回
        }
        
        isSubscribe = false
        // 從下一輪的監聽函式陣列(用於下一次dispatch)中刪除這個監聽器。
        const index = nextListeners.indexOf(listener)
        nextListeners.splice(index, 1)
    }
}
複製程式碼

subscribe返回的是一個取消訂閱的方法。取消訂閱是非常必要的,當新增的監聽器沒用了之後,應該從store中清理掉。不然每次dispatch都會呼叫這個沒用的監聽器。

dispatch
function dispatch(action) {
    //呼叫reducer,得到新state
    currentState = currentReducer(currentState, action);
    
    //更新監聽陣列
    currentListener = nextListener;
    //呼叫監聽陣列中的所有監聽函式
    for(let i = 0; i < currentListener.length; i++) {
        const listener = currentListener[i];
        listener();
    }
}
複製程式碼

createStore這個方法的基本功能我們已經實現了,但是呼叫createStore方法需要提供reducer,讓我們來思考一下reducer的作用。

combineReducers

在理解combineReducers之前,我們先來想想reducer的功能:reducer接受一箇舊的狀態和一個action,當這個action被觸發的時候,reducer處理後返回一個新狀態。

也就是說 ,reducer負責狀態的管理(或者說更新)。在實際使用中,我們應用的狀態是可以分成很多個模組的,比如一個典型社交網站的狀態可以分為:使用者個人資訊,好友列表,訊息列表等模組。理論上,我們可以用一個reducer去處理所有狀態的維護,但是這樣做的話,我們一個reducer函式的邏輯就會太多,容易產生混亂。

因此我們可以將邏輯(reducer)也按照模組劃分,每個模組再細分成各個子模組,開發完每個模組的邏輯後,再將reducer合併起來,這樣我們的邏輯就能很清晰的組合起來。

對於我們的這種需求,redux提供了combineReducers方法,可以把子reducer合併成一個總的reducer。

來看看redux原始碼中combineReducers的主要邏輯:

function combineReducers(reducers) {
    //先獲取傳入reducers物件的所有key
    const reducerKeys = Object.keys(reducers)
    const finalReducers = {} // 最後真正有效的reducer存在這裡
    
    //下面從reducers中篩選出有效的reducer
    for(let i = 0; i < reducerKeys.length; i++){
        const key  = reducerKeys[i]
        
        if(typeof reducers[key] === 'function') {
            finalReducers[key] = reducers[key] 
        }
    }
    const finalReducerKeys = Object.keys(finalReducers);
    
    //這裡assertReducerShape函式做的事情是:
    // 檢查finalReducer中的reducer接受一個初始action或一個未知的action時,是否依舊能夠返回有效的值。
    let shapeAssertionError
  	try {
    	assertReducerShape(finalReducers)
  	} catch (e) {
    	shapeAssertionError = e
  	}
    
    //返回合併後的reducer
    return function combination(state= {}, action){
  		//這裡的邏輯是:
    	//取得每個子reducer對應的state,與action一起作為引數給每個子reducer執行。
    	let hasChanged = false //標誌state是否有變化
        let nextState = {}
        for(let i = 0; i < finalReducerKeys.length; i++) {
                    //得到本次迴圈的子reducer
            const key = finalReducerKeys[i]
            const reducer = finalReducers[key]
            //得到該子reducer對應的舊狀態
            const previousStateForKey = state[key]
            //呼叫子reducer得到新狀態
            const nextStateForKey = reducer(previousStateForKey, action)
            //存到nextState中(總的狀態)
            nextState[key] = nextStateForKey
            //到這裡時有一個問題:
            //就是如果子reducer不能處理該action,那麼會返回previousStateForKey
            //也就是舊狀態,當所有狀態都沒改變時,我們直接返回之前的state就可以了。
            hasChanged = hasChanged || previousStateForKey !== nextStateForKey
        }
        return hasChanged ? nextState : state
    }
} 
複製程式碼

為什麼需要中介軟體

在redux的設計思想中,reducer應該是一個純函式

維基百科關於純函式的定義:

程式設計中,若一個函式符合以下要求,則它可能被認為是純函式

  • 此函式在相同的輸入值時,需產生相同的輸出。函式的輸出和輸入值以外的其他隱藏資訊或狀態無關,也和由I/O裝置產生的外部輸出無關。
  • 該函式不能有語義上可觀察的函式副作用,諸如“觸發事件”,使輸出裝置輸出,或更改輸出值以外物件的內容等。

純函式的輸出可以不用和所有的輸入值有關,甚至可以和所有的輸入值都無關。但純函式的輸出不能和輸入值以外的任何資訊有關。純函式可以傳回多個輸出值,但上述的原則需針對所有輸出值都要成立。若引數是傳引用呼叫,若有對引數物件的更改,就會影響函式以外物件的內容,因此就不是純函式。

總結一下,純函式的重點在於:

  • 相同的輸入產生相同的輸出(不能在內部使用Math.random,Date.now這些方法影響輸出)
  • 輸出不能和輸入值以外的任何東西有關(不能呼叫API獲得其他資料)
  • 函式內部不能影響函式外部的任何東西(不能直接改變傳入的引用變數),即不會突變

reducer為什麼要求使用純函式,文件裡也有提到,總結下來有這幾點:

  • state是根據reducer建立出來的,所以reducer是和state緊密相關的,對於state,我們有時候需要有一些需求(比如列印每一次更新前後的state,或者回到某一次更新前的state)這就對reducer有一些要求。

  • 純函式更易於除錯

    • 比如我們除錯時希望action和對應的新舊state能夠被列印出來,如果新state是在舊state上修改的,即使用同一個引用,那麼就不能列印出新舊兩種狀態了。
    • 如果函式的輸出具有隨機性,或者依賴外部的任何東西,都會讓我們除錯時很難定位問題。
  • 如果不使用純函式,那麼在比較新舊狀態對應的兩個物件時,我們就不得不深比較了,深比較是非常浪費效能的。相反的,如果對於所有可能被修改的物件(比如reducer被呼叫了一次,傳入的state就可能被改變),我們都新建一個物件並賦值,兩個物件有不同的地址。那麼淺比較就可以了。

至此,我們已經知道了,reducer是一個純函式,那麼如果我們在應用中確實需要處理一些副作用(比如非同步處理,呼叫API等操作),那麼該怎麼辦呢?這就是中介軟體解決的問題。下面我們就來講講redux中的中介軟體。

中介軟體處理副作用的機制

中介軟體在redux中位於什麼位置,我們可以通過這兩張圖來看一下。

先來看看不用中介軟體時的redux工作流程:

redux工作流程_同步

  1. dispatch一個action(純物件格式)
  2. 這個action被reducer處理
  3. reducer根據action更新store(中的state)

而用了中介軟體之後的工作流程是這樣的:

redux工作流程_中介軟體

  1. dispatch一個“action”(不一定是標準的action)
  2. 這個“action”先被中介軟體處理(比如在這裡傳送一個非同步請求)
  3. 中介軟體處理結束後,再傳送一個"action"(有可能是原來的action,也可能是不同的action因中介軟體功能不同而不同)
  4. 中介軟體發出的"action"可能繼續被另一箇中介軟體處理,進行類似3的步驟。即中介軟體可以鏈式串聯。
  5. 最後一箇中介軟體處理完後,dispatch一個符合reducer處理標準的action(純物件action)
  6. 這個標準的action被reducer處理,
  7. reducer根據action更新store(中的state)

那麼中介軟體該如何融合到redux中呢?

在上面的流程中,2-4的步驟是關於中介軟體的,但凡我們想要新增一箇中介軟體,我們就需要寫一套2-4的邏輯。

如果我們需要多箇中介軟體,我們就需要考慮如何讓他們串聯起來。如果每次串聯都寫一份串聯邏輯的話,就不夠靈活,萬一需要增刪改或調整中介軟體的順序,都需要修改中介軟體串聯的邏輯。

所以redux提供了一種解決方案,將中介軟體的串聯操作進行了封裝,經過封裝後,上面的步驟2-5就可以成為一個整體,如下圖:

封裝中介軟體後的邏輯

我們只需要改造store自帶的dispatch方法。action發生後,先給中介軟體處理,最後再dispatch一個action交給reducer去改變狀態。

在redux中使用中介軟體

還記得redux 的createStore()方法的第三個引數enhancer嗎:

function createStore(reducer, preloadedState, enhancer) {
    if(enhancer是有效的){  
        return enhancer(createStore)(reducer, preloadedState)
    } 
    
    //...
}
複製程式碼

在這裡,我們可以看到,enhancer(可以叫做強化器)是一個函式,這個函式接受一個「普通createStore函式」作為引數,返回一個「加強後的createStore函式」。

這個加強的過程中做的事情,其實就是改造dispatch,新增上中介軟體。

redux提供的applyMiddleware()方法返回的就是一個enhancer。

applyMiddleware,顧名思義,「應用中介軟體」。輸入為若干中介軟體,輸出為enhancer。下面來看看它的原始碼:

function applyMiddleware(...middlewares) {
    // 返回一個函式A,函式A的引數是一個createStore函式。
    // 函式A的返回值是函式B,其實也就是一個加強後的createStore函式,大括號內的是函式B的函式體
    return createStore => (...args) => {
        //用引數傳進來的createStore建立一個store
        const store  = createStore(...args)
        //注意,我們在這裡需要改造的只是store的dispatch方法
        
        let dispatch = () => {  //一個臨時的dispatch
            					//作用是在dispatch改造完成前呼叫dispatch只會列印錯誤資訊
            throw new Error(`一些錯誤資訊`)
        } 
        //接下來我們準備將每個中介軟體與我們的state關聯起來(通過傳入getState方法),得到改造函式。
        const middlewareAPI = {
            getState: store.getState,
            dispatch: (...args) => dispatch(...args)
        }
        //middlewares是一箇中介軟體函式陣列,中介軟體函式的返回值是一個改造dispatch的函式
        //呼叫陣列中的每個中介軟體函式,得到所有的改造函式
        const chain = middlewares.map(middleware => middleware(middlewareAPI))
        
        //將這些改造函式compose(翻譯:構成,整理成)成一個函式
        //用compose後的函式去改造store的dispatch
        dispatch = compose(...chain)(store.dispatch)
        // compose方法的作用是,例如這樣呼叫:
        // compose(func1,func2,func3)
        // 返回一個函式: (...args) => func1( func2( func3(...args) ) )
        // 即傳入的dispatch被func3改造後得到一個新的dispatch,新的dispatch繼續被func2改造...
        
        // 返回store,用改造後的dispatch方法替換store中的dispatch
        return {
            ...store,
            dispatch
        }
    }
}
複製程式碼

總結一下,applyMiddleware的工作方式是:

  1. 呼叫(若干個)中介軟體函式,獲取(若干個)改造函式
  2. 把所有改造函式compose成一個改造函式
  3. 改造dispatch方法

中介軟體的工作方式是:

  • 中介軟體是一個函式,不妨叫做中介軟體函式
  • 中介軟體函式的輸入是store的getStatedispatch,輸出為改造函式(改造dispatch的函式)
  • 改造函式輸入是一個dispatch,輸出「改造後的dispatch

原始碼中用到了一個很有用的方法:compose(),將多個函式組合成一個函式。理解這個函式對理解中介軟體很有幫助,我們來看看它的原始碼:

function compose(...funcs) {
    // 當未傳入函式時,返回一個函式:arg => arg
    if(funcs.length === 0) {
        return arg => arg
    }
    
    // 當只傳入一個函式時,直接返回這個函式
    if(funcs.length === 1) {
        return funcs[0]
    }
    
    // 返回組合後的函式
    return funcs.reduce((a, b) => (...args) => a(b(...args)))
    
    //reduce是js的Array物件的內建方法
    //array.reduce(callback)的作用是:給array中每一個元素應用callback函式
    //callback函式:
    /*
     *@引數{accumulator}:callback上一次呼叫的返回值
     *@引數{value}:當前陣列元素
     *@引數{index}:可選,當前元素的索引
     *@引數{array}:可選,當前陣列
     *
     *callback( accumulator, value, [index], [array])
    */
}
複製程式碼

畫一張圖來理解compose的作用:

compose

在applyMiddleware方法中,我們傳入的「引數」是原始的dispatch方法,返回的「結果」是改造後的dispatch方法。通過compose,我們可以讓多個改造函式抽象成一個改造函式。

中介軟體的實現

作者注:本來只想講redux,但是講著講著卻發現:理解中介軟體,是理解redux的中介軟體機制的前提。

下面我們以redux-thunk為例,看看一箇中介軟體是如何實現的。

redux-thunk的功能

你可能沒用過redux-thunk,所以在閱讀原始碼前,我先簡要的講一下redux-thunk的作用:

正常的dispatch函式的引數action應該是一個純物件。像這樣:

store.dispatch({
    type:'REQUEST_SOME_THING',
    payload: {
        from:'bob',
    }
})
複製程式碼

使用了thunk之後,我們可以dispatch一個函式:

function logStateInOneSecond(name) {
    return (dispatch, getState, name) => {  // 這個函式會在合適的時候dispatch一個真正的action
        setTimeout({
            console.log(getState())
            dispatch({
                type:'LOG_OK',
                payload: {
                    name,
                }
            })
        }, 1000)
    }
}

store.dispatch(logStateInOneSecond('jay')) //dispatch的引數是一個函式
複製程式碼

為什麼需要這個功能?或者說「dispatch一個函式」能解決什麼問題?

從上面的例子中你會發現,如果dispatch一個函式,我們可以在這個函式內做任何我們想要的操作(非同步處理,呼叫介面等等),不受任何限制,為什麼?

因為我們「還沒有dispatch一個真正的action」,所以不會呼叫reducer,我們並沒有將副作用放在reducer中,而是在使用reducer之前就處理了副作用

如果你還不明白redux-thunk的功能,可以去它的github倉庫檢視更詳細的解釋。

redux-thunk的實現

如何實現redux-thunk中介軟體呢?

首先中介軟體肯定是改造dispatch方法,改造後的dispatch應該具有這樣的功能:

  1. 如果傳入的引數是函式,就執行這個函式。
  2. 否則,就認為傳入的是一個標準的action,就呼叫「改造前的dispatch」方法,dispatch這個action。

現在我們來看看redux-thunk的原始碼(8行有效程式碼):

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}
複製程式碼

如果三個箭頭函式讓你有點頭暈,我來幫你展開一下:

//createThunkMiddleware的作用是返回thunk中介軟體(middleware)
function createThunkMiddleware(extraArgument) {
    
    return function({ dispatch, getState }) { // 這是「中介軟體函式」
        
        return function(next) { // 這是中介軟體函式建立的「改造函式」
            
            return function(action) { // 這是改造函式改造後的「dispatch方法」
                if (typeof action === 'function') {
                  return action(dispatch, getState, extraArgument);
                }
                
                return next(action);
            }
        } 
    }
}
複製程式碼

再加點註釋?

function createThunkMiddleware(extraArgument) {
    
    return function({ dispatch, getState }) { // 這是「中介軟體函式」
        //引數是store中的dispatch和getState方法
        
        return function(next) { // 這是中介軟體函式建立的「改造函式」
            //引數next是被當前中介軟體改造前的dispatch
            //因為在被當前中介軟體改造之前,可能已經被其他中介軟體改造過了,所以不妨叫next
            
            return function(action) { // 這是改造函式「改造後的dispatch方法」
                if (typeof action === 'function') {
                  //如果action是一個函式,就呼叫這個函式,並傳入引數給函式使用
                  return action(dispatch, getState, extraArgument);
                }
                
                //否則呼叫用改造前的dispatch方法
                return next(action);
            }
        } 
    }
}
複製程式碼

講完了。可以看出redux-thunk嚴格遵循了redux中介軟體的思想:在原始的dispatch方法觸發reducer處理之前,處理副作用。

總結

至此,redux的核心原始碼已經講完了,最後不得不感嘆,redux寫的真的美,真tm的簡潔。

一句話總結redux的核心功能:「建立一個store來管理state」

關於中介軟體,我會嘗試著寫一篇《如何自己實現一個redux中介軟體》,更深入的理解redux中介軟體的意義。

關於store如何與其他框架(如react)共同工作,我會再寫一篇《react-redux原始碼解讀》的部落格探究探究這個問題。

敬請期待。


第一次更新(2018-09-12)

  • 新增了對compose()的原始碼解釋
  • 修正了錯誤的描述”改變state的唯一方式是觸發dispatch”;補充了關於unSubscribe的解釋
  • 新增了中介軟體的實現原理,以redux-thunk為例

相關文章