各位编程爱好者、系统架构师以及对JavaScript底层机制充满好奇的开发者们,大家好。
今天,我们将深入探讨JavaScript中一个看似简单,实则蕴含丰富编程思想的方法——Array.prototype.flat。这个方法在处理嵌套数组时表现出惊人的实用性,尤其是在数据结构扁平化、数据处理管道构建等场景中。我们将从零开始,手写实现一个功能完备的flat方法,不仅要支持默认的单层扁平化,更要支持指定任意深度(depth)的扁平化,包括无限深度。
本次讲座将以“编程专家”的视角,层层递进地剖析问题,探讨多种解决方案,并深入分析它们的优缺点、性能考量以及在实际开发中的应用。我们将大量运用代码示例,确保每一步的逻辑都严谨清晰。
1. 嵌套数组的困境与flat方法的诞生
在现代JavaScript开发中,我们经常会遇到处理复杂数据结构的需求。其中,嵌套数组是一种非常常见的数据组织形式,例如:
const categories = [
'Electronics',
['Books', ['Fiction', 'Non-Fiction']],
'Clothing',
[['Home Goods', 'Kitchenware'], 'Furniture']
];
const userPermissions = [
'read',
['write', ['update', 'delete']],
'execute'
];
这样的结构在表示层级关系时非常直观。然而,当我们需要对这些数据进行统一处理,或者将其展示在一个扁平的列表中时,这种嵌套结构就成了障碍。例如,我们可能需要一个包含所有权限的单一列表,或者一个所有商品分类的集合。
手动遍历并解构这些嵌套数组是繁琐且容易出错的,尤其当嵌套深度不确定时。正是为了解决这一痛点,ECMAScript 2019(ES10)引入了Array.prototype.flat()方法,它提供了一种简洁、高效的方式来将嵌套数组扁平化。
flat方法的核心作用是将多维数组转换为一维数组,其神奇之处在于它允许我们指定扁平化的深度。让我们先看一个简单的例子,体会一下它的便利性:
const nestedArray = [1, [2, 3], [4, [5, 6]]];
// 默认深度为1,只扁平化一层
console.log(nestedArray.flat()); // [1, 2, 3, 4, [5, 6]]
// 扁平化两层
console.log(nestedArray.flat(2)); // [1, 2, 3, 4, 5, 6]
// 扁平化所有层级
console.log(nestedArray.flat(Infinity)); // [1, 2, 3, 4, 5, 6]
可以看到,flat方法极大地简化了代码。现在,我们的目标是手写实现这个强大的方法,使其具备相同的灵活性和鲁棒性。
2. 扁平化的基本思想:深度为1的情况
在着手实现任意深度的扁平化之前,我们先从最简单的情况入手:深度为1的扁平化。这意味着我们只展开数组的第一层嵌套。
假设我们有 [1, [2, 3], 4]。经过深度为1的扁平化后,我们期望得到 [1, 2, 3, 4]。
实现这一目标,有多种常见的JavaScript编程范式可供选择:
2.1 使用 for...of 循环和 push/concat
这是最直观的实现方式之一。我们遍历原数组的每一个元素,如果元素本身是一个数组,就将其内部的元素逐个添加到结果数组中;如果不是数组,就直接添加。
function flattenDepthOneForOf(arr) {
const result = [];
for (const item of arr) {
if (Array.isArray(item)) {
// 如果是数组,使用扩展运算符(spread syntax)将其元素展开并添加到结果
// 或者使用 item.forEach(subItem => result.push(subItem));
result.push(...item);
} else {
// 如果不是数组,直接添加到结果
result.push(item);
}
}
return result;
}
// 示例
const arr1 = [1, [2, 3], 4, ['a', 'b', [5, 6]]];
console.log("flattenDepthOneForOf:", flattenDepthOneForOf(arr1)); // [1, 2, 3, 4, 'a', 'b', [5, 6]]
这种方法清晰易懂,通过 Array.isArray() 准确判断元素类型,然后利用 push 或 spread 语法将子数组元素合并到结果数组中。
2.2 使用 reduce 方法
reduce 是一个非常强大的数组方法,它能够将数组的所有元素“归约”成一个单一的值。在这里,我们可以用它来构建我们的扁平化结果数组。
reduce 的回调函数通常接收四个参数:accumulator(累加器),currentValue(当前值),currentIndex(当前索引),array(原数组)。我们的目标是将 accumulator 初始化为一个空数组,然后根据 currentValue 的类型,决定是将其直接添加到 accumulator,还是将其展开后添加到 accumulator。
function flattenDepthOneReduce(arr) {
return arr.reduce((accumulator, currentValue) => {
if (Array.isArray(currentValue)) {
// 如果当前值是数组,将其元素与累加器数组合并
return accumulator.concat(currentValue);
// 或者使用 return [...accumulator, ...currentValue];
} else {
// 如果不是数组,直接将当前值添加到累加器数组
return accumulator.concat(currentValue);
// 或者使用 return [...accumulator, currentValue];
}
}, []); // 初始累加器为一个空数组
}
// 示例
const arr2 = [1, [2, 3], 4, ['a', 'b', [5, 6]]];
console.log("flattenDepthOneReduce:", flattenDepthOneReduce(arr2)); // [1, 2, 3, 4, 'a', 'b', [5, 6]]
reduce 方法在这里展现了其优雅和简洁性。concat 方法在合并数组时会创建新数组,这在某些场景下可能比 push 更消耗内存(如果 push 频繁修改同一数组),但在函数式编程风格中,concat 保持了不可变性,更受欢迎。
2.3 concat 结合 spread 运算符的巧妙运用
实际上,对于深度为1的扁平化,有一种非常简洁且高效的JavaScript技巧,就是利用 concat 方法的特性与 spread 运算符。
当 concat 方法的参数中包含数组时,它会将该数组的元素逐个添加到结果中,而不是将整个数组作为单个元素添加。结合 spread 运算符,我们可以创建一个新的数组,其中包含原数组的所有元素,并对其中的子数组进行一层展开。
function flattenDepthOneSpreadConcat(arr) {
// 巧妙之处在于,spread运算符会展开arr的第一层元素
// 如果元素本身是数组,它会被concat展开
// 如果元素不是数组,它会被直接添加到结果中
return [].concat(...arr);
}
// 示例
const arr3 = [1, [2, 3], 4, ['a', 'b', [5, 6]]];
console.log("flattenDepthOneSpreadConcat:", flattenDepthOneSpreadConcat(arr3)); // [1, 2, 3, 4, 'a', 'b', [5, 6]]
这种方法非常精炼,是实现单层扁平化的一个惯用模式。它的缺点是,如果数组的元素数量非常庞大,[].concat(...arr) 可能会导致栈溢出,因为 ...arr 会在函数调用前将所有元素作为独立的参数传递。但对于大多数实际场景,这并不是一个问题。
3. 引入深度:扁平化算法的核心挑战
现在我们已经掌握了单层扁平化的方法,但真正的挑战在于如何实现指定深度的扁平化。flat 方法接收一个可选参数 depth,表示要扁平化的层数。
depth = 0:不进行任何扁平化,返回原数组的浅拷贝。depth = 1(默认值):只扁平化一层。depth = k:扁平化k层。depth = Infinity:扁平化所有层级,直到数组中不再包含嵌套数组为止。
处理任意深度的问题,自然而然地会引导我们走向两种经典的算法范式:递归(Recursion)和迭代(Iteration)。
3.1 递归实现:自然而优雅的解决方案
递归是解决这类层级结构问题的强大工具。其核心思想是:
- 基本情况(Base Case):当遇到一个非数组元素,或者已经达到指定的扁平化深度时,停止递归,直接将元素添加到结果中。
- 递归步骤(Recursive Step):如果当前元素是一个数组,并且尚未达到最大扁平化深度,那么就对这个子数组进行递归调用,并将返回的结果合并到当前结果中。
为了实现深度控制,我们需要在递归函数中引入一个额外的参数,用于跟踪当前的扁平化层数。
/**
* 递归实现 Array.prototype.flat
* @param {Array} arr 要扁平化的数组
* @param {number} depth 扁平化的深度,默认为1。可以为 Infinity。
* @returns {Array} 扁平化后的新数组
*/
function recursiveFlat(arr, depth = 1) {
// 确保深度参数有效。如果不是数字或小于0,则视为0。
const actualDepth = Number.isInteger(depth) && depth > 0 ? depth : 0;
const result = [];
for (const item of arr) {
// 如果 item 是一个数组,并且我们还有深度可以继续扁平化
if (Array.isArray(item) && actualDepth > 0) {
// 递归调用,深度减1
// 使用 spread 运算符将递归结果的元素展开并添加到 result
result.push(...recursiveFlat(item, actualDepth - 1));
} else {
// 否则,直接将 item 添加到结果中 (非数组元素或已达扁平化深度)
result.push(item);
}
}
return result;
}
// 完善 recursiveFlat,使其可以作为 Array.prototype 的方法
// 注意:直接修改 Array.prototype 可能会引起冲突,但在教学中演示是常见的
if (!Array.prototype.customFlatRecursive) {
Object.defineProperty(Array.prototype, 'customFlatRecursive', {
value: function customFlatRecursive(depth = 1) {
// 确保 depth 是一个有效的数字,默认值是1
const actualDepth = Number.isInteger(depth) && depth > 0 ? depth : 0;
// 如果深度为0,直接返回数组的浅拷贝
if (actualDepth === 0) {
return Array.isArray(this) ? [...this] : []; // 确保对非数组调用时返回空数组或浅拷贝
}
const result = [];
for (const item of this) {
// 如果 item 是一个数组,并且我们还有深度可以继续扁平化
if (Array.isArray(item) && actualDepth > 0) {
// 递归调用,深度减1
// 使用 spread 运算符将递归结果的元素展开并添加到 result
result.push(...item.customFlatRecursive(actualDepth - 1));
} else {
// 否则,直接将 item 添加到结果中 (非数组元素或已达扁平化深度)
result.push(item);
}
}
return result;
},
writable: true,
configurable: true,
});
}
// 示例使用
const complexArr = [1, [2, 3], [4, [5, 6, [7, 8]]], 9, [[10]]];
console.log("n--- 递归实现示例 ---");
console.log("Original:", complexArr);
console.log("Depth 0:", complexArr.customFlatRecursive(0)); // [1, [2, 3], [4, [5, 6, [7, 8]]], 9, [[10]]] (浅拷贝)
console.log("Depth 1:", complexArr.customFlatRecursive(1)); // [1, 2, 3, 4, [5, 6, [7, 8]], 9, [10]]
console.log("Depth 2:", complexArr.customFlatRecursive(2)); // [1, 2, 3, 4, 5, 6, [7, 8], 9, 10]
console.log("Depth 3:", complexArr.customFlatRecursive(3)); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
console.log("Depth Infinity:", complexArr.customFlatRecursive(Infinity)); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
递归实现的细节分析:
-
depth参数处理:Number.isInteger(depth)检查depth是否为整数。depth > 0确保只有正整数深度才进行扁平化。- 如果
depth不满足条件(例如是负数、非数字或0),我们将其视为0,这意味着不进行扁平化,只返回数组的浅拷贝。这是Array.prototype.flat的标准行为。 Infinity会被Number.isInteger排除,但Infinity > 0为true。我们将在后续处理Infinity的逻辑。
-
Infinity深度处理:- 当
depth是Infinity时,actualDepth - 1仍然是Infinity。这意味着只要遇到数组,我们就会一直递归下去,直到没有嵌套数组为止。这完美地实现了无限深度扁平化。
- 当
-
this绑定:- 当我们将函数作为
Array.prototype的方法添加时,函数内部的this会自动指向调用该方法的数组实例。
- 当我们将函数作为
-
性能与栈溢出:
- 递归方法简洁优雅,但其缺点在于每次递归调用都会在调用栈上创建一个新的栈帧。如果嵌套深度非常大(例如几十万层),可能会导致栈溢出(Stack Overflow)错误。在JavaScript引擎中,调用栈的大小是有限的。
递归调用的跟踪示例
让我们跟踪 [1, [2, 3], [4, [5, 6]]].customFlatRecursive(2) 的执行过程:
| 步骤 | 调用栈 | this |
actualDepth |
result |
item |
Array.isArray(item) |
actualDepth > 0 |
操作 |
|---|---|---|---|---|---|---|---|---|
| 1 | flat(2) |
[1, [2, 3], [4, [5, 6]]] |
2 | [] |
1 |
false |
– | result.push(1) -> [1] |
| 2 | flat(2) |
[1, [2, 3], [4, [5, 6]]] |
2 | [1] |
[2, 3] |
true |
true |
递归调用 [2, 3].flat(1) |
| 2.1 | flat(1) |
[2, 3] |
1 | [] |
2 |
false |
– | result.push(2) -> [2] |
| 2.2 | flat(1) |
[2, 3] |
1 | [2] |
3 |
false |
– | result.push(3) -> [2, 3] |
| 2.3 | flat(1) |
[2, 3] |
1 | [2, 3] |
返回 [2, 3] |
|||
| 3 | flat(2) |
[1, [2, 3], [4, [5, 6]]] |
2 | [1] |
(返回 [2, 3]) |
result.push(...[2, 3]) -> [1, 2, 3] |
||
| 4 | flat(2) |
[1, [2, 3], [4, [5, 6]]] |
2 | [1, 2, 3] |
[4, [5, 6]] |
true |
true |
递归调用 [4, [5, 6]].flat(1) |
| 4.1 | flat(1) |
[4, [5, 6]] |
1 | [] |
4 |
false |
– | result.push(4) -> [4] |
| 4.2 | flat(1) |
[4, [5, 6]] |
1 | [4] |
[5, 6] |
true |
true |
递归调用 [5, 6].flat(0) |
| 4.2.1 | flat(0) |
[5, 6] |
0 | [] |
actualDepth 是 0,返回 [5, 6] |
|||
| 4.3 | flat(1) |
[4, [5, 6]] |
1 | [4] |
(返回 [5, 6]) |
result.push(...[5, 6]) -> [4, 5, 6] |
||
| 4.4 | flat(1) |
[4, [5, 6]] |
1 | [4, 5, 6] |
返回 [4, 5, 6] |
|||
| 5 | flat(2) |
[1, [2, 3], [4, [5, 6]]] |
2 | [1, 2, 3] |
(返回 [4, 5, 6]) |
result.push(...[4, 5, 6]) -> [1, 2, 3, 4, 5, 6] |
||
| 6 | flat(2) |
[1, [2, 3], [4, [5, 6]]] |
2 | [1, 2, 3, 4, 5, 6] |
返回 [1, 2, 3, 4, 5, 6] |
整个过程清晰地展示了递归如何逐层深入,并在满足条件时将结果合并。
3.2 迭代实现:避免栈溢出的选择
虽然递归实现简洁优雅,但其对调用栈的依赖在处理极深嵌套数组时可能成为瓶颈。为了避免栈溢出,我们可以采用迭代的方式来实现扁平化。迭代方法通常涉及一个显式的栈(或队列)来模拟递归的调用过程。
核心思想:
- 初始化一个结果数组和一个处理栈。
- 将原始数组的元素(可能连同它们的当前深度)压入栈中。
- 循环处理栈中的元素:
- 如果元素是一个数组,并且当前深度允许进一步扁平化,则将其内部元素(并更新深度)压入栈中。为了保持元素的原始顺序,通常会反向压入子数组元素。
- 如果元素不是数组,或者已经达到最大扁平化深度,则将其添加到结果数组中。
这里我们有两种主要的迭代策略:使用栈(LIFO,后进先出)或队列(FIFO,先进先出)。对于扁平化,我们通常希望保持元素的原始相对顺序,因此使用LIFO栈,并以逆序将子元素推入栈中,可以自然地实现这一点。
/**
* 迭代实现 Array.prototype.flat (使用显式栈)
* @param {Array} arr 要扁平化的数组
* @param {number} depth 扁平化的深度,默认为1。可以为 Infinity。
* @returns {Array} 扁平化后的新数组
*/
function iterativeFlat(arr, depth = 1) {
// 处理 depth 参数,同递归实现
const actualDepth = Number.isInteger(depth) && depth > 0 ? depth : 0;
if (actualDepth === 0) {
return Array.isArray(arr) ? [...arr] : [];
}
const result = [];
// 栈用于存储待处理的元素及其当前深度
// 栈中的每个元素都是一个 pair: [item, currentLevel]
// 初始时,我们将原始数组的元素以逆序压入栈中,并设置它们的深度为0
// 这样在弹出时,可以保持原始的顺序
const stack = [];
for (let i = arr.length - 1; i >= 0; i--) {
stack.push([arr[i], 0]); // 0代表当前元素的层级
}
while (stack.length > 0) {
const [item, currentLevel] = stack.pop(); // 弹出栈顶元素
// 如果 item 是一个数组,并且当前层级低于最大扁平化深度
if (Array.isArray(item) && currentLevel < actualDepth) {
// 将子数组的元素以逆序压入栈中,并增加它们的层级
for (let i = item.length - 1; i >= 0; i--) {
stack.push([item[i], currentLevel + 1]);
}
} else {
// 否则,将非数组元素或已达到最大深度的数组直接添加到结果数组的前端
// 因为我们是逆序弹出,所以需要 unshift 来保持最终顺序
result.unshift(item);
}
}
return result;
}
// 完善 iterativeFlat,使其可以作为 Array.prototype 的方法
if (!Array.prototype.customFlatIterative) {
Object.defineProperty(Array.prototype, 'customFlatIterative', {
value: function customFlatIterative(depth = 1) {
const actualDepth = Number.isInteger(depth) && depth > 0 ? depth : 0;
if (actualDepth === 0) {
return Array.isArray(this) ? [...this] : [];
}
const result = [];
const stack = [];
// 初始时,将原始数组的元素以逆序压入栈中,并设置它们的深度为0
// 这样在弹出时,可以保持原始的顺序
for (let i = this.length - 1; i >= 0; i--) {
stack.push([this[i], 0]);
}
while (stack.length > 0) {
const [item, currentLevel] = stack.pop();
if (Array.isArray(item) && currentLevel < actualDepth) {
// 将子数组的元素以逆序压入栈中,并增加它们的层级
for (let i = item.length - 1; i >= 0; i--) {
stack.push([item[i], currentLevel + 1]);
}
} else {
// 否则,将非数组元素或已达到最大深度的数组直接添加到结果数组的前端
// 因为我们是逆序弹出,所以需要 unshift 来保持最终顺序
result.unshift(item);
}
}
return result;
},
writable: true,
configurable: true,
});
}
// 示例使用
const complexArrIter = [1, [2, 3], [4, [5, 6, [7, 8]]], 9, [[10]]];
console.log("n--- 迭代实现示例 ---");
console.log("Original:", complexArrIter);
console.log("Depth 0:", complexArrIter.customFlatIterative(0)); // [1, [2, 3], [4, [5, 6, [7, 8]]], 9, [[10]]]
console.log("Depth 1:", complexArrIter.customFlatIterative(1)); // [1, 2, 3, 4, [5, 6, [7, 8]], 9, [10]]
console.log("Depth 2:", complexArrIter.customFlatIterative(2)); // [1, 2, 3, 4, 5, 6, [7, 8], 9, 10]
console.log("Depth 3:", complexArrIter.customFlatIterative(3)); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
console.log("Depth Infinity:", complexArrIter.customFlatIterative(Infinity)); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
迭代实现的细节分析:
stack的作用:stack存储了所有待处理的元素,每个元素都是一个[value, currentLevel]对。currentLevel记录了value所在的当前扁平化层级。- 顺序保持:
- 为了在扁平化后保持元素的原有相对顺序,我们在初始化时将
this数组的元素以逆序压入栈。 - 当从栈中弹出子数组元素时,我们再次以逆序将它们压入栈。
- 最后,当一个元素被确定为最终结果的一部分时,我们使用
result.unshift(item)将其添加到result数组的开头。这种“逆序压栈,逆序压子元素,正序弹出后逆序添加”的策略,确保了最终result数组的顺序是正确的。 - 另一种更简单的保持顺序的方法是,正序压栈,每次遇到数组元素时,使用
splice或concat将其展开到栈的正确位置,但这通常会更复杂或效率更低。或者使用队列(FIFO),但队列操作shift效率低于pop。
- 为了在扁平化后保持元素的原有相对顺序,我们在初始化时将
Infinity深度:与递归类似,当actualDepth是Infinity时,currentLevel < Infinity永远为真,直到没有嵌套数组为止,从而实现无限深度扁平化。- 性能:迭代方法避免了JavaScript调用栈的限制,理论上可以处理任意深度的嵌套数组而不会导致栈溢出。然而,它需要手动管理一个显式栈,可能会在内存使用上有所增加(但通常在可接受范围内)。
unshift操作在某些JavaScript引擎中可能会比push效率低,因为它可能导致数组元素的重新索引。对于非常大的数组,可以考虑先push到一个临时数组,最后再reverse一次。
3.3 迭代实现优化:避免 unshift 的性能开销
unshift 操作在数组开头插入元素,对于大型数组,这通常会导致所有现有元素需要重新索引,从而带来 O(n) 的时间复杂度。如果我们在循环中频繁使用 unshift,总时间复杂度可能会很高。
我们可以通过在处理时直接将元素 push 到一个辅助数组中,最后再将整个辅助数组 reverse 一次来优化。
// 迭代实现 Array.prototype.flat (优化版,避免频繁 unshift)
if (!Array.prototype.customFlatIterativeOptimized) {
Object.defineProperty(Array.prototype, 'customFlatIterativeOptimized', {
value: function customFlatIterativeOptimized(depth = 1) {
const actualDepth = Number.isInteger(depth) && depth > 0 ? depth : 0;
if (actualDepth === 0) {
return Array.isArray(this) ? [...this] : [];
}
const result = [];
const stack = [];
// 注意:这里仍然是逆序压入原始数组的元素,以便后续弹出时能保持原始顺序
for (let i = this.length - 1; i >= 0; i--) {
stack.push([this[i], 0]);
}
while (stack.length > 0) {
const [item, currentLevel] = stack.pop();
if (Array.isArray(item) && currentLevel < actualDepth) {
// 子数组的元素也以逆序压入栈
for (let i = item.length - 1; i >= 0; i--) {
stack.push([item[i], currentLevel + 1]);
}
} else {
// 将非数组元素或已达到最大深度的数组直接添加到结果数组的末尾
// 因为我们处理的顺序是“反向”的,所以最终需要反转
result.push(item);
}
}
// 最后将结果数组反转,以恢复原始元素的相对顺序
return result.reverse();
},
writable: true,
configurable: true,
});
}
// 示例使用
const complexArrIterOpt = [1, [2, 3], [4, [5, 6, [7, 8]]], 9, [[10]]];
console.log("n--- 迭代优化实现示例 ---");
console.log("Original:", complexArrIterOpt);
console.log("Depth Infinity:", complexArrIterOpt.customFlatIterativeOptimized(Infinity)); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
这种优化后的迭代方法,通过将所有元素先 push 到 result 数组,然后在循环结束后进行一次 reverse 操作,将 unshift 的 O(n) 累计操作分摊为一次 reverse 的 O(n) 操作,通常能够获得更好的性能。
4. 边缘情况与鲁棒性考量
一个健壮的 flat 实现需要考虑各种边缘情况,以确保其行为与原生方法一致且稳定。
4.1 depth 参数的验证与默认值
- 默认值:原生
flat方法的depth默认值为1。我们的实现也应遵循此规则。 - 非数字
depth:如果depth参数不是数字,或者是一个负数,原生flat会将其视为0。这意味着它会返回原数组的浅拷贝,不做任何扁平化。我们需要在actualDepth的计算中体现这一点。// 示例:非数字或负数深度 const arr = [1, [2]]; console.log(arr.customFlatRecursive('abc')); // [1, [2]] console.log(arr.customFlatRecursive(-1)); // [1, [2]] console.log(arr.customFlatRecursive(0)); // [1, [2]] Infinity:我们已经处理了Infinity深度,它会扁平化所有层级。
4.2 空数组
如果输入数组是空的,或者嵌套数组是空的,flat 应该返回一个空数组。我们的递归和迭代实现都能自然地处理这种情况,因为循环不会执行。
console.log([].customFlatRecursive()); // []
console.log([[], [[]]].customFlatRecursive(Infinity)); // []
4.3 稀疏数组(Sparse Arrays)
稀疏数组是指那些包含“空槽”或未定义元素的数组。例如 [1, , 3]。原生 flat 方法在扁平化时会保留这些空槽,它们在结果中表现为 undefined。
const sparseArr = [1, , [2, , 3], 4];
// 原生 flat 行为:
// console.log(sparseArr.flat(1)); // [1, undefined, 2, undefined, 3, 4]
我们的实现,无论是递归还是迭代,由于使用了 for...of 循环或 for 循环遍历索引,会自然地将这些空槽作为 undefined 处理并添加到结果中,这与原生行为一致。
4.4 数组中包含非数组元素
flat 方法只会对数组类型的元素进行扁平化。如果数组中包含数字、字符串、布尔值、null、undefined 或对象,它们都会被直接添加到结果数组中,而不会被“展开”。
const mixedArr = [1, 'hello', [true, null], {a: 1}, undefined];
console.log(mixedArr.customFlatRecursive(1)); // [1, 'hello', true, null, {a: 1}, undefined]
我们的 Array.isArray(item) 判断确保了这一点。只有当 item 确实是数组时,才会尝试对其进行扁平化。
5. 最终整合:一个完整的 Array.prototype.flat 实现
综合以上讨论,我们将选择迭代优化版本作为最终的 Array.prototype.flat 实现。它在性能和鲁棒性方面表现均衡。
/**
* 手写实现 Array.prototype.flat
* 支持指定深度 (depth) 的数组扁平化算法
*
* @param {number} [depth=1] 扁平化的深度,默认为 1。可以为 Infinity。
* 如果 depth 不是一个大于 0 的整数,或者为 0,则返回数组的浅拷贝。
* @returns {Array} 扁平化后的新数组。
*/
if (!Array.prototype.customFlat) {
Object.defineProperty(Array.prototype, 'customFlat', {
value: function customFlat(depth = 1) {
// 1. 参数校验与默认值处理
// 确保 depth 是一个有效的数字。
// Number() 尝试将值转换为数字。
// isNaN() 检查转换结果是否为 NaN。
// Number.isInteger() 检查是否为整数。
// Math.max(0, ...) 确保深度不会是负数,并且非数字或负数深度被视为 0。
const actualDepth = Number.isInteger(Number(depth)) ? Math.max(0, Number(depth)) : 0;
// 2. 深度为0的特殊处理:返回原数组的浅拷贝
// 这里的 `this` 是调用 `flat` 方法的数组实例。
// 如果 `this` 不是数组,或者 `actualDepth` 为 0,则返回一个浅拷贝。
// 确保即使对非数组调用,也能返回一个合理的空数组或浅拷贝,尽管通常 `flat` 只在数组上调用。
if (!Array.isArray(this) || actualDepth === 0) {
return Array.isArray(this) ? [...this] : [];
}
// 3. 核心扁平化逻辑(迭代优化版本)
const result = [];
// 栈用于存储待处理的元素及其当前深度
// 栈中的每个元素都是一个 pair: [item, currentLevel]
const stack = [];
// 初始时,将原始数组的元素以逆序压入栈中,并设置它们的深度为0
// 这样在后续弹出时,可以保持原始的顺序,因为栈是LIFO
for (let i = this.length - 1; i >= 0; i--) {
stack.push([this[i], 0]); // 0代表当前元素的层级
}
// 循环处理栈中的元素,直到栈为空
while (stack.length > 0) {
const [item, currentLevel] = stack.pop(); // 弹出栈顶元素
// 如果 item 是一个数组,并且当前层级低于最大扁平化深度
// 注意:Infinity 与任何数字比较都会返回 true,因此也适用于 Infinity 深度
if (Array.isArray(item) && currentLevel < actualDepth) {
// 将子数组的元素以逆序压入栈中,并增加它们的层级
// 确保子数组的元素在弹出时也能保持其内部的原始相对顺序
for (let i = item.length - 1; i >= 0; i--) {
stack.push([item[i], currentLevel + 1]);
}
} else {
// 否则,将非数组元素或已达到最大深度的数组直接添加到结果数组的末尾
// 因为我们处理的顺序是“反向”的(从右到左压栈,从右到左弹出),
// 所以最终 result 数组的元素顺序是反的
result.push(item);
}
}
// 最后将结果数组反转,以恢复原始元素的相对顺序
return result.reverse();
},
writable: true,
configurable: true,
});
}
// --- 最终实现测试 ---
console.log("n--- 最终 Array.prototype.customFlat 实现测试 ---");
const testArray = [1, [2, 3, [4, [5]]], 6, [[7], 8]];
console.log("Original Array:", JSON.stringify(testArray));
// 默认深度 (1)
console.log("customFlat():", JSON.stringify(testArray.customFlat()));
// 预期: [1, 2, 3, [4, [5]], 6, [7], 8]
// 深度 0
console.log("customFlat(0):", JSON.stringify(testArray.customFlat(0)));
// 预期: [1, [2, 3, [4, [5]]], 6, [[7], 8]] (浅拷贝)
// 深度 1
console.log("customFlat(1):", JSON.stringify(testArray.customFlat(1)));
// 预期: [1, 2, 3, [4, [5]], 6, [7], 8]
// 深度 2
console.log("customFlat(2):", JSON.stringify(testArray.customFlat(2)));
// 预期: [1, 2, 3, 4, [5], 6, 7, 8]
// 深度 3
console.log("customFlat(3):", JSON.stringify(testArray.customFlat(3)));
// 预期: [1, 2, 3, 4, 5, 6, 7, 8]
// 深度 Infinity
console.log("customFlat(Infinity):", JSON.stringify(testArray.customFlat(Infinity)));
// 预期: [1, 2, 3, 4, 5, 6, 7, 8]
// 稀疏数组测试
const sparseTest = [1, , [2, , 3], , [4, , [5, , 6]]];
console.log("Sparse Array Original:", JSON.stringify(sparseTest));
console.log("Sparse Array customFlat(1):", JSON.stringify(sparseTest.customFlat(1)));
// 预期: [1, null, 2, null, 3, null, 4, null, [5, null, 6]]
// 注意: JSON.stringify 会将 undefined 序列化为 null,而我们的实现会是 undefined。
// 实际输出会是 [1, undefined, 2, undefined, 3, undefined, 4, undefined, [5, undefined, 6]]
console.log("Sparse Array customFlat(Infinity):", JSON.stringify(sparseTest.customFlat(Infinity)));
// 预期: [1, null, 2, null, 3, null, 4, null, 5, null, 6]
// 实际输出会是 [1, undefined, 2, undefined, 3, undefined, 4, undefined, 5, undefined, 6]
// 混合类型测试
const mixedTest = [1, 'hello', null, [true, undefined, {key: 'value'}], Symbol('sym')];
console.log("Mixed Array Original:", JSON.stringify(mixedTest));
console.log("Mixed Array customFlat():", JSON.stringify(mixedTest.customFlat()));
// 预期: [1, "hello", null, true, null, {"key":"value"}, Symbol("sym")]
// 实际输出会是 [1, "hello", null, true, undefined, {key: 'value'}, Symbol('sym')]
// 非数字深度测试
console.log("Non-number depth 'abc':", JSON.stringify(testArray.customFlat('abc')));
// 预期: [1, [2, 3, [4, [5]]], 6, [[7], 8]]
console.log("Negative depth -1:", JSON.stringify(testArray.customFlat(-1)));
// 预期: [1, [2, 3, [4, [5]]], 6, [[7], 8]]
// 确保不修改原数组
const originalArray = [1, [2]];
const flattenedArray = originalArray.customFlat(1);
console.log("Original array unchanged:", JSON.stringify(originalArray)); // [1, [2]]
console.log("Flattened array:", JSON.stringify(flattenedArray)); // [1, 2]
6. 实际应用与更广阔的视野
Array.prototype.flat 及其变体 flatMap 在现代JavaScript开发中扮演着越来越重要的角色。
- 数据预处理:在从API获取数据或处理用户输入时,数据可能以嵌套数组的形式返回。
flat可以快速将这些数据扁平化,便于后续的统一处理,例如过滤、映射或排序。 - 处理树形结构:当将树形或图状数据结构的所有叶子节点提取到一个列表中时,
flat(Infinity)尤其有用。例如,从一个多级菜单结构中提取所有可点击项。 - 配置管理:有时配置文件或权限列表会以嵌套数组的形式组织,扁平化后可以方便地进行查找和合并。
- 结合其他数组方法:
flat经常与其他数组方法(如map,filter,reduce)结合使用,形成强大的数据处理管道。例如,arr.map(item => item.value).flat()可以先提取每个对象的特定属性,然后将结果数组扁平化。 Array.prototype.flatMap:值得一提的是,ES2019 还引入了Array.prototype.flatMap,它相当于map后再flat(1)。它提供了一种更紧凑的方式来实现映射和扁平化的组合操作,对于处理返回数组的映射函数非常方便。
// 示例:使用 flatMap
const words = ["hello world", "how are you"];
const charArrays = words.map(word => word.split(''));
// charArrays: [["h", "e", "l", "l", "o", " ", "w", "o", "r", "l", "d"], ["h", "o", "w", " ", "a", "r", "e", " ", "y", "o", "u"]]
// 使用 flatMap 替代 map + flat(1)
const allChars = words.flatMap(word => word.split(''));
// allChars: ["h", "e", "l", "l", "o", " ", "w", "o", "r", "l", "d", "h", "o", "w", " ", "a", "r", "e", " ", "y", "o", "u"]
flatMap 是 flat 的一个非常实用的衍生,进一步简化了常见的数据转换模式。
7. 深入理解,精进编程技艺
手写实现 Array.prototype.flat 不仅仅是为了理解这个方法本身,更重要的是通过这个过程,我们能够:
- 强化对递归和迭代这两种核心算法思想的理解:它们是解决许多计算机科学问题的基石。
- 提升对数据结构(尤其是栈)的运用能力:显式栈的运用是迭代解决递归问题的一种常见模式。
- 精进对JavaScript数组方法的掌握:
Array.isArray,push,pop,unshift,reverse,concat,reduce等。 - 培养严谨的参数校验和边缘情况处理习惯:一个健壮的函数需要考虑所有可能的输入。
- 深入了解JavaScript引擎的性能考量:例如,
unshift的潜在性能问题,以及如何通过优化来避免。
通过这次深入的学习,我们不仅掌握了 flat 方法的实现,更重要的是提升了解决复杂问题的编程思维和实践能力。
今天的讲座就到这里,感谢大家的参与。希望通过这次探讨,能让各位对JavaScript数组扁平化算法有更深刻的理解。