不用flat实现数组扁平化:递归、栈迭代与生成器的完整指南
做代码评审的时候遇到一个很有意思的场景。同事提交的代码里用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或者用reduceconcat原地展开一层再循环。也就是说“手动扁平化数组”并不是没有原生方法之后才被迫琢磨的技巧它本身就长期存在于工程实践中。很多人觉得既然现在浏览器都支持 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 到 resultresult 是[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”的场合你脑子里浮现的不只是一个函数而是一整套可以按需选择的工具箱。最后再分享一个小习惯我写这类工具函数时一定会补上几个边界测试用例比如空数组、[[]]、混合类型数组、深层嵌套数组这样哪怕以后重构或者别人接手也不容易把行为改歪。