reselect原始碼解讀

Juliiii發表於2019-03-03

背景

最近偶然想起了reselect這個庫,因為面試今日頭條的時候,面試官有問到,當時也回答上來了。只是有點好奇這個庫是怎麼做到記憶,從而達到快取的。所以開啟它的github看了一下,發現程式碼量不多,而且實現邏輯不難。所以就趁熱寫下這篇reselect原始碼閱讀。

開始

  • reselect是什麼?

    開始講解程式碼前,我覺得還是得介紹下reselect是什麼。因為其實不少react的初學者,很少會了解到這個庫。我也是之前偶然看到的。引用它的github的readme的話:

    Simple “selector” library for Redux (and others) inspired by getters in NuclearJS,subscriptionsin re-frame and this proposal from speedskater .

    • Selectors can compute derived data, allowing Redux to store the minimal possible state.
    • Selectors are efficient. A selector is not recomputed unless one of its arguments changes.
    • Selectors are composable. They can be used as input to other selectors.

    英文好的同學可以自己看看,我個人的理解reselect就是一個根據redux的state,來計算衍生資料的庫,並且這個庫是當衍生資料依賴的state發生了變化,才會被重新計算,不然就繼續用原來的。換句話說,這個庫有快取,記憶的作用。

    舉個例子:

    import { createSelector } from `reselect`
    
    const shopItemsSelector = state => state.shop.items
    const taxPercentSelector = state => state.shop.taxPercent
    
    const subtotalSelector = createSelector(
      shopItemsSelector,
      items => items.reduce((acc, item) => acc + item.value, 0)
    )
    
    const taxSelector = createSelector(
      subtotalSelector,
      taxPercentSelector,
      (subtotal, taxPercent) => subtotal * (taxPercent / 100)
    )
    
    export const totalSelector = createSelector(
      subtotalSelector,
      taxSelector,
      (subtotal, tax) => ({ total: subtotal + tax })
    )
    
    let exampleState = {
      shop: {
        taxPercent: 8,
        items: [
          { name: `apple`, value: 1.20 },
          { name: `orange`, value: 0.95 },
        ]
      }
    }
    
    console.log(subtotalSelector(exampleState)) // 2.15
    console.log(taxSelector(exampleState))      // 0.172
    console.log(totalSelector(exampleState))    // { total: 2.322 }
    
    複製程式碼

    舉了官網例子,redux是隻要維護items和 taxPercent這兩個資料,根據這兩個資料,可以計算出很多別的衍生資料。可能你也會說,不用這麼做也行,還有別的做法。這個說法沒錯是沒錯,比如我們可以在reducer或mapStateToProps這些地方,寫一個計算的函式,傳入items和taxPercent就可以了。但是這個缺點在於,每次state變化,就會導致計算執行一次。這樣就會導致很多無用的計算。如果計算不復雜,效能上的確沒多大的區別,反之,就會造成效能上的不足。而reselect幫我們做好了記憶快取的工作。即使state變化了,但是衍生資料的依賴的state中的資料沒有發生變化,計算是不會執行的。所以下面,我們講講它的原始碼,不過重點會講reselect是如何做到記憶化的。

  • 原始碼

    先介紹幾個基本的函式

    這個為預設的比較函式,採用===的比較方式。

    // 比較函式,採用全等的比較方式
    function defaultEqualityCheck(a, b) {
      return a === b
    }
    複製程式碼

    這個函式是用來比較前後的依賴值是否發生變化

    /**
     * 比較前後的引數是否相等
     * 
     * @param {any} equalityCheck 比較函式,預設採用上面說到的全等比較函式
     * @param {any} prev 上一份引數
     * @param {any} next 當前份引數
     * @returns 比較的結果,布林值
     */
    function areArgumentsShallowlyEqual(equalityCheck, prev, next) {
      // 先簡單比較下是否為null和引數個數是不是一致
      if (prev === null || next === null || prev.length !== next.length) {
        return false
      }
    
      // 這裡就用比較函式做一層比較,?的那個原始碼註釋,說用for迴圈,而不用forEach這些,因為forEach, return後還是會繼續迴圈, 而for會終止。當資料量大的時候,效能提升明顯
      // Do this in a for loop (and not a `forEach` or an `every`) so we can determine equality as fast as possible.
      const length = prev.length
      for (let i = 0; i < length; i++) {
        // 不相等就return false
        // 這裡提一下,官方Readme裡的一些F&Q中,基於使用了redux和預設的比較函式
        // (1) 有問到為什麼state發生變化了,卻不更新資料。那是因為使用者
        // 的reducer沒有返回一個新的state。這裡使用預設的比較函式比較就會得出先後資料是一致的,所以就不會更新。
        // 比如往todolist裡插入一個todo,如果只是 state.todos.push(todo)的話,那prev.todos和
        // state.todos還是指向同一個引用,所以===比較是true, 故不會更新
        // (2) 也有問到為什麼state沒有變化,但老是重新計算一次。那是因為state中某個屬性經過filter或者別的操作後
        // 與原來的屬性還是一樣,但由於是不同的引用了,所以===比較還是會返回false,就會導致重新計算。
        // 所以源頭都是預設的比較函式,如果大家需要根據業務需求自定義自己的比較函式的話,也是可以的。下面會繼續說
        if (!equalityCheck(prev[i], next[i])) {
          return false
        }
      }
    
      return true
    }
    複製程式碼

    這個函式,我感覺就是判斷傳入的inputSelector是不是函式,如果不是,就報錯。。。

    /**
     * 這個感覺就是拿來判斷傳入的inputSelector(reselect如是說,個人感覺就是獲取依賴的函式)
     * 的型別是不是函式,如果有誤就拋錯誤。反之就,直接返回func
     * 
     * @param {any} funcs 
     * @returns 
     */
    function getDependencies(funcs) {
      const dependencies = Array.isArray(funcs[0]) ? funcs[0] : funcs
    
      if (!dependencies.every(dep => typeof dep === `function`)) {
        // 報錯的內容類似 function,string,function....
        const dependencyTypes = dependencies.map(
          dep => typeof dep
        ).join(`, `)
        throw new Error(
          `Selector creators expect all input-selectors to be functions, ` +
          `instead received the following types: [${dependencyTypes}]`
        )
      }
    
      return dependencies
    }
    複製程式碼

    這裡要重點說說了,defaultMemoize這個函式接受兩個引數,第一個是根據依賴值計算出衍生值的方法,也就是我們createSelector時傳入的最後一個函式,第二個就是比較函式,如果不傳入話,就預設使用我們之前說的defaultEqualityCheck,即採用全等的方式去比較。然後這個函式返回了一個閉包,這個閉包能記住該函式作用域定義的兩個變數,lastArgs和lastResult,一個是上一份的依賴值 ,一個是上一次計算得到的結果。而這個閉包的作用就是根據傳入的新的依賴值,通過我們之前說的areArgumentsShallowlyEqual來比較新舊的依賴值,如果依賴值發生了變化,就呼叫func,來計算出新的衍生值,並儲存到lastResult中,自然,lastArgs儲存這次的依賴值,方便下一次比較使用。那麼從這裡就可以看到,reselect的記憶化的根本做法就是閉包,通過閉包的特性,來記憶上一次的依賴值和計算結果,根據比較結果,來決定是重新計算,還是使用快取。那這個庫,最核心的程式碼,就是?的了,思想就是閉包。

    /**
     * 預設的記憶函式
     * 
     * @export
     * @param {any} func 根據依賴的值,計算出新的值的函式
     * @param {any} [equalityCheck=defaultEqualityCheck] 比較函式,這裡可以自定義
     * @returns function
     */
    export function defaultMemoize(func, equalityCheck = defaultEqualityCheck) {
      // 儲存上一次計算得到的結果和依賴的引數
      let lastArgs = null
      let lastResult = null
      // we reference arguments instead of spreading them for performance reasons
      // 返回一個函式
      return function () {
        // 該函式執行的時候,會先對上一份引數和當前的引數做個比較,比較方式由equalityCheck決定,如果使用者不自定義的話,預設採用全等比較
        if (!areArgumentsShallowlyEqual(equalityCheck, lastArgs, arguments)) {
          // apply arguments instead of spreading for performance.
          // 如果是發生了改變,重新計算值,並存到lastResult中,下次如果沒變的話可以直接返回
          lastResult = func.apply(null, arguments)
        }
    
        // 將當前的引數儲存到lastArgs中,下次使用
        lastArgs = arguments
    
        // 返回結果
        return lastResult
      }
    }
    複製程式碼

    這個函式就是能讓我們自定義的函式,比如自定義記憶函式memoize,或者自定義比較函式。而我們使用該庫的createSelector就是預設只傳入defaultMemoize,執行該函式得到的返回值。該函式內部用了兩次記憶函式,一個是我們傳入的,一個是defaultMemoize。第一個是為了根據我們傳入的記憶函式來快取資料,第二個是這個庫內部做一個優化。舉個例子,這個庫和redux一起使用,而我們使用redux的都知道,reducer是根據action.type來更新state的,如果reducer中沒有某個action.type的更新邏輯,那就會返回舊的state。所以這個時候通過defaultMemoize來加一層優化,可以針對該情況,減少計算的次數。

    /**
     * createSelector的建立函式
     * 
     * @export
     * @param {any} memoize 記憶函式
     * @param {any} memoizeOptions 其餘的一些option,比如比較函式
     * @returns function
     */
    export function createSelectorCreator(memoize, ...memoizeOptions) {
      return (...funcs) => {
        // 重新計算的次數
        let recomputations = 0
        // 取出計算的函式
        const resultFunc = funcs.pop()
        // 將所有獲取依賴的函式傳入getDependencies,判斷是不是都是函式
        const dependencies = getDependencies(funcs)
    
        // 這裡呼叫了memoize,傳入一個func和傳入的option,所以這裡是生成真正核心的計算程式碼
        // 而這個func就是我們自己定義的根據依賴,計算出資料的方法,也是我們createSelector時
        // 傳入的最後一個引數,同時也傳入memoizeOptions,一般是傳入自定義的比較函式
        // 
        // 而這個memoize返回的函式,我稱為真正的記憶函式,當被呼叫時,傳入的是我們傳入的inputSelector的返回值,
        // 而這個inputSelector一般是從store的state中取值,所以每次dispatch一個redux時
        // 會導致元件和store都會被connect一遍,而這個函式會被呼叫,比較上次的state和這次
        // 是不是一樣,是一樣就不計算了,返回原來的值,反之返回新計算的值。
        const memoizedResultFunc = memoize(
          function () {
            recomputations++
            // apply arguments instead of spreading for performance.
            return resultFunc.apply(null, arguments)
          },
          ...memoizeOptions
        )
    
        // 這裡是預設使用defaultMemoize,額,這裡傳入arguments應該是state和props,算是又做了一層優化
        // 因為reducer是不一定會返回一個新的state,所以state沒變的時候,真正的記憶函式就不用被呼叫。
        // If a selector is called with the exact same arguments we don`t need to traverse our dependencies again.
        const selector = defaultMemoize(function () {
          const params = []
          const length = dependencies.length
    
          // 根據傳入的inputSelector來從state中獲取依賴值
          for (let i = 0; i < length; i++) {
            // apply arguments instead of spreading and mutate a local list of params for performance.
            params.push(dependencies[i].apply(null, arguments))
          }
          // 呼叫真正的記憶函式
          // apply arguments instead of spreading for performance.
          return memoizedResultFunc.apply(null, params)
        })
    
        // 最後返回
        selector.resultFunc = resultFunc
        selector.recomputations = () => recomputations
        selector.resetRecomputations = () => recomputations = 0
        return selector
      }
    }
    複製程式碼

結語

以上就是reselect的原始碼解讀。這個庫也是比較容易閱讀的,因為程式碼總數就100來行,而且邏輯上不是很難理解。總結一句話,reselect是起到計算衍生值和優化效能的作用,它有點類似vue中的computed功能,而它的實現核心就是閉包。具體一點,就是比較前後的store的state,來決定是否更新衍生值,是,那就執行我們給予的更新邏輯來更新,不是,那就返回之前計算好的結果。原始碼地址:github.com/Juliiii/sou…, 歡迎大家star和fork,如有不對,請issue,謝謝

相關文章