数组扁平化估计是JavaScript里最经典的一道手写题了。不管是面试初筛、还是日常业务里处理多层嵌套数据“把二维、三维甚至更深层的数组拉成一条平铺数组”这个需求几乎绕不开。ES2019之后有了Array.prototype.flat一行代码就能解决但问题在于老环境里不一定有这个API面试官也经常补一句“不能用flat你手写一个”。这篇文章我就把这几年写过的几种思路都整理出来从最简单的递归到偏工业级的迭代方案连同那些文档里不会写的坑位一起聊透。文章里所有代码我都会逐步拆解适合三种人看第一种是刚接触JavaScript、想搞懂手写扁平化思路的新手第二种是准备面试、想把手写题答出加分项的求职者第三种是在实际项目里被嵌套数组折磨过、想找一个稳一点的工具函数的开发者。不管你现在是哪种状态看完之后至少能自己写出两三种不同思路的实现而且知道它们各自在什么场景下会翻车。1. 为什么要自己实现扁平化直接flat不香吗1.1 flat的兼容性短板Array.prototype.flat是 ES2019 才进入规范的方法。ES2019 对应的时间点大概是 2019 年但浏览器跟进需要时间更别提很多企业内部的老系统还跑在定制内核的浏览器上。我自己就遇到过这种情况明明代码里写了arr.flat(Infinity)测试环境一切正常到了某个客户的内网环境直接报arr.flat is not a function。所以很多团队会做 polyfill。但 polyfill 本身也是要有人写出来的如果你能亲手实现一套兼容性方案比直接引入一个core-js的依赖更可控。而且面试的时候手写扁平化几乎是“重灾区”你光会说flat怎么用是没有价值的面试官真正想看的是你对递归、迭代、数据结构基础的理解程度。1.2 业务里到底什么时候用得上除了面试题日常开发里嵌套数组的出现频率比你想象的更高。举几个我遇到过的真实场景多级菜单的接口返回了children嵌套结构要把所有菜单的id收集成一个一维数组后端却不肯改接口。地区联动组件拿到了省、市、区多级嵌套的数据前端需要把所有层级的名字拉平用来做关键词搜索。接口返回的某个字段是数组数组里每一项又可能带着子数组数据清洗阶段就得先把它们拍平。这三个场景的共同点是你不一定要“完全扁平化”而是希望在处理嵌套结构的过程中顺手做去重、做筛选、做条件判断。这时候手写一个工具函数比到处调用flat再叠加filter更灵活。2. 扁平化的本质先理清需求边界再动手2.1 先搞清楚你要的是哪种扁平化很多人一上来就写递归但写之前应该先问自己一个问题这个函数到底要把数组扁到什么程度我把需求拆成三种形态浅扁平化depth1只展开最外层[1, [2, [3]]]变成[1, 2, [3]]。限深扁平化depthn展开指定层数比如depth2时展开两层。完全扁平化depthInfinity不管嵌多深全部拉成一维数组。原生flat的默认行为是第一种你直接写arr.flat()只展开一层想完全展开必须传Infinity。手写时这三个需求都要考虑否则写出来的函数换一个场景就没法用了。我的建议是自定义实现一律支持depth参数默认值设为1这样和原生行为对齐别人用起来也不会有认知负担。2.2 判断一个元素是不是数组的标准姿势写扁平化函数离不开一个核心判断当前这一项是不是数组。这里必须用Array.isArray(item)千万别用typeof item object因为数组、对象、null的 typeof 结果都是object你会把null和普通对象也当成数组去处理结果完全不对。另外还要注意类数组对象比如arguments、NodeList也不能直接被Array.isArray判定为数组它们没有数组的扁平化语义。所以规范一点的做法是只处理“真数组”其他类型一律当作普通元素直接放进结果。2.3 三种大方向递归、迭代、生成器手写扁平化的实现思路大体有三条线递归是最符合人类直觉的写法遇到数组就调自己但深层嵌套时有调用栈溢出的风险。迭代 栈/队列是把递归改成循环不依赖调用栈适合处理超大数据和超深嵌套。生成器自带惰性求值特性适合处理“不需要一次性全部展开”的场景配合for...of可以边遍历边生成结果。这三种路线没有绝对的优劣我后面会各写一个版本再告诉你每个版本适合用在哪里。3. 四种手写实现逐个拆解3.1 递归实现最符合直觉的版本先看最直白的一种写法function flatRecursive(arr, depth 1) { const result []; for (const item of arr) { if (Array.isArray(item) depth 0) { result.push(...flatRecursive(item, depth - 1)); } else { result.push(item); } } return result; }这段代码的逻辑很清晰遍历原数组的每个元素遇到数组且还有展开深度就递归调用函数把返回的数组解构后放进result否则直接把元素推进结果。这里我特意用了for...of而不是forEach原因后面在“稀疏数组”那一节会讲到。你可能会问为什么不用map因为map是等长映射无法天然处理“一个元素变多个元素”的情况你还得在外面包一层concat或flat那等于又绕回原点了。递归版本的最大隐患就是层级特别深的时候会爆栈。什么叫“深”我看过一些测试递归深度超过一万层Chrome 的调用栈就受不了了。业务数据一般不会嵌到一万层但如果你要做一个完全扁平化的工具函数谁也说不准输入数据会是什么样。3.2 reduce concat最优雅的写法用reduce可以把递归和数组合并优雅地结合起来function flatReduce(arr, depth 1) { return arr.reduce((acc, item) { if (Array.isArray(item) depth 0) { return acc.concat(flatReduce(item, depth - 1)); } return acc.concat(item); }, []); }reduce的本质是“把一个数组归约为一个值”这个“值”刚好可以是数组。每次遍历到一个元素如果它是数组就把递归结果concat到累加器里否则直接concat当前元素。这套写法的优势是纯函数、无副作用而且代码量很少。ES2019 的flat规范实现里内部也用了类似FlattenIntoArray的抽象逻辑你可以理解成手写版就是对规范的精简模拟。要注意的点是concat只能展开一层数组所以当递归结果本身就是一维数组时concat刚好能把它拼接成最终结果。如果你用push(...arr)替代效果一样但碰上超大数组时push(...largeArray)可能因为实参数量过多而报Maximum call stack size exceededconcat在参数处理上更稳一些。3.3 生成器函数适合流式处理的版本如果追求更现代的写法可以上生成器function* flatGenerator(arr, depth 1) { for (const item of arr) { if (Array.isArray(item) depth 0) { yield* flatGenerator(item, depth - 1); } else { yield item; } } } // 使用方式 const result [...flatGenerator([1, [2, [3]]], Infinity)];yield*的意思是“委托给另一个生成器”它会把嵌套生成器产生的所有值依次转发给外层调用者。这个版本最妙的地方在于它不会立刻生成一个完整的数组而是每次迭代时才计算下一个值。如果数据量极大你甚至可以只取前几个值const gen flatGenerator(massiveNestedArray, Infinity); console.log(gen.next().value); // 只取第一个扁平化结果这才是生成器真正的优势——惰性求值。坏处也很明显如果你只是想要一个普通数组还得用扩展运算符或者Array.from把它收集起来中间多一层转换。另外生成器在性能上和直接循环相比会有额外的迭代器开销小数据量无所谓百万级数据下不如后面的迭代版本灵光。3.4 栈迭代不依赖递归的工业级方案最后是压轴方案——用栈或者叫显式栈模拟递归这也是我在生产环境里更愿意用的版本function flatStack(arr, depth 1) { const stack [...arr].map((item) [item, depth]); const result []; while (stack.length 0) { const [item, currentDepth] stack.pop(); if (Array.isArray(item) currentDepth 0) { stack.push(...item.map((child) [child, currentDepth - 1])); } else { result.push(item); } } return result.reverse(); }这段代码稍微有点绕我来拆一下先把原数组中的每个元素和初始深度配对放进栈里比如[1, [2, [3]]]就变成[[1, 1], [[2, [3]], 1]]。每次从栈尾弹出最后一个元素检查它是不是数组、还能不能展开。如果还能展开就把它的子元素逐个压回栈里同时深度减一。如果不能展开就直接把元素推进结果数组。因为pop是从栈尾取而后处理的元素会被先放进result所以最终顺序是反的最后必须reverse()一下。如果你不想用reverse可以把pop改成shift从数组头部取元素但shift的时间复杂度是 O(n)数据量大的时候性能很差。相比之下pop reverse是更划算的组合。栈迭代版本最大的好处是无论嵌套多深都不占用调用栈。它把“接下来要处理什么”这件事从调用栈搬到了一个普通数组里而普通数组在内存里可以动态扩展只要内存够就能一直往下压。我实测过深度达到几十万层的畸形数据递归版本早就炸了栈版本还能稳定跑完。4. 边界情况、性能坑位与兼容性陷阱4.1 稀疏数组forEach和for...of的结果竟然不一样JavaScript 数组是可以有“空洞”的。比如你写const arr [1, , 2]中间这一项就是空位。原生flat会跳过空位但不同写法的处理方式完全不同const arr [1, , [2, , 3]]; // 方式一for...of 遍历 for (const item of arr) { console.log(item); // 1, undefined, [2, undefined, 3] } // 方式二forEach 遍历 arr.forEach((item) console.log(item)); // 1, [2, undefined, 3]看到区别了吗forEach会跳过空位for...of会把空位当作undefined处理。原生flat的行为更像forEach会跳过空位。所以如果你追求和flat完全一致的行为用for...of的版本就需要额外判断item ! undefined或者改用forEach。但反过来讲有些业务场景里你可能希望把空位显式地变成undefined来保证索引不错位这就是各取所需。我在代码注释里通常会专门写一句“是否跳过稀疏空位”让使用者自己决定。另外提醒一句JSON.stringify对稀疏数组的空位会序列化成null这又是一个容易踩的坑。如果你用后面讲的 JSON 技巧去扁平化空位会全部变成null。4.2 非真数组元素别让对象混进来有人写扁平化的时候图省事用typeof item object判断是否要递归这一下就出问题了。null的typeof也是object普通对象也是它们根本没有length和迭代器硬要当数组处理就会报错。正确的顺序是先判断Array.isArray(item)不是数组的直接收进结果。这是唯一不会误判的方式。如果你还想兼容类数组对象比如arguments需要额外用Array.from或者Array.prototype.slice把它们转成真数组但多数情况下没必要扁平化就专注处理真数组别给自己加戏。4.3 JSON序列化技巧看着炫但坑很多网上流传过一种很炫的“一行代码扁平化”function flatJSON(arr) { return JSON.parse([ JSON.stringify(arr).replace(/\[|\]/g, ) ]); }原理是先把数组序列化成字符串把所有的[和]都去掉再包上新的中括号解析回数组。听起来很巧妙但它有三个致命问题undefined、函数、Symbol在JSON.stringify过程中会被丢弃或者变成null数据被破坏了。空位会被序列化成null丢失原始结构信息。字符串内部如果恰好包含方括号字符比如a[b]替换时会误删直接被污染。我自己在技术群里见过有人把这段代码贴出来当“黑魔法”真要拿去处理业务数据迟早出事。它只能拿来在纯数字、纯字符串的简单场景里炫技我建议你只把它当成一个反面教材来理解。4.4 递归爆栈的边界在哪里你可能想确认一下递归到底多深会爆栈这个没有固定数字和浏览器、操作系统、上下文大小都有关系。我在 Chrome 下用最简单的自递归函数测试过大概一万多层就会报Maximum call stack size exceeded。所以如果你的数据源是可控的几千层以内递归问题不大如果数据来自外部接口或者用户上传的内容就一定要限制depth或者直接改用栈迭代版本。还有一种更隐蔽的爆栈情况数组存在循环引用。比如const a [1]; a.push(a)这时如果你用flatRecursive(a, Infinity)会无限递归直到栈爆。原生flat也不太可能处理循环引用。我在工具函数里会加一个seen集合已经访问过的数组对象再次出现时直接跳过防止死循环。5. 常见问题速查与避坑清单5.1 手写题考法扁平化 去重 排序面试里最常见的变化是把扁平化和其他数组操作糅在一起。比如“请把嵌套数组扁平化然后去重最后升序排序。”function flatUniqueSort(arr, depth Infinity) { const result []; const seen new Set(); (function inner(items, d) { for (const item of items) { if (Array.isArray(item) d 0) { inner(item, d - 1); } else if (!seen.has(item)) { seen.add(item); result.push(item); } } })(arr, depth); return result.sort((a, b) a - b); } console.log(flatUniqueSort([3, [1, [2]], 3, [1, 4]])); // [1, 2, 3, 4]这种复合题考察的就是你把基础技巧组合起来的能力。去重用Set是最简单的排序时要注意sort默认按字符串排序数字数组必须传 comparator。只要你基础版本能写对组合题并不难。5.2 面试要提的几个加分细节如果想在手写题上打出差异化不用多写代码多聊几个细节就能让面试官眼前一亮解释Array.isArray相比typeof的优势说明你踩过坑。主动提到稀疏数组的处理差异并说明forEach会跳过空位。提到Symbol.isConcatSpreadable。这个符号可以控制concat是否展开数组如果在对象上设置为trueconcat时也会被展开。这属于偏冷门的知识能说出来基本都是加分项。比较递归和迭代的爆栈风险说明你知道如何在深层嵌套数据下保命。以上几点不用全说挑一两个自然地带出来就好刻意堆砌反而显得奇怪。5.3 生产环境实测建议如果你想把上面的某个版本直接用到项目里我的建议是数据量小几千条以内且嵌套可控用flatReduce版本代码简洁维护成本低。数据量大、层级深、不可控用flatStack版本配合depth参数做上限保护。要做流式处理或只取前几个结果用flatGenerator。能用原生flat且有 polyfill 兜底就用原生flat它经过了最完整的测试性能也是最优的。手写方案的价值在于理解而不是替代。我在项目里封装过一个flatten(arr, depth Infinity)工具函数内部先用Array.isArray做前置校验再用栈迭代最后统一返回新数组。这个函数至今跑了三年多没出过一次问题。如果你不想自己维护直接采用3.4节的栈迭代版本就够用了。附录四种实现快速对比实现方案代码复杂度是否容易爆栈性能表现推荐场景递归版本低深嵌套时容易爆栈中等学习理解、浅层数据reduce版本低深嵌套时容易爆栈中等小数据量、追求简洁生成器版本中不会爆栈中下额外迭代开销流式处理、惰性取值栈迭代版本中高不会爆栈高生产环境、不可控深层数据