redux 原始碼研究:中介軟體

龍騎將楊影楓發表於2017-11-26

設計模式與 redux 中介軟體

中介軟體是代理/裝飾模式的一種的實踐方式,通過改造 store.dispatch 方法,可以攔截 action(代理)或新增額外功能(裝飾)。

突然發現 Javascript 裡的代理/裝飾模式的寫法蠻通用的....

對於建立的 store 物件,如果希望代理/裝飾 dispatch 函式,基本的格式如下:

  1. 新建一個變數指向 store.dispatch。
  2. 新建同名函式 dispach,接收引數為 action。
  3. 編寫自己的額外邏輯。
  4. 在 dispach 內部執行 oldDispatch,並返回( store.dispatch 是有返回值的)。
  5. 令 store.dispatch 指向新的 dispatch ,返回新的 store。

簡單的說:函式地址交換,在新函式中執行老函式。

const applyMyMiddlware = (store) => {
  // 1. 新建一個變數指向 store.dispatch
  const oldDispatch = store.dispatch;

  // 2. 新建 dispach,接收引數為 action
  const dispatch = (action) => {

    // 3. 編寫額外邏輯
    /* 
      ........
    */

    // 3.1 所謂的代理就是攔截引數 action,根據 action 來進行自己的操作
    // 3.2 所謂的裝飾就是不攔截 action,但是在這之前進行自己的邏輯處理
    // 3.3 注意物件中 this(如果有) 的指向問題

    // 4. 在 dispach 內部執行 oldDispatch,並返回。
    return oldDispatch(action);
    // 4.1 store.dispatch 是有返回值的,返回值型別是 action
  };
  //5 令 store.dispatch 指向新的 dispatch ,返回新的 store
  store.dispatch = dispatch;

  return store
  // 或者也可以這樣寫
  //  returen {
  //    ...store,
  //    dispatch
  //  } 
}複製程式碼

執行 store = applyMyMiddlware(store) 後, 呼叫 store.dispatch(action) 的結果便為代理/裝飾後的結果。

applyMiddleware 原始碼研究

redux 提供了官方載入中介軟體的函式 applyMiddleware,同時規定了中介軟體的寫法必須是:

({dispatch, getState}) => next => action => {
  // .... 中介軟體自己的邏輯

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

直到看原始碼之前,我只是單純的記住了這麼一長串的多重呼叫,並不理解為什麼。

而這種多重返回的原因,就在 applyMiddleware 的原始碼裡

import compose from './compose'

export default function applyMiddleware(...middlewares) {
  return (createStore) => (...args) => {
    const store = createStore(...args)
    let dispatch = () => {
      throw new Error(
        `Dispatching while constructing your middleware is not allowed. ` +
        `Other middleware would not be applied to this dispatch.`
      )
    }
    let chain = []

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }
    chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch
    }
  }
}複製程式碼

1. ({dispatch, getState})

分析一下 applyMiddleware 原始碼很容易找到 {dispatch, getState} 的來源:

  • 首先用傳入的 createStore 方法建立了 store 物件。此時 store 中有 store.dispatch 以及 store.getState 方法(subscribe 暫時不考慮)。

  • 初始化了一個 dispatch,但是中間塞了一個斷言。如果直接呼叫,就會報錯。

  • 定義了一個 chain 陣列和

  const middlewareAPI = {
    getState: store.getState,
    dispatch: (...args) => dispatch(...args)
  }複製程式碼

getState 可以獲得當前 state 狀態,dispatch 則是經過處理新增了新功能

  • 通過 map 函式,把每個中介軟體執行了一遍,傳入的引數就是 middlewareAPI。
 chain = middlewares.map(middleware => middleware(middlewareAPI))複製程式碼

因此,對於中介軟體:

const middleware = ({dispatch, getState}) => next => action => {
  // .... 中介軟體自己的邏輯

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

第一個引數 {dispatch, getState} 顯然是 (middlewareAPI),返回值為

 next => action => {
  // .... 中介軟體自己的邏輯

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

這麼呼叫的好處是,在返回值(也是個函式)內部依然可以呼叫到 store.getState 方法閉包

2. next 是什麼(上):喪心病狂的 compose

經過 map 遍歷,chain 陣列此刻的值為:

[
  next => action => {
    // .... 中介軟體自己的邏輯

    return next(action);
  },
  next => action => {
    // .... 中介軟體自己的邏輯

    return next(action);
  } 
  // ...其他中介軟體
]複製程式碼

這麼一種形式。

dispatch = compose(...chain)(store.dispatch),是整段程式碼中最不()好()理()解()的部分。

貼一下 compose 函式原始碼:

export default function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg
  }

  if (funcs.length === 1) {
    return funcs[0]
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}複製程式碼

根據其原始碼,compose 應對了 3 種陣列的情況。

對於 dispatch = compose(...chain)(store.dispatch)

  • 如果 chian 的長度是 0(也就是未傳入中介軟體),等價於 dispatch = (args=> args)(store.dispatch) ,即 dispatch = store.dispatch。
  • 如果 chian 的長度為 1,也就是說為
    [
      next => action => {
        console.log('0號中介軟體')
        return next(action)
      }
    ];複製程式碼
    如果用一個變數 temp0 指向上述函式的地址,compose(...chain)(store.dispatch) 便等同於 temp0(next = store.disptach)此時 next 為 store.dispatch,next(action) 便等同於 store.dispatch(action)。

因此 temp0(next = store.disptach) 的返回值應為:

  dispatch = temp0(next = store.disptach) = action => {
    console.log('0號中介軟體');
    return store.dispatch(action);
  }複製程式碼

結論:

無中介軟體的情況下, dispatch 為 store.dispatch在只有一箇中介軟體的情況下,next 的值是 store.dispatch。把 console.log('0號中介軟體'); 換成其他的邏輯,中介軟體就可以在保證原本 store.dispatch 功能的情況下,實現自己的額外功能。

3. next 是什麼(下):庶民推理

接下來的推理相當繞,我也是捋了半天才捋明白。

懶人請直接看結論。

結論1:當多於 2 個的元素的時候,傳入的 action 按照初始化時中介軟體陣列的順序依次經過每個中介軟體,最後依靠執行 store 原本的 dispatch (當然前提是此 action 沒有被中途攔截)離開,完成整個流程。

注:單向資料流

結論2:對於每一箇中介軟體({dispatch, getState}) => next => action => {..... return next(action)} ,如果不做攔截 action 最早傳入的 action;如果進行攔截,後面的 action 為攔截後生成的 action。最後一位中介軟體的 next 為 store.dispatch,其餘中介軟體的 next 都是下一位中介軟體 action => {..... return next(action)} 的部分。

以下是無聊的推理過程:

  • 假設 chain 的長度 為 2 。

即:

  // 先假設只有 2 個元素
  chain = [
      next => action => {
          console.log('0 號中介軟體');
          return next(action);
      }, 
      next => action => {
          console.log('1 號中介軟體')
          return next(action);
    }]複製程式碼

腦補 compose 的執行過程。

第 1 步. return funcs.reduce((a, b) => (...args) => a(b(...args)))

因為沒有初始值,所以 a b 為最開始的兩個元素。即

return (...args) => a(b(...args)));

compose(...chian) = (...args) => a(b(...args)));

陣列的 reduce 方法很有意思,接收一個回撥函式(和一個初始值)做引數。該回撥函式的第一個引數便是該回撥函式上一次執行的結果。如果有初始值用初始值,如果沒有初始值則直接從第二個元素開始迴圈,初始值為第一個元素。常用於解決遞迴的邏輯,和 map 相比最大的好處是不用引入外界變數。

第 2 步. 根據 JavaScript 的語法,先執行 b(...args)

b 為

next=> action => {
    console.log('1 號中介軟體')
    return next(action);
  }複製程式碼

所以 b(...args) 的執行結果為

action => {
    console.log('1 號中介軟體')
    return (...args)(action);
  }複製程式碼

第 3 步. 執行 a(b(...args))

a 為:

next => action => {
    console.log('0 號中介軟體');
    return next(action);
  }複製程式碼

a(b(...args)) 就等同於

  action => {
      console.log('0 號中介軟體');
      return (
        // 用 b(...args) 的返回值代替 next
        action => {
          console.log('1 號中介軟體')
          return (...args)(action);
        }
      )(action)
}複製程式碼

compose(...chian)

  (...args) => action => {
      console.log('0 號中介軟體');
      return (
        // 用 b(...args) 的值代替 next
        action => {
          console.log('1 號中介軟體')
          return (...args)(action);
        }
      )(action)複製程式碼

第 4 步:dispatch

dispatch 等價於 compose(...chian)(store.dispatch) 等價於

  // 因為 compose(...chian)(store.dispatch) 的引數 ...args 等於 store.dispatch
  // 去掉 (...args)=> 並應用 store.dispatch 替換 (...args)(action) 為 (store.dispatch)(action)
  dispatch = action => {
      console.log('0 號中介軟體');
      return (action => {
        console.log('1 號中介軟體')
          // 使用 store.dispatch 代替 ...args
        return (store.dispatch)(action);
      })(action);複製程式碼

換個寫法:

dispatch = action => {
  console.log('0 號中介軟體');

  const next = action => {
    console.log('1 號中介軟體');
    return store.dispatch(action);
  }

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

3.1. 遞迴與中介軟體呼叫

現在考慮 chain 的陣列多於 2 個元素的情況,例如 chain = [a, b, c]

由 3 得知 , b c 的執行結果是

dispatchBC = action => {
  console.log('b');

  const next = action => {
    console.log('c');
    return store.dispatch(action);
  }

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

因此 compose(...[a, b, c]) 的執行結果等同與

dispatch = action => {
  console.log(a);

  const next = dispatchBC;

  return next(action);

}複製程式碼

讚美遞迴!

後記:為什麼要採用這種複雜的呼叫?

沒看懂 applyMiddleware 原始碼之前總是覺得作者故意找茬,為什麼不能用訂閱/釋出的模式去寫?

比如像這個樣子:

const applyObserver = (...middlewares) {
    // .... 建立 store 的邏輯
    const oldDispatch = store.dispatch;
    const dispatch = (action) => {
        for(middleware in middlewares) {
            middleware.call(null, {dispatch: store.dispatch, getState: store.getState})
        }
        return oldDispatch(action)
    }
    return {
        ...store,
        dispatch
    }
}複製程式碼

看懂了以後發自內心的讚歎:臥槽太牛逼了。

使用 applyObserver 只能實現裝飾模式,無法實現對 action 的攔截與轉換。如果每一箇中介軟體都能消費或者產生新的 action,那麼一個 action 傳入後會產生多個 action,而這與 redux 單項資料流 的理念相悖。applyMiddleware 的寫法最大程度的保證了 action 的流向,每一步的資料變化都是可以追蹤的。

這邊是 redux 中介軟體使用多重返回函式的真正原因。

這也是 compose 為什麼這麼牛逼的原因。

後記2:函式與 JavaScript

我剛開始用 js 的時候有位高人對我說:JavaScript 其實並不是正統的 OOP 函式。

確實,直到 ES6 裡面才有了 extends 關鍵字進行繼承,ES6 之前只有 proTotype。而所謂的 class 也不過是轉成函式,進行呼叫。雖然 JavaScript 經過 es6 的革新和 es7 的強化後寫法不再那麼反人類,但是離純 OOP 的語言比如 Java 還有不小的差距。

研究過 dva 和 redux 的部分原始碼之後,我發現 JavaScript 框架的作者在解決通用性問題的方式,都是通過提供了組合的函式而不是一個組合過的類( dva 處理非同步呼叫的時候是返回了一個 takeEvery 的函式)。

沒有什麼不是一個函式可以解決的問題,如果有就再來一個

這個就和目前的 OOP 思想差別相當大了,瞄準的是功能而不是物件。

對於 Java,雖然可以使用反射實現動態呼叫,但是類必須真實存在的;
對於 JavaScript,有沒有類無所謂,沒有就自己造一個。只要產生的物件能嘎嘎叫並像鴨子一樣走路,那就是鴨子(著名的鴨式辨型)。
現在前端推廣 stateless 元件和高階元件,寫來寫去也是函式。

OOP 用多了,有的時候是有思維盲區存在的;換個角度從函式出發,說不定真的有驚喜。

不過首要解決的還是如何習慣閱讀返回值是函式的函式,坦白的說在這一點上我經常被繞暈,尤其是函式套函式的情況,超過 2 層必蒙圈(捂臉)。所以多重函式一直是我盡力避免或者繞開的問題,但是現在看來這才是 JavaScript 的精髓。

相關文章