背景
最近偶然想起了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,謝謝。