Open Juliiii opened 5 years ago
最近偶然想起了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.
Simple “selector” library for Redux (and others) inspired by getters in NuclearJS,subscriptionsin re-frame and this proposal from speedskater .
英文好的同学可以自己看看,我个人的理解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,来决定是否更新衍生值,是,那就执行我们给予的更新逻辑来更新,不是,那就返回之前计算好的结果。源码地址:https://github.com/Juliiii/source-plan, 欢迎大家star和fork,如有不对,请issue,谢谢。
背景
最近偶然想起了reselect这个库,因为面试今日头条的时候,面试官有问到,当时也回答上来了。只是有点好奇这个库是怎么做到记忆,从而达到缓存的。所以打开它的github看了一下,发现代码量不多,而且实现逻辑不难。所以就趁热写下这篇reselect源码阅读。
开始
reselect是什么?
开始讲解代码前,我觉得还是得介绍下reselect是什么。因为其实不少react的初学者,很少会了解到这个库。我也是之前偶然看到的。引用它的github的readme的话:
英文好的同学可以自己看看,我个人的理解reselect就是一个根据redux的state,来计算衍生数据的库,并且这个库是当衍生数据依赖的state发生了变化,才会被重新计算,不然就继续用原来的。换句话说,这个库有缓存,记忆的作用。
举个例子:
举了官网例子,redux是只要维护items和 taxPercent这两个数据,根据这两个数据,可以计算出很多别的衍生数据。可能你也会说,不用这么做也行,还有别的做法。这个说法没错是没错,比如我们可以在reducer或mapStateToProps这些地方,写一个计算的函数,传入items和taxPercent就可以了。但是这个缺点在于,每次state变化,就会导致计算执行一次。这样就会导致很多无用的计算。如果计算不复杂,性能上的确没多大的区别,反之,就会造成性能上的不足。而reselect帮我们做好了记忆缓存的工作。即使state变化了,但是衍生数据的依赖的state中的数据没有发生变化,计算是不会执行的。所以下面,我们讲讲它的源码,不过重点会讲reselect是如何做到记忆化的。
源码
先介绍几个基本的函数
这个为默认的比较函数,采用===的比较方式。
这个函数是用来比较前后的依赖值是否发生变化
这个函数,我感觉就是判断传入的inputSelector是不是函数,如果不是,就报错。。。
这里要重点说说了,defaultMemoize这个函数接受两个参数,第一个是根据依赖值计算出衍生值的方法,也就是我们createSelector时传入的最后一个函数,第二个就是比较函数,如果不传入话,就默认使用我们之前说的defaultEqualityCheck,即采用全等的方式去比较。然后这个函数返回了一个闭包,这个闭包能记住该函数作用域定义的两个变量,lastArgs和lastResult,一个是上一份的依赖值 ,一个是上一次计算得到的结果。而这个闭包的作用就是根据传入的新的依赖值,通过我们之前说的areArgumentsShallowlyEqual来比较新旧的依赖值,如果依赖值发生了变化,就调用func,来计算出新的衍生值,并存储到lastResult中,自然,lastArgs存储这次的依赖值,方便下一次比较使用。那么从这里就可以看到,reselect的记忆化的根本做法就是闭包,通过闭包的特性,来记忆上一次的依赖值和计算结果,根据比较结果,来决定是重新计算,还是使用缓存。那这个库,最核心的代码,就是👇的了,思想就是闭包。
这个函数就是能让我们自定义的函数,比如自定义记忆函数memoize,或者自定义比较函数。而我们使用该库的createSelector就是默认只传入defaultMemoize,执行该函数得到的返回值。该函数内部用了两次记忆函数,一个是我们传入的,一个是defaultMemoize。第一个是为了根据我们传入的记忆函数来缓存数据,第二个是这个库内部做一个优化。举个例子,这个库和redux一起使用,而我们使用redux的都知道,reducer是根据action.type来更新state的,如果reducer中没有某个action.type的更新逻辑,那就会返回旧的state。所以这个时候通过defaultMemoize来加一层优化,可以针对该情况,减少计算的次数。
结语
以上就是reselect的源码解读。这个库也是比较容易阅读的,因为代码总数就100来行,而且逻辑上不是很难理解。总结一句话,reselect是起到计算衍生值和优化性能的作用,它有点类似vue中的computed功能,而它的实现核心就是闭包。具体一点,就是比较前后的store的state,来决定是否更新衍生值,是,那就执行我们给予的更新逻辑来更新,不是,那就返回之前计算好的结果。源码地址:https://github.com/Juliiii/source-plan, 欢迎大家star和fork,如有不对,请issue,谢谢。