reselect源码解读

背景

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值