手写数组扁平化 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),并结合真实场景、性能对比、边界处理和实战案例,帮助你真正掌握这一经典算法问题的本质。无论你是面试准备、日常编码还是架构设计,都能从中受益。记住:理解原理比背诵代码更重要。