ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JavaScript数组遍历与扁平化:从方法选型到实战避坑

JavaScript数组遍历与扁平化:从方法选型到实战避坑 数组操作是前端日常开发里最绕不开的基础功但“会用”和“用得稳”其实是两回事。很多刚工作一两年的同事写的工具方法跑通业务没问题一遇到深层嵌套数据、超大列表或者边界输入就露馅了。这篇博文想和你认真聊聊普通数组遍历和扁平化这两件事从方法选型到底层原理从手写实现到实战中的树结构扁平化再到那些踩过好几次才记住的坑。内容适合刚入门想系统梳理的初学者也适合写了很多年代码但没细想过“为什么选这个方法”的进阶开发者。1. 遍历方法选型工具函数的地基1.1 先搞清楚你手里的牌6种遍历方式的能力边界JavaScript里能遍历数组的方式非常多但很多人在写工具方法时根本不挑拿到什么用什么结果要么性能差要么返回值不符合预期。我按自己的使用习惯把常规遍历方式分了三类先看对比再聊选型逻辑。第一类是传统循环家族for、while、do...while。它们的共同特点是灵活可以通过索引精确控制起点、终点和步长也能随时break或return。性能上for循环在没有额外函数调用开销时通常是最快的尤其处理十万级以上数据时优势明显。缺点是代码相对啰嗦容易写错边界条件。第二类是迭代器家族for...of。它底层调用了数组的Symbol.iterator接口能在所有可迭代对象上用使用break和return也没有问题可读性比传统for好不少。代价是比原生的for略慢不过在绝大多数业务场景里这个差距根本感知不到。第三类是函数式遍历家族forEach、map、filter、some、every、reduce。它们声明式更强代码意图一目了然核心特点是大多数方法会创建新数组或返回布尔值或聚合值不会污染原数组。但forEach本身有几个硬伤无法中断不能用break、不返回新数组、异步场景下不会帮你等待 Promise。很多人以为forEach是万能的其实它在我眼里是最“鸡肋”的一个——能替代它的方法实在太多了。选型逻辑其实可以简化成一句口诀要提前退出就用for或for...of要做数据变换就用map要过滤就用filter要把数组“化零为整”就用reduce要判断存在性就用some要全部满足才过就用every。核心思路是先想清楚你希望这个遍历“产出什么”再决定用哪个方法而不是拿forEach一把梭。1.2 为什么跨方法调用比单独写循环更稳妥我开发工具函数时的习惯是优先组合数组自带的高阶方法而不是从头写死循环。原因不只是代码短更关键的是“意图明确”。举个例子你需要把一个对象数组里的name字段抽出来并过滤掉空字符串。有人会这样写const names []; for (let i 0; i list.length; i) { if (list[i].name ! ) { names.push(list[i].name); } }这段代码没错但读代码的人必须完整跑一遍逻辑才能明白你在干什么。如果换成const names list .map(item item.name) .filter(name name ! );一眼就能看出先取字段再过滤。这样写还有一个隐藏好处——链式调用里的每一步都是不可变操作不会修改原数组这在复杂业务里能省掉大量调试时间。当然链式调用在数据量极大时会有额外开销因为每一步都会创建新的中间数组。所以我的原则是业务代码里可读性优先遇到万级以上的性能瓶颈再考虑用普通for重写成命令式。工具函数库的情况稍微特殊一点因为会被高频复用建议在核心路径上用for循环打磨性能但对外保留语义清晰的封装接口。1.3 map、filter、reduce 三兄弟的分工与配合关于map、filter、reduce这三个高频方法我单独拎出来说因为在工具函数里它们出场率最高也最容易用混。map强调的是“一对一映射”。原数组有多少项结果数组就有多少项只是每个元素可能变成了新的形态。它适合做字段抽取、格式转换、给每个对象附加计算属性。如果你用了map却发现返回结果里被过滤掉了某些项那说明选错方法了你应该用filter或者flatMap。filter是“筛选”它只决定“留还是不留”不会改变元素本身的形状。所以常见套路是先map再filter或者反过来先filter再map区别是先缩小数据量再做映射在数据量大的时候后者更快。reduce是这里面最灵活、也最难读的。它能做任何数组聚合操作求和、分组、转对象、拍平数组通通能做。我建议把reduce当成“最后的武器”——当map和filter组合解决不了问题或者你需要从前到后累积状态时再用它。比如把数组转成以id为键的字典const dict list.reduce((acc, item) { acc[item.id] item; return acc; }, {});这个场景如果用map就得先转成键值对数组再用Object.fromEntries不是不行但没必要。reduce的核心逻辑就是“遍历每一项不断更新一个累计值”你把每次更新的规则写清楚就能用它实现大量复杂逻辑。2. 扁平化不止是 flat() 这么简单2.1 什么时候你非要扁平化不可扁平化的需求通常出现在三类场景里我一个个说。第一类是后端接口返回的嵌套数据。比如省市区数据经常是“省下有市、市下有区”的三级嵌套结构但前端的下拉联动组件往往需要一个平铺的选项数组这时候就得把三层结构拍平。第二类是树形控件的节点收集像组织架构树、权限菜单树、目录树做搜索时需要把树上所有节点的可见字段收进一个数组不能漏掉任何一层。第三类是数据处理时的中间步骤比如你拿到了一个二维数组表格数据需要把它全部放进一个一维数组里做去重或统计。如果只是偶尔处理一两次直接用原生flat就够了。但如果你要写一个通用工具函数就不能假设输入永远是一层嵌套也不能假设数组里全是数字或字符串所以有必要把扁平化的原理和边界条件彻底搞清楚。2.2 原生 flat() 的完整使用姿势原生Array.prototype.flat是 ES2019 引入的方法它会递归地把子数组的元素提取出来生成一个新数组。默认只展开一层const arr [1, [2, [3, [4]]]]; console.log(arr.flat()); // [1, 2, [3, [4]]] console.log(arr.flat(2)); // [1, 2, 3, [4]] console.log(arr.flat(Infinity)); // [1, 2, 3, 4]第一个参数是“展开深度”默认是 1。传Infinity可以展开任意深度。这个方法不会修改原数组而且会跳过数组中的空位。这两点我在后面避坑部分会再展开这里先记住结论。flatMap是map和flat的组合拳等价于先map再flat(1)。它的好处是只遍历一次效率更高。比如你有一个单词列表想把每个单词拆成字母再合并成一个大数组const words [hi, ok]; console.log(words.flatMap(word word.split())); // [h, i, o, k]如果直接用map得到的是[[h,i], [o,k]]还得再调一次flat()。顺带提一句flatMap并不等价于“深度任意展开”它只能展开一层。想实现“映射加深层展开”得先flatMap再flat(Infinity)或者直接交给自定义递归函数处理。2.3 手写一个通用扁平化函数从递归到栈原生flat虽然好用但工具函数库很多时候需要自己控制行为比如“只展开叶子节点”“遇到非数组元素直接保留”“避免循环引用导致爆栈”等。这个时候手写一个flatten就很有必要了。我提供两个版本先看最直观的递归版function flattenDeep(arr) { const result []; for (const item of arr) { if (Array.isArray(item)) { result.push(...flattenDeep(item)); } else { result.push(item); } } return result; }这里用了一个很朴素的想法遍历数组的每一项如果某项本身是数组就递归地去拍平它再把结果展开后放进当前结果如果不是数组就直接放进去。逻辑清晰缺点是在嵌套深度很大的极端情况下可能触发调用栈溢出。而且因为用了展开运算符...在层级深且元素多时也会产生较大中间数组。优化版可以采用显式栈完全避免递归调用栈的问题展开过程用push和pop维护待处理列表function flattenIterative(arr) { const stack [...arr]; const result []; while (stack.length) { const next stack.pop(); if (Array.isArray(next)) { stack.push(...next); } else { result.push(next); } } return result.reverse(); }这段代码的关键是遇到数组时先不展开把它内部元素依次压回栈中等非数组元素弹出时直接收进结果。因为栈是后进先出的最后需要reverse()让顺序恢复成从左到右。这个版本理论上可以处理非常深的嵌套只要内存够不依赖调用栈深度。第三个版本可以用reduce实现代码非常短适合面试或源码阅读场景const flattenByReduce arr arr.reduce( (acc, item) acc.concat(Array.isArray(item) ? flattenByReduce(item) : item), [] );其实工作里我会根据入参规模决定用哪个小数据量几十条用递归版阅读性最好可能很大或很深的场景用迭代版安全性更好。你也可以把两者封装成一个带开关的工具函数对外暴露flattenDeep(arr)和flattenShallow(arr, depth)内部按需选择。3. 数据清洗实战当遍历遇上扁平化3.1 树结构扁平化省市区数据是怎么拍平的树结构数据在后台管理系统里几乎天天见最典型的就是省市区。假设你拿到了一份“省→市→区”三层嵌套的对象数组结构类似const regionTree [ { id: 11, name: 北京市, children: [ { id: 1101, name: 市辖区, children: [ { id: 110101, name: 东城区 }, { id: 110102, name: 西城区 } ] } ] }, { id: 33, name: 浙江省, children: [ { id: 3301, name: 杭州市, children: [{ id: 330106, name: 西湖区 }] } ] } ];要把这棵树变成平铺的一维数组同时保留每一层的层级关系或父级信息直接套用前面的flattenDeep是不行的因为我们要处理的不是普通嵌套数组而是“包在数组里的对象对象里又有 children 数组”。这种扁平化需要我们用遍历去改造树形结构自身的形状。最常见的做法是深度优先遍历递归收集节点同时把父节点信息带下去function flattenTree(tree, parentId ) { const result []; for (const node of tree) { const { children, ...rest } node; const flatNode { ...rest, parentId }; result.push(flatNode); if (children children.length) { result.push(...flattenTree(children, node.id)); } } return result; }这段代码的意图是每到一个节点先把当前节点放进去再把它的子孙节点递归地接在后面。这种顺序其实对应了“先父后子”的深度优先排列。对于省市区这种层级固定、数据量不大的场景这个实现已经够用。唯一要注意的是parentId的默认值根节点的父级可以约定成空字符串或null具体看你的业务口径。如果不想用递归避免大树结构下爆栈可以改写成显式栈的方式。我常写一个泛化版本利用数组当作栈倒序遍历入栈function flattenTreeIterative(tree, parentId ) { const stack tree.map(node ({ node, parentId })); const result []; while (stack.length) { const { node, parentId } stack.pop(); const { children, ...rest } node; result.push({ ...rest, parentId }); if (children children.length) { for (let i children.length - 1; i 0; i--) { stack.push({ node: children[i], parentId: node.id }); } } } return result.reverse(); }为什么这里要倒着压栈因为弹出顺序是后进先出倒序压栈可以让下一轮先弹出原始的左边孩子从而得到从左到右的树顺序。写成reverse()也可以但倒序压栈更贴合“一边遍历一边控制顺序”的思路。实际项目中后面这个迭代版本更稳我建议工具库默认收这个。3.2 多级嵌套业务数组表格行转一维再举一个业务里很常见的场景后端返回了一个“数组套数组”的结构比如排班表或 excel 导入后的二维数据。你想把多行多列的数据全部取出来做去重统计或者想判断是否存在某个特定值。紧接前面思路把二维数组扁平化即可const table [ [张三, 李四], [王五, 张三, 赵六], [钱七] ]; // 统计出现次数 const allNames table.flat(Infinity); const countMap allNames.reduce((acc, name) { acc[name] (acc[name] || 0) 1; return acc; }, {}); // { 张三: 2, 李四: 1, 王五: 1, 赵六: 1, 钱七: 1 }这里注意一个细节flat(Infinity)可以直接展开任意层所以即使某一行数据里还嵌套了别的数组也能一次拉平。但它会丢弃原有行边界如果你需要保留“哪一行的数据”这个信息就不能直接扁平化得先用map给每条数据打上行号再flatconst rows table.map((row, rowIndex) row.map(name ({ name, rowIndex })) ); const flatRows rows.flat();这类处理方法的核心原则是先补齐上下文信息再扁平化最后统一处理。这个顺序可以帮助你在不丢失来源信息的前提下完成数据清洗比直接粗暴拍平再回头反查原数组要高效得多。3.3 map 与扁平化的组合拳一条链处理复杂输入实际开发里我特别常用的一个模式是map之后接flatMap或filter。它的核心价值在于先改造数据形态再一次性摊开不需要的多余层级。举个例子后端给了你一个订单列表每个订单里有一个items数组你想把所有订单的商品名聚成一个数组const orders [ { id: 1, items: [{ name: 手机 }, { name: 耳机 }] }, { id: 2, items: [{ name: 充电器 }] } ]; const allItemNames orders.flatMap(order order.items.map(item item.name) ); // [手机, 耳机, 充电器]这个写法比“先map一下 → 再flat一层”更高效因为flatMap只做一次遍历。需要过滤的场景在 flatMap 的回调里返回空数组[]就行空数组拍平后什么都不留等价于过滤掉该项这样又少了一次filter调用。const availableItems orders.flatMap(order order.items .filter(item item.stock 0) .map(item item.name) );这种链式写法的可读性足够高每一段只做一件事取子数组、过滤、映射、摊平。当你看一个工具函数时能顺着链一步步读下来基本不需要注释。4. 高频坑位复盘与性能实测提醒4.1 空值处理null、undefined、空位到底算不算元素数组扁平化遇到最多的坑就是空值。原生flat有一个行为比较特殊它会跳过稀疏数组中的空位但不会跳过null和undefined。我经常看到很多人误以为flat会把空值也清理掉结果数据里残留了一堆null导致后续渲染报错。const sparseArr [1, , 3, null, undefined]; console.log(sparseArr.flat()); // [1, 3, null, undefined]可以看到中间那个空位被跳过了但null和undefined被保留。如果你想在扁平化时顺便清掉空值要么自己过滤要么在扁平化前统一处理const cleaned arr .filter(item item ! null item ! undefined) .flat(Infinity);这里顺序有讲究先过滤再用flat可以避免大小写混乱也更直观。如果不确定输入来源建议在处理工具函数入口处就加一道“空值清洗”不然排查问题时会很难受。有的数据里还可能混有函数、对象、布尔值是否需要保留要根据业务判断如果你写的是泛化工具库建议提供第二个参数shouldRemoveEmpty之类的选项给调用者。4.2 稀疏数组和字符串数组的扁平化差异还有一个容易踩的坑数组的空位sparse hole在普通遍历中表现得很不一致。forEach会跳过空位map会保留空位flat会清空空位。如果你没有意识到这一点很可能在写map后拿到一个带洞的数组再往下游传时出现诡异问题。再一个常见情况是“数组元素不是数字而是字符串”。有人以为flat会把字符串拆成字符其实不会。字符串不是数组所以flat遇到字符串时原样保留。如果你有一个字符串数组想拆成单个字符的大列表不能用flat得用join()或split()配合flatMapconst chars [ab, cd].flatMap(str str.split()); // [a, b, c, d]这里split返回的是数组flatMap再展开一层正好达到目的。这个操作很常用尤其是做搜索关键词分词时比replace加正则更直白。4.3 手写扁平化的注意点循环引用、类型检查、性能取舍手写扁平化工具函数时有两个容易被忽略的问题循环引用检查和性能取舍。先说循环引用。如果你处理的数据来自动态配置、用户上传或某种图结构对象之间可能互相引用。假设数组里有一个对象a它的某个属性又指向a自身递归扁平化时会陷入死循环直到爆栈。防御办法是维护一个WeakSet记录已经访问过的对象function flattenSafe(arr) { const seen new WeakSet(); const result []; const stack [...arr]; while (stack.length) { const item stack.pop(); if (Array.isArray(item)) { stack.push(...item); } else if (item typeof item object) { if (seen.has(item)) continue; seen.add(item); result.push(item); } else { result.push(item); } } return result.reverse(); }这里遇到对象时先查重再放进结果。虽然正常业务数据很少遇到循环引用但工具函数的意义就是“在边界条件下也不出问题”。再说性能取舍。很多文章说“for循环才是最快的”但这句话有前提在百万级数据的纯数值计算场景下for循环确实最快可如果你的“扁平化”是一个递归或栈操作瓶颈可能更多在数组的push和展开操作上而不是循环本身。我的建议是不要提前优化。先写语义清晰的方法等到真在性能面板看到瓶颈再优化。绝大多数管理系统单页数据量不超过几千条flat(Infinity)随便用性能完全没问题。真到了十万级以上的大数据量处理再考虑用迭代版手写函数配合for循环和“预分配数组”的手段去压榨性能。比如用while配合索引替代for...of避免迭代器开销function flattenFast(arr) { const result []; const stack []; for (let i arr.length - 1; i 0; i--) stack.push(arr[i]); while (stack.length) { const item stack.pop(); if (Array.isArray(item)) { for (let i item.length - 1; i 0; i--) { stack.push(item[i]); } } else { result.push(item); } } return result; }这种写法少了几次函数调用和迭代器协议的开销是扁平化场景下比较“性能敏感”的实现。核心思想还是用栈替代递归同时尽量复用变量避免创建过多中间数组。实测在十万级数组、嵌套五层左右的情况下这个版本比递归版快接近一倍也完全没有爆栈风险。4.4 常见问题速查表问题原因解决办法flat()后 null/undefined 还在原生 flat 只跳空位不过滤空值手动 filter 后再 flatflatMap没有展开深层嵌套flatMap 等价于 map flat(1)只能展开一层改成flatMap(...).flat(Infinity)或用自定义递归递归扁平化爆栈 RangeError嵌套过深触发调用栈上限改用显式栈的迭代版本树结构扁平化后丢掉了父级关系只取了节点本身没有携带 parentId遍历时把父级 id 作为参数传入二维数组flat()只展开了一层默认深度为 1二层以上仍在使用flat(Infinity)遍历对象数组时把函数、Date 也当成有效数据存进去了工具函数判断逻辑不严谨增加类型判断明确“哪些类型要保留”处理大数据量时页面卡顿链式调用创建大量中间数组改用命令式循环栈实现减少中间分配4.5 实测下来的一些心得说点真实体会。早期我给团队写工具库时flattenTree用的就是递归版自测了几棵几十层的树都没问题。后来交给运营配置了一棵特别深的栏目树接近上百层每层节点不多页面在部分低端安卓机器上直接卡住甚至崩溃。排查时发现控制台爆了RangeError: Maximum call stack size exceeded这才意识到递归不是不能写而是一定要在工具函数这种“会被不可控数据量调用”的代码里多加一层保险。后来我把所有涉及树的通用函数全部换成了显式栈实现线上就再没因为这个问题炸过。写法上确实比递归麻烦一点但换回来的是稳定性和可预测性。还有一次处理用户导入的 Excel 数据后端把空行也传过来了二维数组里嵌着大量空数组和null我一开始直接flat(Infinity)结果后面统计报表时发现数字对不上。后来定位到是空值没有被过滤掉。那天之后我给自己定了个规矩凡是写数据处理类的工具函数第一行先明确“空值怎么处理”绝不在中间环节默默丢弃或保留。最后再分享一个小技巧写工具函数时尽量把“完全扁平化”和“只扁平一层”两个操作拆成两个函数或者至少用参数区分。因为业务上这两种需求经常交替出现合并成一个函数但参数不清晰别人调用时很容易用错。拆开命名反而更安全比如flattenDeep和flattenOnce。这种小设计不会让你看起来很厉害但会让后续维护的人省很多心。
返回列表