做代码评审的时候遇到一个很有意思的场景。同事提交的代码里用arr.flat(Infinity)干净利落地把多层嵌套数组拍平了,功能完全正确,但组里新来的同学在旁边小声问了一句:“如果不能用 flat,这个数组要怎么手动展开?”这个问题其实比表面看起来有深度。“不用 flat 怎么扁平化数组”在原生方法还没出现之前就是前端面试的常客,放到今天也依然是检验一个人对递归、迭代和 JavaScript 数组机制理解程度的好题目。这篇文章就从零开始,把“自己实现数组扁平化”这件事完整讲透,覆盖递归、reduce、循环展开、栈迭代、生成器五种主流写法,再聊聊深度控制、稀疏数组、性能对比和常见坑。适合正在准备面试的同学,也适合工作中需要兼容老旧浏览器、或者单纯想搞懂原生 flat 背后原理的工程师。
1. 先从 flat 说起:为什么还有这个问题
1.1 flat 是什么,解决了什么
Array.prototype.flat是 ES2019 引入的原生方法,作用是返回一个新数组,把多维嵌套数组按指定深度拉平。arr.flat()默认只拉平一层,arr.flat(depth)可以指定层数,最常用也最省事的是arr.flat(Infinity),直接把所有层都拍平。ES2019 同时还引入了flatMap,本质是先 map 再 flat(1) 的合并操作,这里我们只讨论 flat。
在 flat 出现之前,前端处理这种需求大概有三个思路:自己写递归工具函数、引用 Lodash 这类工具库里的flatten/flattenDeep,或者用reduce+concat原地展开一层再循环。也就是说,“手动扁平化数组”并不是没有原生方法之后才被迫琢磨的技巧,它本身就长期存在于工程实践中。
很多人觉得既然现在浏览器都支持 flat 了,还研究手动实现是不是多此一举。我的观点恰恰相反:理解手动实现能让你在使用原生方法时更清楚它内部发生了什么,比如 flat 默认只拉平一层、空槽会被直接跳过而不是置成 undefined、以及返回新数组而不是修改原数组。这些行为细节,只有自己写一遍才能真正留下深刻印象。
1.2 面试题和工程背后的隐藏需求
“不用 flat 怎么扁平化数组”到现在依然是高频面试题,不是因为面试官闲得慌,而是这个题目短小精悍,却能一次性考察好几层东西:
- 会不会用递归,能不能写清楚终止条件;
- 知不知道用
Array.isArray判断数组,而不是靠typeof; - 有没有接触过 reduce、展开运算符、生成器这些基础特性;
- 懂不懂递归可能带来的调用栈溢出,以及怎么用迭代改写。
工程上也有很多场景不能直接无脑flat(Infinity)。最典型的是老项目需要兼容不支持 ES2019 的低版本浏览器,还有某些内部框架或编辑器环境对 Array.prototype 做了扩展或拦截;另外原生 flat 对空槽的跳过行为在部分业务里可能不是你想要的,比如你希望空槽位置保留一个默认值。很多 polyfill 库之所以要自己实现扁平化逻辑,也正是因为底层开发者需要把原生行为完全复刻成可裁剪的代码。这些情况都会促使你写一个自己的扁平化工具函数。接下来我把常见方案一个个拆开讲。
2. 基础方案:递归写法
2.1 最直观的递归版本
先写一个所有人第一反应都会想到的递归版本:
function flatten(arr) { const result = []; for (let i = 0; i < arr.length; i++) { const item = arr[i]; if (Array.isArray(item)) { const nested = flatten(item); for (let j = 0; j < nested.length; j++) { result.push(nested[j]); } } else { result.push(item); } } return result; }思路非常简单:遍历当前数组,如果某一项是数组,就递归地把子数组拍平,再把拍平后的每一项 push 进结果;如果某一项不是数组,直接 push。代码里我特意没用concat,而是用两层 for 循环手动拼接,这样能少创建一次中间数组。你当然也可以改成result = result.concat(flatten(item)),代码会短一点,但每次 concat 都会创建一个全新数组,元素数量大的时候 GC 压力会高一些。
这个版本有一个容易被忽略的地方:为什么用Array.isArray(item)而不是item instanceof Array?严格来说,在只考虑同一个全局作用域时两者都能用,但一旦页面里嵌入了 iframe 或者多个 window 上下文,数组实例从另一个上下文传过来时,instanceof会因为原型链指向不同而失效。Array.isArray是规范推荐的方式,它在跨上下文场景下依然可靠。写工具函数时尽量用Array.isArray,这是老手和新手一个很直观的区别。
2.2 函数式风格:reduce 与 concat 组合
如果你觉得两层 for 循环不够优雅,那 reduce + concat 是数组扁平化经典到不能再经典的函数式写法:
function flatten(arr) { return arr.reduce((acc, cur) => { return Array.isArray(cur) ? acc.concat(flatten(cur)) : acc.concat(cur); }, []); }reduce 从左到右遍历数组,把每次迭代的结果累积到初始值为[]的 acc 里。遇到数组就递归展开后再拼接,遇到普通值直接拼接。这个版本代码量小,表达也清晰,是很多文档和博客里最常出现的扁平化实现。
不过有一个点要留意:concat 每次都会创建新数组,所以这个版本在数据量较大时,中间临时数组的数量也会变多。假设一个长度一万的二维数组,每递归一层都会产生很多个临时数组。结论是:reduce 版本适合讲究代码简洁和函数式风格的场景,性能不是它的强项;如果你是在一个高频调用路径上处理大数组,我更推荐下面要讲的 for 循环 + push 版本。
2.3 递归方案的注意点
递归方案可以工作,但有三件事需要提前想清楚。
第一,递归深度过大会直接爆栈。JavaScript 引擎对递归深度一般有几万层的上限,一旦超过就抛出RangeError: Maximum call stack size exceeded。如果业务里真的会出现嵌套几万层的数据,递归方案就不合适了,你得换栈迭代那套。
第二,原数组不会被修改,这点和原生 flat 保持一致。函数内部创建新数组,旧数组依然保持嵌套状态。如果你希望原地修改,那要另外做设计,但从函数式编程习惯来说,返回新数组通常是更安全的选择。
第三,递归里的子数组一旦存在重复引用,比如同一个数组对象被两个父节点引用,扁平化过程中可能会产出重复元素。这个问题不常见,但处理树形结构转一维数组时概率会高一些,最好提前意识到副本不是引用拷贝,结果里的元素仍是同一个对象引用。
3. 进阶方案:不用递归的迭代实现
3.1 扩展运算符 + some 的“无脑拍平法”
如果不主动写递归,JavaScript 里还有一个很“无脑”的拍平办法,核心是 while + some + 展开运算符:
function flatten(arr) { const result = [...arr]; while (result.some((item) => Array.isArray(item))) { result = [].concat(...result); } return result; }代码逻辑是:先把原数组浅拷贝一份到 result,只要 result 里还有任一元素是数组,就用[].concat(...result)把所有元素展开后重新拼成一个新数组。因为...在这里会把 result 的每个元素作为独立参数传给 concat,而 concat 遇到参数是数组时会把数组元素取出来,所以一层嵌套就被拉平了。循环继续,直到不再存在数组元素。
这个方法写起来很爽,适合面试时展示对展开运算符和 some 的熟练度。但它有两个明显的性能问题:每次循环都要用some扫描一遍全部元素,同时[].concat(...result)又把整个数组重新创建一次,对于“深度很深”且“每一层元素很多”的数组,循环次数和中间数组数量都会显著增加。实测下来,嵌套层数在几层以内、元素总量不太大时,它用起来没问题;一旦数组又大又深,速度肉眼可见地变慢。所以它更适合“理解起来最快”的场景,不适合“跑起来最快”的场景。
顺带提一句,展开运算符在这里能工作,完全是因为它本身就不是“把数组作为一个参数传进去”,而是把数组的每个元素展开成独立参数。理解这个机制,你还能顺带理解剩余参数(rest parameters)为什么能收集参数成数组——一个是拆开,一个是收起,两个操作正好互补,这也是 JavaScript 参数处理里非常基础的一对操作。
3.2 栈迭代:完全避开递归层数限制
递归的本质是依赖调用栈,如果我们把递归改写成一个显式的 stack(栈),递归深度限制就不再是问题。栈迭代的写法如下:
function flatten(arr) { const stack = [...arr]; const result = []; while (stack.length > 0) { const item = stack.pop(); if (Array.isArray(item)) { stack.push(...item); } else { result.push(item); } } return result.reverse(); }思路是把所有待处理的元素压入一个栈,每次从栈顶弹出一个元素。如果弹出的元素是数组,就把它的元素全部再压回栈中,等下一轮继续处理;如果不是数组,就记录到 result 里。由于后弹出的元素会被优先处理,整体顺序是被倒转的,所以最后要reverse()一次才能恢复原始顺序。
拿[1, [2, [3, 4]]]举例:初始 stack 是[1, [2, [3, 4]]];第一个 pop 出来的是[2, [3, 4]],它是数组,于是把 2 和[3, 4]压回去,stack 变成[1, 2, [3, 4]];再 pop 出[3, 4],压入 3、4;继续 pop 出 4、3、2、1,依次 push 到 result,result 是[4, 3, 2, 1],最后 reverse 成[1, 2, 3, 4]。走一遍这个过程,你就彻底明白 reverse 为什么是必需的了。
这个版本是我在实际工程里最常用的写法。理由有三条:
- 它不做递归,不消耗调用栈,即使嵌套层数非常深也不会栈溢出;
- 只用了一个栈加一个结果数组,没有大量中间数组;
- push、pop 在数组尾部操作,JS 引擎对尾部操作优化得非常好。
在某些极端场景,比如嵌套了十万层的数组,递归方案早就爆栈了,栈迭代依然能稳定跑完。代价只是代码没有递归版本那么直观,第一次看的人可能要花几秒钟反应一下为什么最后要 reverse。
3.3 生成器版本:懒人也有懒办法
函数式编程和迭代之外,还有一种思路是用生成器(generator)来做。生成器的特点是可以惰性求值,每次yield一个值,而不是一次性把所有结果算完:
function* flatten(arr) { for (const item of arr) { if (Array.isArray(item)) { yield* flatten(item); } else { yield item; } } } // 使用时转成数组 const flatArr = [...flatten(nestedArr)];这个版本非常优雅。yield* flatten(item)会把子生成器产出的每一个值一块块交给外层,这样整个嵌套数组就像一条流水线,一次吐一个元素出来。如果数据总量特别大,而你只是要逐个消费,而不是真的需要一个完整数组,那直接for (const x of flatten(arr))就能获得流式处理的效果,不需要一次性把所有元素都存在内存里。
不过要说清楚:生成器版本在“遍历深度”上依然是递归的,它的惰性是“值的产出是惰性的”,但生成器调用栈同样会随着嵌套深度增长,极端深层嵌套依然可能爆栈。所以如果你对抗栈溢出有硬性要求,还是栈迭代最靠谱。生成器更大的价值在于“不用等全部拍平才开始处理”,配合管道式数据处理非常合适。
4. 深度控制和边界情况
4.1 实现一个带 depth 参数的扁平化
原生 flat 允许传深度,那我们自己写也最好支持。稍微改一下递归版本就能实现:
function flatten(arr, depth = 1) { return arr.reduce((acc, cur) => { if (Array.isArray(cur) && depth > 0) { return acc.concat(flatten(cur, depth - 1)); } else { return acc.concat(cur); } }, []); }默认深度是 1,和原生 flat 默认行为一致。flatten(arr, Infinity)可以拍平所有层,但要注意:Infinity在递归里会一路递减,哪怕Infinity - 1仍然是 Infinity,所以能一直递归下去。这也侧面说明Infinity作为深度参数的巧妙之处——原生 flat 正是利用了这个数学特性。
深度参数还得注意这样一个陷阱:传负数、NaN或非数值类型时,判断会变得很随意。更可靠的做法是在函数入口做一次数值归一化:
function flatten(arr, depth = 1) { const d = Number.isFinite(depth) ? Math.max(0, depth) : Infinity; // 后续用 d 判断 }这样flatten(arr, -1)返回的其实就是原数组各元素不展开的结果,flatten(arr, NaN)则等同Infinity的行为。工程上提前把参数规范好,后面递归逻辑就能省掉很多“玄学判断”。这是我实际写工具函数时经常加的一步,看起来不起眼,但能避免很多调用端的不可控输入。
4.2 稀疏数组的空槽问题
数组里可能有没有赋值的空位,比如const arr = [1, , 2],这个空位就是 hole。原生 flat 在遇到 hole 时会直接跳过,不会把它转成 undefined。这是很多人没注意到的细节。
const arr = [1, , [2, , 3]]; arr.flat(); // [1, 2, 3]如果你希望自己的扁平化实现也保持这个行为,就不能简单用 for 循环去遍历,因为 for 循环在空槽位置会得到一个 undefined 值,然后把它当成普通值 push 进去。用 forEach 倒是会跳过空槽,但 forEach 又不能配合普通 return 收集结果。一个干净的技巧是用in操作符判断索引是否真实存在:
function flatten(arr) { const result = []; for (let i = 0; i < arr.length; i++) { if (!(i in arr)) { continue; } const item = arr[i]; if (Array.isArray(item)) { result.push(...flatten(item)); } else { result.push(item); } } return result; }不过说实话,绝大多数业务根本不会出现稀疏数组,如果你只是写个工具函数,不用过度纠结这个语义差异。知道“原生 flat 会跳过空槽”这一点,已经足够你在面试里区别开自己和只会背 API 的人。
4.3 循环引用和循环嵌套
还有一种更罕见的边界:数组里出现自引用。
const arr = [1, 2]; arr.push(arr); // 数组引用了自身这种数组传给任何递归扁平化函数都会陷入无限递归,原生 flat 也一样会挂。想防御可以在函数里维护一个 Set,记录已经处理过的数组对象,遇到重复引用直接跳过:
function flatten(arr, seen = new Set()) { if (seen.has(arr)) { return []; } seen.add(arr); const result = []; for (const item of arr) { if (Array.isArray(item)) { result.push(...flatten(item, seen)); } else { result.push(item); } } return result; }这个需求实际业务里确实很少见,多见于处理 JSON 序列化异常或外部数据源清洗。知道有这层风险就够了,日常写代码时遇到再补也来得及。不过一旦你写了这个防御,就相当于给函数增加了一层“记忆”,处理相互引用的树结构时会安全很多。
5. 性能对比与选型建议
5.1 五种方案的关键差异对比
不同方案在可读性、性能、兼容性上各有取舍,我整理成一张表方便对照:
| 方案 | 递归调用 | 是否修改原数组 | 性能表现 | 适合场景 |
|---|---|---|---|---|
| for 循环 + push | 是 | 否 | 大数组性能良好 | 通用场景,推荐优先考虑 |
| reduce + concat | 是 | 否 | 中间数组多,偏慢 | 代码简洁优先的教学写法 |
| 扩展运算符 + some | 否 | 否 | 层数浅时尚可,又大又深时明显变慢 | 面试演示或深度很浅的数据 |
| 栈迭代 | 否 | 否 | 性能稳定,无爆栈风险 | 深层嵌套、大数据量首选 |
| 生成器 | 是 | 否 | 惰性产出,适合流式消费 | 大数组逐条处理场景 |
我本地跑过简单测试,数据量在几千到几万、嵌套两到三层时,for 循环 + push 和栈迭代是表现最稳的两个;reduce + concat 因为中间数组分配太多,会稍慢一些;扩展运算符 + some 在元素多且层数多的时候最吃亏。如果你追求综合最优,我个人的建议是记住栈迭代版本,它在深层和常规场景下都很能打。
5.2 实际工程里怎么选
一口气记五六个实现方法不太现实,实际项目里要做的是分清场景:
- 新项目、运行环境确定支持 ES2019,直接用
arr.flat(Infinity),不要自己造轮子; - 需要兼容老浏览器或有定制需求(比如跳过空槽、保留 undefined、限制深度),选一个简单递归版本就够;
- 数据量大、嵌套深、或者数据是从后端一次性拉回来的大对象,优先用栈迭代;
- 要处理一个超大甚至无限的数据流,才考虑生成器版本。
不要把“不用 flat 实现扁平化”理解成鼓励大家抛弃原生方法,它的核心价值是让你在需要的时候能写出一个靠谱的自定义方案,同时理解原生方法为什么好用。
6. 常见问题与排查技巧实录
6.1 递归栈溢出的定位和替代方案
遇到RangeError: Maximum call stack size exceeded,先别急着觉得是数组嵌套无限了。最常见的原因是某一条嵌套路径特别深,哪怕只有一万层,递归一层层调用也会把栈打满。定位方法很简单:在递归函数入口打日志或断点,看递归到多少层时报错;然后用浏览器开发者工具的 call stack 面板查看调用链是不是有你没注意到的自引用。
替代方案优先考虑栈迭代写法。栈迭代消耗的是堆内存,虽然也有上限,但比调用栈大得多,几个 g 的内存用来跑扁平化绰绰有余。如果连内存都扛不住,那就要考虑生成器流式处理和分页加载,一次性把整个嵌套数组塞进内存本来就不合理。
6.2 判断数组的几种方式,别用错
排查错误时经常看到有人用typeof item === 'object'来判断数组,这是错的。typeof[]的结果是'object',它无法区分数组和普通对象。三种常见判断方式要对齐:
| 判断方式 | 是否推荐 | 说明 |
|---|---|---|
Array.isArray(item) | 推荐 | 跨上下文可靠,语义清晰 |
item instanceof Array | 不推荐 | 跨 iframe 等场景会失效 |
typeof item === 'object' | 错误 | 数组、对象、null 都会返回 object |
如果你的扁平化函数不小心把对象也当数组展开,结果一定不是空数组就是出现一堆[object Object]。遇到这种问题,回来看判断条件是不是写错了。另外还要注意arguments对象是类数组但不是数组,Array.isArray(arguments)是 false,这类数据想扁平化得先转成真正的数组。
6.3 反面教材:字符串 hack 为什么搜得到但不要用
网上搜数组扁平化,偶尔会看到这样一个“一行代码”:
JSON.parse('[' + JSON.stringify(arr).replace(/[\[\]]/g, '') + ']')思路是用 JSON.stringify 把数组变成字符串,然后删掉所有方括号,再用 JSON.parse 转回数组。它对纯数字数组确实能跑通,但一遇到下面这些情况就废了:
- 元素是字符串且内容包含方括号,会被误删;
- 元素是 undefined、函数、Symbol、BigInt 或含循环引用的对象,JSON.stringify 根本序列化不了;
- 性能极差,字符串转换和正则处理在大数组上慢得离谱。
这个写法能作为反面教材提醒我们:数组扁平化的本质是递归或迭代地重组数据结构,而不是“用字符串操作曲线救国”。凡是需要依赖序列化副作用来实现数据操作的方案,几乎都是脆弱的。
我个人在实际操作中的一个体会是:如果你把上面这几种实现都亲手敲一遍,再拿几组“嵌套很深、带空槽、元素类型混杂”的测试用例跑一跑,你对数组结构、递归调用栈、迭代替代方案这三个概念的理解会比只看文档深刻得多。以后真的遇到“不能直接用 flat”的场合,你脑子里浮现的不只是一个函数,而是一整套可以按需选择的工具箱。最后再分享一个小习惯:我写这类工具函数时,一定会补上几个边界测试用例,比如空数组、[[]]、混合类型数组、深层嵌套数组,这样哪怕以后重构或者别人接手,也不容易把行为改歪。