源码阅读计划——每周学习一个lodash方法(difference)

5,094 阅读7分钟

前言

老是听人说阅读什么Vue,React,……XXX源代码,但是阅读这些框架的源代码是真的很难,而且这些优秀的框架中势必涉及了很多算法、设计模式和数据结构知识。一上来就读这么难的源代码,你真的读的进去吗?在缺乏一定基础的情况下,盲目的去阅读各类框架的源代码只会消磨人的意志力。小时候读书的时候老师就说过,学习知识要循序渐进,考试做题的时候要先易后难呐。嘛,阅读源代码这种事也是一回事吧。我们也应该先易后难,循序渐进。一直听说过lodash的威名,但是说实话,我一直没有去看过lodash的源代码,最近去看了看,发现lodash的源代码写的是惊为天人!抠到极致的细节、考虑到的各类边界情况、性能方面的考量都是我们值得学习的。所以,我决定将lodash的源代码都仔细的看一遍,藉此撰写杂文几片记录这一学习过程。

废话不说了,我阅读的第一个lodash的方法是difference方法

difference函数分析

创建一个具有唯一array值的数组,每个值不包含在其他给定的数组中。(注:即创建一个新数组,这个数组中的值,为第一个数字(array 参数)排除了给定数组中的值。)该方法使用SameValueZero做相等比较。结果值的顺序是由第一个数组中的顺序确定。

源代码

function difference(array, ...values) {
  return isArrayLikeObject(array)
    ? baseDifference(array, baseFlatten(values, 1, isArrayLikeObject, true))
    : []
}

lodash的代码具有很高的自注释性,我们可以根据它的函数名称就能大概阅读出来作者的意图,difference函数本身的意思是:

array是否是类数组 ?
如果是类数组,则将...value值展开,并跟array作baseDifference的比较
如果不是类数组,直接返回空数组。

lodash源代码非常的简洁,lodash暴露出来的方法并不复杂,都是由isArrayLike, baseDifference, baseFlatten这类的基础函数组成的,从一方面也可以看出lodash的内部同样具有很高的复用性和抽象水平。 接下来我们就对其进行抽丝剥茧。

抽丝剥茧

isArrayLikeObject

其字面意思是 “是否是类数组对象”

类数组: 类数组其实不是数组,而是一个类似数组的对象。一个类数组对象应当符合以下两点:

  1. 使用数字作为属性名称
  2. 需要具备length属性
function isArrayLikeObject(value) {
  return isObjectLike(value) && isArrayLike(value)
}

该函数同样很简单,如果一个值是对象并且是类数组则该值是类数组对象。 我们简单看一下isObjectLikeisArrayLike的源代码

function isObjectLike(value) {
  return typeof value === 'object' && value !== null
}

function isArrayLike(value) {
  return value != null && typeof value !== 'function' && isLength(value.length)
}

isObjectLike中牵涉到一个JavaScript的上古BUG,typeof null的值为object(为什么JavaScript里面typeof(null)的值是"object"?)

isArrayLike 中剔除了value是function的情况,需要注意的是其中的isLength方法。

const MAX_SAFE_INTEGER = 9007199254740991
function isLength(value) {
  return typeof value === 'number' &&
    value > -1 && value % 1 == 0 && value <= MAX_SAFE_INTEGER
}

isLength这里限定了该值必须是整数并且小于最大的安全整数,此时,9007199254740991这个神奇的数字成功的引起了我的注意,这个数字是怎么来的呢?9007199254740991 刚好等于2 ** 53 - 1。为什么是等于这个数字呢?简单的讲是因为js中是采用IEEE754标准统一表示了浮点数和整数。js并不能完全的将64位浮点数的所有位都用来表示整数。

详细的信息大家可以参考: JS 中的 MAX_VALUE 和 MAX_SAFE_INTEGER 是怎么来的

OK,到此isArrayLikeObject的源代码就已经分析的差不多了,就仅仅阅读了这一点点代码就不得不让人感叹lodash真是细节拉满。

baseFlatten

从字面意思看,这是一个将多维对象/数组展开的函数。

function baseFlatten(array, depth, predicate, isStrict, result) {
  predicate || (predicate = isFlattenable)
  result || (result = [])

  if (array == null) {
    return result
  }

  for (const value of array) {
    if (depth > 0 && predicate(value)) {
      if (depth > 1) {
      	// 有栈溢出的风险
        // Recursively flatten arrays (susceptible to call stack limits).
        baseFlatten(value, depth - 1, predicate, isStrict, result)
      } else {
        result.push(...value)
      }
    } else if (!isStrict) {
      result[result.length] = value
    }
  }
  return result
}

先说明一下函数的参数是什么意思吧

  • array: 需要展开的数组
  • depth: 需要展开几层?
  • predicate: 对数组的中的每一项进行“检查”的函数
  • isStrict: 是否严格要求每一项都必须通过predicate函数的检查
  • result: 将展开的结果放入其中的数组

上面的代码简单的讲就是对数组进行遍历,如果需要展开的层级大于1,就进行递归,否则就直接用 ...数组展开符进行展开。 还需要注意的是如果在“严格模式”下,没有通过predicate函数检查的值不会被放入result数组中。

baseDifference

最后我们来分析一下 baseDifference 这个函数

import SetCache from './SetCache.js'
import arrayIncludes from './arrayIncludes.js'
import arrayIncludesWith from './arrayIncludesWith.js'
import map from '../map.js'
import cacheHas from './cacheHas.js'
const LARGE_ARRAY_SIZE = 200

function baseDifference(array, values, iteratee, comparator) {

  // includes 会根据不同的使用情况而发生改变
  let includes = arrayIncludes
  let isCommon = true
  const result = []
  const valuesLength = values.length

  if (!array.length) {
    return result
  }
  if (iteratee) {
    values = map(values, (value) => iteratee(value))
  }
  // 如果有comparator则变为arrayIncludesWith
  if (comparator) {
    includes = arrayIncludesWith
    isCommon = false
  }
  else if (values.length >= LARGE_ARRAY_SIZE) {
    includes = cacheHas
    isCommon = false
    values = new SetCache(values)
  }
  // for循环以上的部分是针对不同的情况而对其中使用的一些函数进行调整和改变一些标识符
  
  outer:
  for (let value of array) {
    const computed = iteratee == null ? value : iteratee(value)

    value = (comparator || value !== 0) ? value : 0
    if (isCommon && computed === computed) {
      let valuesIndex = valuesLength
      while (valuesIndex--) {
        if (values[valuesIndex] === computed) {
          continue outer
        }
      }
      result.push(value)
    }
    else if (!includes(values, computed, comparator)) {
      result.push(value)
    }
  }
  return result
}

还是先对函数的参数列表进行说明:

  • array: 需要进行检查的数组
  • values: 与array进行对比的数组
  • iteratee: 遍历数组时每个元素都会调用iteratee函数
  • comparator: 用于判断两个值是否相等的函数

我们先通读一遍逻辑,再对其中的细节进行深挖

for循环以上的部分是针对不同的情况而对其中使用的一些函数进行调整和改变一些标识符。
for循环中的部分对array数组进行遍历,接着再使用一个循环与values数组中的值进行比较,如果不同的话则放入result数组中。

大致的逻辑总的来说是比较简单的,就是一个二重循环遍历。需要值得注意的地方在于 comparatorLARGE_SIZE 的部分。当我们传入了 comparator 或者 values数组的大小超过200时,isCommon === false,此时会有一些不同。

comparator

如果我们提供了 comparator,则 includes 函数会变成 arrayIncludesWith

arrayIncludes源代码

function arrayIncludes(array, value) {
  const length = array == null ? 0 : array.length
  return !!length && baseIndexOf(array, value, 0) > -1
}

function baseIndexOf(array, value, fromIndex) {
  // NaN !== NaN
  return value === value
    ? strictIndexOf(array, value, fromIndex)
    : baseFindIndex(array, baseIsNaN, fromIndex)
}

function strictIndexOf(array, value, fromIndex) {
  let index = fromIndex - 1
  const { length } = array

  while (++index < length) {
    if (array[index] === value) {
      return index
    }
  }
  return -1
}

function baseFindIndex(array, predicate, fromIndex, fromRight) {
  const { length } = array
  let index = fromIndex + (fromRight ? 1 : -1)

  while ((fromRight ? index-- : ++index < length)) {
    if (predicate(array[index], index, array)) {
      return index
    }
  }
  return -1
}


function baseIsNaN(value) {
  return value !== value
}
arrayIncludesWith源代码
function arrayIncludesWith(array, target, comparator) {
  if (array == null) {
    return false
  }

  for (const value of array) {
    if (comparator(target, value)) {
      return true
    }
  }
  return false
}

arrayIncludesWitharrayIncludes的功能是相同的,都是为了判断某一个值是否出现在数组中,只不过arrayIncludesWith 需要我们自己提供一个 comparator 函数用于比较两个值是否相等。
lodash中提供的arrayIncludes函数可以说是又是把细节拉满,充分的考虑到了数组为空和NaN值的这类的边界条件。

LARGE_SIZE

而当我们的 values 数组的大小超过了 LARGE_SIZE后,includes会变为 cacheHas, values数组也会变为 SetHash 对象。现在我们来看看 cacheHasSetHash 对象是怎么一回事。

cacheHas

function cacheHas(cache, key) {
  return cache.has(key)
}

它的源代码很简单,cache 这个对象有一个has方法会返回该对象中是否存在这个 key 值。

SetCache

SetCache的源代码相对来说比较多一些,这里另起一篇文章来介绍SetCache这个对象。这里大家可以将其理解为 ES6中提供的Set对象。

那说了这么多,为什么当 values 比较大的时候要使用 Set 这种对象呢?让我们回顾一下之前是如何判断两个数组中有不同的值的?我们采用了双重循环,时间复杂度是O(n^2)。而Set这类对象它的查询数据的时间复杂度是O(1),在加上最外层的循环,则我们的时间复杂度变为了O(n)。

如果我们的数组比较小,则创建SetCache对象的开销可能大于双重循环造成的性能下降,但是如果数组比较大的话,双重循环造成的性能问题就远远大于我们创建SetCache的开销了。

总结

至此,整个 difference方法的源码阅读也就基本告一段落了。我们总结一下其中学习到的一些亮点与细节:

  1. typeof null === 'object' 这是JS设计之处的一个BUG。(233333333)
  2. NaN !== NaN 这一点需要注意,这也是判断一个值是不是 NaN 的方法。
  3. JS 中使用IEEE-754标准统一表示了整型数字和浮点数,所以JS中最大的安全数字为2^53 - 1。
  4. 在数组比较大的情况下,采用哈希表进行数组查询比双重循环要快,因为哈希表查询的时间复杂度为O(1)。

如果你觉得文章对你有所帮助,可以点个赞再走~