lodash源码解读之flatten

1,016 阅读4分钟

lodash中flatten方法,就是将二维数组打平的一个方法(注意这里只能打平二维数组,多维数组有另外的方法),在lodash的flatten.js文件中,就只有几行代码,如下:

function flatten(array) {
  const length = array == null ? 0 : array.length
  return length ? baseFlatten(array, 1) : []
}

其意思就是如果未传入array或则传入的arraynull类型的,那么就返回一个空的数组,否则就返回baseFlatten方法的执行结果。
看下baseFlatten.js文件,文件导出了一个名为baseFlatten的方法,其代码如下:

import isFlattenable from './isFlattenable.js'

/**
 * The base implementation of `flatten` with support for restricting flattening.
 *
 * @private
 * @param {Array} array The array to flatten.
 * @param {number} depth The maximum recursion depth.
 * @param {boolean} [predicate=isFlattenable] The function invoked per iteration.
 * @param {boolean} [isStrict] Restrict to values that pass `predicate` checks.
 * @param {Array} [result=[]] The initial result value.
 * @returns {Array} Returns the new flattened array.
 */
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
}

export default baseFlatten

baseFlatten实则接受五个参数,但是在flattent中调用baseFlatten时只传入了两个参数,所以我们这里只按照两个参数解读
从代码中可以看到,无论predicateresult是否有传,都会被重新赋值,所以predicate被赋值为一个名为isFlattenable的函数,isFlattenable的内部不用看了,就是判断数组/类数组/Symbol.isConcatSpreadable来确定对象是否可以被解构(下面会提到,因为这里面涉及到一些规范的问题);
result被赋值为空数组 如果arraynull,则返回空数组,这的这个判断跟flatten中的判断重复的原因主要是baseFlatten函数不止在flatten函数中用到,所以为了保险,在这里又判断了一次。
下面就是打平数组的主要代码了,很平常的采用for循环加递归的方式实现的,使用for of循环,从性能上考虑要比for in循环更优,因为for in循环会遍历对象的原型上的属性
depth表示数组是几纬的(也就是数组的嵌套层级有多深);可以看到在flatten中调用baseFlatten时传入的是1,所以flatten方法只能打平二维数组;
depth大于1并且predicate(value)为true时,继续判断depth是否大于1,大于1就表示数组不止嵌套两层,就会进入到递归中,否则就采用解构赋值的形式pushresult
如果条件depth > 0 && predicate(value)不满足,并且不是在严格模式下(isStrict表示是否在严格模式下),result[result.length] = value;结束 现在数组的新增了很多方法,在不考虑老旧版本的情况下,flatten函数还可以自己使用reduce方法去实现:

//利用reduce实现
function flatten(arr) {
    return arr.reduce(function (prev, next) {
        return prev.concat(Array.isArray(next) ? flatten(next) : next)
    }, [])
}

这样实现就比较简单粗暴了,并没有像lodash中那这样去考虑数组是否知道嵌套层级,知道和不知道有不同的处理(flattenflattenDeepflattenDepth方法),也没有考虑不同场景(例如:arr是数组可以使用concat函数,Symbol.isConcatSpreadable 会直接影响到concat函数的返回结果,也就是会影响到数组的展开,所以,数组在使用concat方法之后,不一定能得到你想要的结果)
这里额外说一下 isFlattenable 里面涉及到的一些东西

const a = [1, 2];
const b = [3, 4].concat(a);
console.log(b)  // [3,4,1,2]

使用Symbol.isConcatSpreadable之后:

const a = [1, 2];
a[Symbol.isConcatSpreadable] = false;
const b = [3, 4].concat(a);
console.log(b)  //[3,4,[1,2]]

这里就可以看到在使用concat方法的时候,并没有将数组a展开了 看看lodash中的isFlattenable

import isArguments from '../isArguments.js'

/** Built-in value reference. */
const spreadableSymbol = Symbol.isConcatSpreadable

/**
 * Checks if `value` is a flattenable `arguments` object or array.
 *
 * @private
 * @param {*} value The value to check.
 * @returns {boolean} Returns `true` if `value` is flattenable, else `false`.
 */
function isFlattenable(value) {
  return Array.isArray(value) || isArguments(value) ||
    !!(value && value[spreadableSymbol])
}

export default isFlattenable

这里也是用到的Symbol.isConcatSpreadable来判断数组是否可展开,但是在lodash中并没有使用concat函数在展开数组,而是使用解构...result.push(...value)),那为什么lodash中还使用数组的Symbol.isConcatSpreadable属性来判断数组是否可以展开呢?明明Symbol.iterator才会影响到解构赋值。
这是因为 isFlattenable 是给 flatten 族函数用的。而 flatten 的实现是按照 tc39/flat 提案实现的,在规范中 flat 中并没有使用 @@iterator。这就会导致一些问题,因为字符串也是可以迭代的,最简单的就是导致

['hello', 'world'].flat() //输出的结果是 ['h', 'e', 'l', 'l', ...] 

看着这输出的结果是不是很怪异,不是自己想要的,所以lodash做了一层判断,但是,现在的规范已经出现了@@iterator,所以这里lodash这样判断的 但是,这个函数还是有问题的,就是这个函数的实现和规范并不兼容,但是问题不是在于 @@iterator,而是在于 @@isConcatSpreadable。根据规范,flat 函数是不检查 @@isConcatSpreadable 的。