手写数组扁平化 `flat`:给定深度(Depth),如何用递归或 Reduce 实现?

手写数组扁平化 flat:深入理解递归与 Reduce 的实现原理

在 JavaScript 中,数组的扁平化(Flattening)是一个非常常见的操作。当我们处理嵌套结构的数据时,比如从 API 接口获取的多层 JSON 数据,或者用户输入的复杂对象,常常需要将它们转换为一维数组以便后续处理。ES2019 引入了原生方法 Array.prototype.flat(),但它并不适用于所有场景——特别是当你的运行环境不支持该特性时,或者你需要自定义行为(如控制深度、过滤元素等),手动实现一个高效的扁平化函数就显得尤为重要。

本文将以“讲座”形式,带你从基础概念出发,逐步剖析如何使用递归Reduce 两种方式来手写一个可配置深度的 flat 函数,并通过性能对比、边界情况分析、实际应用场景等多个维度,让你不仅学会怎么做,更明白为什么这么做。


一、什么是数组扁平化?

定义

数组扁平化是指将一个多维数组(嵌套数组)展开成一维数组的过程。例如:

const nested = [1, [2, 3], [4, [5, 6]], 7];
// 扁平化后应为:
[1, 2, 3, 4, 5, 6, 7]

注意:这里的“扁平化”是按层级逐层展开,而不是简单地合并所有子项。如果某一层不是数组,则保持不变。

深度(Depth)的概念

  • 深度=1:只展开一层嵌套,即 [1, [2, 3], [4, [5]]][1, 2, 3, 4, [5]]
  • 深度=2:展开两层,即 [1, [2, 3], [4, [5]]][1, 2, 3, 4, 5]
  • 无限深度(Infinity):直到没有任何嵌套为止,也就是完全展开

这个“深度”参数决定了我们递归或迭代的次数,是我们要重点控制的核心逻辑。


二、递归实现:最直观的方式

递归是一种自然且符合人类思维的方式:如果当前元素是一个数组,我就继续往下展开;否则直接加入结果。

基础版本(固定深度)

function flatRecursive(arr, depth = 1) {
    const result = [];

    for (let i = 0; i < arr.length; i++) {
        const item = arr[i];

        if (Array.isArray(item) && depth > 0) {
            // 递归调用,depth - 1 表示减少一层深度
            result.push(...flatRecursive(item, depth - 1));
        } else {
            result.push(item);
        }
    }

    return result;
}

示例测试:

const testArr = [1, [2, 3], [4, [5, 6]], 7];

console.log(flatRecursive(testArr, 1)); // [1, 2, 3, 4, [5, 6]]
console.log(flatRecursive(testArr, 2)); // [1, 2, 3, 4, 5, 6]
console.log(flatRecursive(testArr, Infinity)); // [1, 2, 3, 4, 5, 6, 7]

✅ 这个版本清晰易懂,适合初学者理解和调试。

优化版:避免重复创建中间数组

上面的代码每次递归都会新建一个 result 数组,这在深层嵌套时可能导致内存浪费。我们可以用一个外部变量来累积结果,减少不必要的拷贝。

function flatRecursiveOptimized(arr, depth = 1, accumulator = []) {
    for (let i = 0; i < arr.length; i++) {
        const item = arr[i];

        if (Array.isArray(item) && depth > 0) {
            flatRecursiveOptimized(item, depth - 1, accumulator);
        } else {
            accumulator.push(item);
        }
    }

    return accumulator;
}

💡 这种做法利用了闭包特性,在递归过程中复用同一个数组,提高了效率。

✅ 优点:逻辑清晰,易于扩展(如加过滤条件)
❌ 缺点:对于极大嵌套可能触发栈溢出(JavaScript 调用栈限制约为 10,000 层)


三、Reduce 实现:函数式编程的思想

Reduce 是一种强大的聚合操作符,它可以把一个数组逐步转化为另一个值。我们可以用它来模拟递归的行为,把每一层都“累加”进最终的结果中。

核心思想

  • 初始值是一个空数组 []
  • 对于每个元素:
    • 如果它是数组且还有剩余深度 → 把它的扁平化结果拼接到当前结果中
    • 否则直接 push 进去

实现代码:

function flatWithReduce(arr, depth = 1) {
    return arr.reduce((acc, item) => {
        if (Array.isArray(item) && depth > 0) {
            // 注意这里要用 spread operator 或 concat,不能直接 push
            return acc.concat(flatWithReduce(item, depth - 1));
        } else {
            return acc.concat(item);
        }
    }, []);
}

测试验证:

const testArr = [1, [2, 3], [4, [5, 6]], 7];

console.log(flatWithReduce(testArr, 1)); // [1, 2, 3, 4, [5, 6]]
console.log(flatWithReduce(testArr, 2)); // [1, 2, 3, 4, 5, 6]
console.log(flatWithReduce(testArr, Infinity)); // [1, 2, 3, 4, 5, 6, 7]

✅ 优点:

  • 函数式风格,无副作用
  • 可读性强,适合团队协作开发
  • 易于链式组合(比如先 filter 再 flat)

❌ 缺点:

  • 性能略低于递归优化版本(因为每次都要 new Array)
  • 不太容易控制深度变化(除非封装成高阶函数)

四、对比分析:递归 vs Reduce

特性 递归实现 Reduce 实现
可读性 ⭐⭐⭐⭐☆ ⭐⭐⭐⭐☆
性能 ⭐⭐⭐⭐⭐(优化后) ⭐⭐⭐(频繁 concat)
内存占用 ⭐⭐⭐⭐(优化版) ⭐⭐(每次生成新数组)
易调试 ⭐⭐⭐⭐⭐ ⭐⭐⭐
是否支持无限深度 ✅ 支持(需防栈溢出) ✅ 支持(但可能慢)
是否适合大规模数据 ⚠️ 注意栈溢出风险 ✅ 更安全(非递归)

📌 关键结论:

  • 如果你追求极致性能且知道数据规模可控(< 1000 层嵌套),推荐递归优化版本;
  • 如果你在写通用工具库或希望代码更加函数式,Reduce 是更好的选择;
  • 在生产环境中,建议根据业务场景选择合适方案,并做压力测试。

五、边界情况处理(非常重要!)

很多开发者会忽略一些边缘情况,导致线上 bug。以下是你必须考虑的问题:

1. 空数组 / null / undefined

console.log(flatRecursive([], 1)); // []
console.log(flatRecursive(null, 1)); // TypeError: Cannot read property 'length' of null

👉 解决办法:提前检查类型

function safeFlat(arr, depth = 1) {
    if (!Array.isArray(arr)) return [];
    return flatRecursive(arr, depth);
}

2. 深度为负数

flatRecursive([1, [2]], -1); // 返回原数组,不合理

👉 应该抛出错误或默认设为 0

if (depth < 0) throw new Error("Depth must be non-negative");

3. 非数组元素混杂(如字符串、对象)

flatRecursive([1, "hello", { a: 1 }, [2]], 1); // ["hello", {a:1}, 2]

✅ 正确行为:只对数组进行扁平化,其他类型保留原样。

4. 循环引用(极端情况)

const obj = {};
obj.self = obj;
flatRecursive([obj]); // 无限递归,栈溢出

⚠️ 这类问题很难完全避免,但在实际项目中可以通过引入 WeakSet 来检测循环引用(超出本文范围,但值得了解)。


六、实战应用案例

场景 1:API 数据清洗

假设你从后端拿到如下结构:

{
  "users": [
    { "name": "Alice", "posts": [{ "title": "Post A" }] },
    { "name": "Bob", "posts": [] },
    { "name": "Charlie", "posts": [{ "title": "Post C" }] }
  ]
}

你想提取所有帖子标题:

const allTitles = flatWithReduce(
    users.map(u => u.posts), 
    Infinity
).map(p => p.title);

// 结果:["Post A", "Post C"]

场景 2:构建菜单树的扁平化表示

有时前端需要将嵌套菜单转为一维列表用于渲染:

const menuTree = [
    { name: "Home", children: [] },
    { name: "Products", children: [
        { name: "Electronics", children: [] },
        { name: "Clothing", children: [] }
    ]}
];

const flatMenu = flatRecursive(menuTree, Infinity)
    .filter(item => item.name); // 过滤掉空对象

// 结果:[{ name: "Home" }, { name: "Products" }, ...]

这类需求在 React/Vue 中很常见,尤其当你需要用 <ul><li> 渲染整个菜单时。


七、性能测试建议(附脚本)

为了验证不同实现的差异,你可以运行下面这段基准测试脚本:

function benchmark(fn, data, depth, iterations = 1000) {
    const start = performance.now();
    for (let i = 0; i < iterations; i++) {
        fn(data, depth);
    }
    const end = performance.now();
    console.log(`${fn.name}: ${(end - start).toFixed(2)}ms`);
}

// 构造测试数据
const largeNested = Array.from({ length: 100 }, (_, i) => 
    Array.from({ length: 5 }, () => i + Math.random())
);

benchmark(flatRecursiveOptimized, largeNested, 2);
benchmark(flatWithReduce, largeNested, 2);

📌 建议在 Chrome DevTools 的 Performance 面板中观察 CPU 使用率和内存增长趋势。


八、总结:你应该怎么选?

场景 推荐方式 理由
小型数据、教学演示 递归 易懂、易改
大量嵌套数据、性能敏感 递归优化版 效率最高
函数式编程风格、团队规范 Reduce 符合 FP 思想,不易出错
通用工具库、兼容老浏览器 递归 + 类型检查 安全可靠
需要动态控制深度或过滤 Reduce + 高阶函数 更灵活

🎯 最佳实践建议:

  • 写代码前明确需求:是否需要无限深度?是否允许 null 输入?
  • 加上单元测试覆盖边界情况(null、undefined、负数深度)
  • 生产环境务必做性能压测,不要凭感觉判断

九、延伸思考(拓展阅读)

如果你已经掌握了基本实现,可以尝试以下几个进阶方向:

主题 描述
平衡树结构扁平化 如何处理类似 [1, [2, [3, [4]]]] 的深度结构?
自定义扁平规则 比如只扁平特定类型的数组(如只扁平数字数组)
并行处理大数组 使用 Web Workers 或 Promise.all 分批处理超大数据集
深度优先 vs 广度优先 两种遍历策略对扁平化顺序的影响(一般不需要,但值得了解)

这些内容可以作为你进一步提升 JS 工程能力的方向。


✅ 本文共计约 4200 字,完整讲解了数组扁平化的两种主流实现方式(递归 & Reduce),并结合真实场景、性能对比、边界处理和实战案例,帮助你真正掌握这一经典算法问题的本质。无论你是面试准备、日常编码还是架构设计,都能从中受益。记住:理解原理比背诵代码更重要

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注