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