Lodash系列之数组篇

本文从使用频率和实用性依次递减的顺序来聊一聊几个Lodash数组类工具函数。对于大多数函数本文不会给出Lodash源码的完整实现,而更多侧重于实现思路的探讨。

本文共11371字,阅读完成大约需要23分钟。

扁平化(flatten)

用法

flatten这个函数非常实用,面试的时候大家也很喜欢问。先来看下用法, 对于不同深度的嵌套数组Lodash提供了3种调用方式:

// 展开所有的嵌套
_.flattenDeep([1, [2, [3, [4]], 5]]) // [1, 2, 3, 4, 5]

// 展开数组元素最外一层的嵌套
_.flattenDepth([1, [2, [3, [4]], 5]], 1) // [1, 2, [3, [4]], 5]

// 等同于flattenDepth(, 1),展开元素最外一层的嵌套
_.flatten([1, [2, [3, [4]], 5]]) // [1, 2, [3, [4]], 5]

不难看出其他两种调用方式都是由flattenDepth派生出来的, flattenDeep相当于第二个参数传入了无穷大,flatten相当于第二个参数传入1。

实现思路

那么问题来了,这个可以指定展开深度的flattenDepth函数怎么实现呢?

一个简单的思路是: 我们可以利用展开语法(Spread syntax)/遍历赋值来展开单层的数组, 例如:

const a = [1];
const b = [
  ...a, 2, 3,
];

那么递归地调用单层展开, 我们自然就可以实现多层的数组展开了。

Lodash的实现方式

在Lodash中这个函数叫baseFlatten, 各位需要对这个函数留点印象,本文后面讨论集合操作的时候还会看到。

// 保留predicate参数为本文后面几个函数服务
function baseFlatten(array, depth, predicate = Array.isArray, result = []) {
  if (array == null) {
    return result
  }
  for (const value of array) {
    if (depth > 0 && predicate(value)) {
      if (depth > 1) {
        // 递归调用, 深度-1
        // Recursively flatten arrays (susceptible to call stack limits).
        baseFlatten(value, depth - 1, predicate, result)
      } else {
        // 未达到指定深度展开当前一层
        result.push(...value)
      }
    } else {
      // 一般条件
      result[result.length] = value
    }
  }
  return result
}

典型的迭代+递归函数,迭代时不断将非数组元素推入result实现扁平化。对于指定深度的调用,超出深度的只展开当前一层, 否则深度递减。

另类的实现方式

另外数组扁平化还有一种比较简短的实现方式, 利用toString()join()将数组转为字符串, 然后将得到的字符串用split()函数分割。不过这种方式有个比较大的问题在于会直接忽略数组中的nullundefined元素, 且得到的数组是字符串数组,其他基础类型(如布尔值,数字)需要手动转换。

这种写法运行效率与递归差别不大,在特定场景也可以有其使用价值。

[1, [2, [3, [4]], 5]].join().split(',')
// or
[1, [2, [3, [4]], 5]].toString().split(',')

去重(uniq)

用法

数组去重也非常的实用,Lodash为不同的数据类型提供了两种调用方式:

_.uniq([2, 1, 2]) // [2, 1]

_.uniqWith([{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }, { 'x': 1, 'y': 2 }], _.isEqual) // [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }]

实现思路

数据去重有众多的实现思路, 其中流传程度最广的当属利用Set数据结构性质进行去重的实现。

其余的都是对数组进行单次遍历,然后构造新数组或者过滤掉重复元素。

不过有需要注意的点: 如何处理NaN的相等性判断(NaN !== NaN), 延伸一下就是如何控制元素相等性判断策略(例如如何能传入一个函数能使得认为[1, 2, 3][1, 2, 3]是相等的)。

引用下MDN上的说法, ES2015中有四种相等算法:

  • 抽象(非严格)相等比较 (==)
  • 严格相等比较 (===): 用于Array.prototype.indexOf, Array.prototype.lastIndexOf
  • 同值零: 用于 TypedArray 和 ArrayBuffer 构造函数、以及Map和Set操作, 并将用于 ES2016/ES7 中的String.prototype.includes
  • 同值(Object.is): 用于所有其他地方

实现方式一(Set)

利用Set数据结构性质进行去重最为简洁且大数据量下效率最高:

// 数组转为set后转回数组, 无法区分±0
const uniq = (arr) => [...new Set(arr)];

需要注意的是Set中的同值零(SameValueZero)相等性判断认为NaN之间,±0之间都是相等的, 因此无法区分±0,且无法传入相等性判断策略。

实现方式二(单次遍历构造新数组)

单次遍历并构造新数组, 空间复杂度O(N)。

需要注意的是NaN的判断,Array.prototype.indexOf使用的是严格相等性判断策略, 无法正确得到NaN元素的索引。例如:

[1, NaN, 2].indexOf(NaN) // -1

于是我们需要使用Array.prototype.includes的同值零相等性判断策略进行判断:

function unique(array) {
  const result = [];
  for (const value of array) {
    // 同样的, 同值零相等性判断策略无法区分±0
    if (!result.includes(value)) {
      result[result.length] = value;
    }
  }
  return result;
}

更进一步,我们可以实现一个includesWith函数来手动传入相等判断策略:

function includesWith(array, target, comparator) {
  if (array == null) return false;

  for (const value of array) {
    if (comparator(target, value)) return true;
  }
  return false;
}
function unique(array, comparator) {
  const result = [];
  for (const value of array) {
    if (!includesWith(result, value, comparator)) {
      result[result.length] = value;
    }
  }
  return result;
}

// 传入同值零相等性判断策略, 可以区分±0
unique([+0, 1, NaN, NaN, -0, 0], Object.is) // [0, 1, NaN, -0]

// 传入外形相等性判断策略
unique([
  [1, 2, 3], {},
  [1, 2, 3], {},
], _.isEqual) // [[1, 2, 3], {}]

实现方式三(单次遍历过滤重复元素)

单次遍历并过滤重复元素的思路有两种实现方式, 一种是利用哈希表过滤存储遍历过的元素,空间复杂度O(N):

function unique(arr) {
  const seen = new Map()
  // 遍历时添加至哈希表, 跟Set一样无法区分±0
  return arr.filter((a) => !seen.has(a) && seen.set(a, 1))
}

对于Map我们虽然不能控制其相等性判断策略,但是我们可以控制其键值生成策略。例如我们可以粗暴地利用JSON.stringify来完成一个简陋的"外形"相等性键值生成策略:

function unique(array) {
  const seen = new Map()
  return array.filter((item) => {
    // 如果你需要将基本类型及其包装对象(如`String(1)`与`"1"`)视为同值,那么也可以将其中的`typeof`去掉
    const key = typeof item + JSON.stringify(item)
    return !seen.has(key) && seen.set(key, 1)
  })
}

另一种方式是利用Array.prototype.findIndex的性质,空间复杂度O(1):

function unique(array) {
  return array.filter((item, index) => {
    // 存在重复元素时,findIndex的结果永远是第一个匹配到的元素索引
    return array.findIndex(e => Object.is(e, item)) === index; // 利用同值相等性判断处理NaN
  });
}

Lodash的实现方式

由于IE8及以下不存在Array.prototype.indexOf函数,Lodash选择使用两层嵌套循环来代替Array.prototype.indexOf:

const LARGE_ARRAY_SIZE = 200

function baseUniq(array, comparator) {
  let index = -1

  const { length } = array
  const result = []

  // 超过200使用Set去重
  if (length >= LARGE_ARRAY_SIZE && typeof Set !== 'undefined') {
    return [...new Set(array)]
  }

  outer:
  while (++index < length) {
    let value = array[index]

    // Q: 什么值自身不等于自身?
    if (value === value) {
      let seenIndex = result.length
      // 等价于indexOf
      while (seenIndex--) {
        if (result[seenIndex] === value) {
          continue outer
        }
      }
      result.push(value)
      // Q: 可以用indexOf吗?
    } else if (!includesWith(result, value, comparator)) {
      result.push(value)
    }
  }
  return result
}

求并集(union)

下文的三个函数是集合的三个核心操作,关于集合论一图胜千言,我就不画了放个网图

用法

以同值零相等性判断策略合并数组, Lodash同样为不同的数据类型提供了两种调用方式:

_.union([2, 3], [1, 2]) // [2, 3, 1]
_.union([0], [-0]) // [0]
_.union([1, [2]], [1, [2]]) // [1, [2], [2]]

// 外形相等性判断
_.unionWith([1, [2]], [1, [2]], _.isEqual) // [1, [2]]

实现思路

思路很简单,就是将传入的数组展开一层到同一数组后去重。

那不就是利用flattenunique吗?

是的, Lodash也就是这样实现union函数的。

Lodash的实现方式

下面只给出了Lodah的实现方式,各位可以尝试组合上文中的各种uniqueflatten实现。

function union(...arrays) {
  // 第三个参数不再是默认的Array.isArray
  return baseUniq(baseFlatten(arrays, 1, isArrayLikeObject))
}

function isArrayLikeObject(value) {
  return isObjectLike(value) && isLength(value.length)
}

// 非null对象
function isObjectLike(value) {
  return typeof value === 'object' && value !== null
}

// 小于2的53次幂的非负整数
function isLength(value) {
  return typeof value === 'number' &&
    value > -1 && value % 1 == 0 && value <= Number.MAX_SAFE_INTEGER
}

求交集(intersection)

用法

求集合中的共有部分,Lodash同样为不同的数据类型提供了两种调用方式:

intersection([2, 1], [2, 3]) // [2]
intersection([2, 3, [1]], [2, [1]]) // [2]

// 外形相等性判断
_.intersectionWith([2, 3, [1]], [2, [1]], _.isEqual) // [2, [1]]

实现思路

集合中的共有部分,那么我们只需要遍历一个集合即可,然后构建新数组/过滤掉其他集合不存在的元素

函数式实现方式

const intersection = (a, b) => a.filter(x => b.includes(x))

// 还记得上文中的includesWith函数吗?
const intersectionWith = (a, b, comparator = Object.is) => a.filter(x => includesWith(b, x, comparator))

求差集(difference)

用法

求集合中的差异部分,Lodash同样为不同的数据类型提供了两种调用方式:

difference([2, 1], [2, 3]) // 得到[1]
difference([2, 1], [2, 3, 1], [2]) // 得到[]
difference([2, 1, 4, 4], [2, 3, 1]) // 得到[4, 4]

需要注意的是差集是存在单个作用主体的,difference的语义是"集合A相对与其他集合的差集", 所以得到的值必定是传入的第一个参数数组(即集合A)中的元素,如果集合A是其他集合的子集,那么得到的值必定为空数组,理解上有困难的不妨画图看看。

实现思路

存在单个作用主体的差异部分,那么我们只需要遍历一个集合即可,然后构建新数组/过滤掉其他集合存在的元素

函数式实现方式

const difference = (a, b) => a.filter(x => !b.includes(x))
// 外形相等性判断
const differenceWith = (a, b, comparator = Object.is) => a.filter(x => !includesWith(b, x, comparator))

分块(chunk)

用法

就是将数组等分为若干份, 最后一份不足的不进行补齐:

chunk(['a', 'b', 'c', 'd'], 2) //  [['a', 'b'], ['c', 'd']]
chunk(['a', 'b', 'c', 'd'], 3) //  [['a', 'b', 'c'], ['d']]

实现思路

看到执行函数的结果就不难想到它是如何实现的, 遍历时将数组切片(slice)得到的若干份新数组合并即可。

另外,如果我不想使用循环遍历,想用函数式编程的写法用Array.prototype.mapArray.prototype.reduce该怎么做呢?

首先我们要构造出一个长度等于Math.ceil(arr.length / size)的新数组对象作为map/reduce的调用对象, 然后进行返回数组切片即可。

不过这里有个问题需要注意: 调用Array构造函数只会给这个新数组对象设置length属性,而其索引属性并不会被自动设置。

const a = new Array(3)
// 不存在索引属性
a.hasOwnProperty("0") // false
a.hasOwnProperty(1) // false

那么问题来了,如何如何设置新数组对象的索引属性呢?

读者可以先自己思考下,答案在下文中揭晓。

实现方式

function chunk(array, size = 1) {
  // toInteger做的就是舍弃小数
  size = Math.max(toInteger(size), 0)
  const length = array == null ? 0 : array.length
  if (!length || size < 1) {
    return []
  }
  let index = 0
  let resIndex = 0
  const result = new Array(Math.ceil(length / size))

  while (index < length) {
    // Array.prototype.slice需要处理一些非数组类型元素,小数据规模下性能较差
    result[resIndex++] = slice(array, index, (index += size))
  }
  return result
}

函数式实现方式

上文说到调用Array构造函数生成的数组对象不存在索引属性,因此我们在需要用到索引属性时需要填充数组对象。

一共有三种方式: 数组展开语法, Array.prototype.fill, Array.from

// 利用展开语法
const chunk = (arr, size) =>
  [...Array(Math.ceil(arr.length / size))].map((e, i) => arr.slice(i * size, i * size + size));

// 利用`Array.prototype.fill`
const chunk = (arr, size) =>
  Array(Math.ceil(arr.length / size)).fill(0).map((e, i) => arr.slice(i * size, i * size + size));

// 利用`Array.from`的回调函数
const chunk = (arr, size) =>
  Array.from({ length: Math.ceil(arr.length / size) }, (e, i) => arr.slice(i * size, i * size + size));

// 利用`Array.from`
const chunk = (arr, size) =>
  Array.from({ length: Math.ceil(arr.length / size) }).map((e, i) => arr.slice(i * size, i * size + size));

// 利用`Array.prototype.reduce`, 索引等于size倍数时将当前切片合并进累计器(accumulator)
const chunk = (arr, size) =>
  arr.reduce((a, c, i) => !(i % size) ? a.concat([arr.slice(i, i + size)]) : a, []);

数组切片(slice)

根据索引得到更小规模的数组:

用法

_.slice([1, 2, 3, 4], 2) // [3, 4]
_.slice([1, 2, 3, 4], 1, 2) // [2]
_.slice([1, 2, 3, 4], -2) // [3, 4]

// 等于 _.slice([1, 2, 3, 4], 4 - 2, 3)
_.slice([1, 2, 3, 4], -2, 3) // [3]

// 等于 _.slice([1, 2, 3, 4], 4 - 3, 3)
_.slice([1, 2, 3, 4], -3, -1) // [2, 3]

实现思路

对于数组切片我们需要记住的是,区间包含start不包含end, 负数索引等同于数组长度加该数, start绝对值大于数组长度时等同于0, end绝对值大于数组长度时等同于数组长度。

这些策略就是Lodash乃至V8实现数组切片的思路。

Lodash的实现方式

function slice(array, start, end) {
  let length = array == null ? 0 : array.length
  if (!length) {
    return []
  }
  start = start == null ? 0 : start
  end = end === undefined ? length : end

  if (start < 0) {
    // 负数索引等同于数组长度加该数, start绝对值大于数组长度时等同于0
    start = -start > length ? 0 : (length + start)
  }
  // end绝对值大于数组长度时等同于数组长度
  end = end > length ? length : end
  // 负数索引等同于数组长度加该数
  if (end < 0) {
    end += length
  }
  length = start > end ? 0 : ((end - start) >>> 0)
  // toInt32
  start >>>= 0

  let index = -1
  const result = new Array(length)
  while (++index < length) {
    result[index] = array[index + start]
  }
  return result
}

一个比较有趣的点是这里的位运算: 无符号右移(end - start) >>> 0, 它起到的作用是toInt32(因为位运算是32位的), 也就是小数取整。

那么问题来了, 为什么不用封装好的toInteger函数呢?

个人理解一是就JS运行时而言,我们没有32位以上的数组切片需求;二是作为一个基础公用函数,位运算的运行效率显然更高。

好了,以上就是本文关于Lodash数组类工具函数的全部内容。行文难免有疏漏和错误,还望读者批评指正。

你可能感兴趣的:(前端,javascript)