本文从使用频率和实用性依次递减的顺序来聊一聊几个Lodash数组类工具函数。对于大多数函数本文不会给出Lodash源码的完整实现,而更多侧重于实现思路的探讨。面试
本文共11371字,阅读完成大约须要23分钟。算法
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中这个函数叫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()
函数分割。不过这种方式有个比较大的问题在于会直接忽略数组中的null
和undefined
元素, 且获得的数组是字符串数组,其余基础类型(如布尔值,数字)须要手动转换。大数据
这种写法运行效率与递归差异不大,在特定场景也能够有其使用价值。
[1, [2, [3, [4]], 5]].join().split(',') // or [1, [2, [3, [4]], 5]].toString().split(',')
数组去重也很是的实用,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
String.prototype.includes
利用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 }); }
因为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 }
下文的三个函数是集合的三个核心操做,关于集合论一图胜千言,我就不画了放个网图。
以同值零相等性判断策略合并数组, 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]]
思路很简单,就是将传入的数组展开一层到同一数组后去重。
那不就是利用flatten
和unique
吗?
是的, Lodash也就是这样实现union函数的。
下面只给出了Lodah的实现方式,各位能够尝试组合上文中的各类unique
与flatten
实现。
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 }
求集合中的共有部分,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))
求集合中的差别部分,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(['a', 'b', 'c', 'd'], 2) // [['a', 'b'], ['c', 'd']] chunk(['a', 'b', 'c', 'd'], 3) // [['a', 'b', 'c'], ['d']]
看到执行函数的结果就不难想到它是如何实现的, 遍历时将数组切片(slice)获得的若干份新数组合并便可。
另外,若是我不想使用循环遍历,想用函数式编程的写法用Array.prototype.map
与Array.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([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实现数组切片的思路。
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数组类工具函数的所有内容。行文不免有疏漏和错误,还望读者批评指正。