2026/10/2 8:54:16

JS多维数组遍历全解:嵌套循环、递归与flat()技巧

JS多维数组遍历全解:嵌套循环、递归与flat()技巧 先别急着去翻文档今天咱们来把“JS多维数组遍历”这件事彻底聊透。做过几年前端或者Node开发的朋友应该都有体会一维数组用map、filter、forEach都很顺手但一旦碰上二维、三维甚至维度不固定的数组就很容易被绕晕。标题里提到的“两种方法”本质上是两条完全不同的思路一条是老老实实的嵌套循环一条是写一个能“自我调用”的递归函数。前者适合维度固定、简单的场景后者才是真正一劳永逸的解决方案。这篇文章我会从多维数组的本质开始拆解把两种方法的原理、代码、边界情况和性能表现都讲清楚最后再送你一个我用flat()偷懒的小技巧。不管你是刚接触JS的新手还是已经写过一阵子但总在遍历上卡壳的开发者这篇都值得你看到底。1. 多维数组遍历为什么会成为问题1.1 一维数组的思路为什么不能直接套用先把最简单的场景摆出来。假设我们有一个一维数组const arr [1, 2, 3, 4]要遍历它你脑子里第一反应大概率是for循环、for...of或者forEach这些都是非常成熟的方案。但问题在于多维数组的结构不是一根线而是一棵树。比如二维数组const grid [[1, 2, 3], [4, 5, 6], [7, 8, 9]]它实际上是一个“数组的数组”外层数组的每一个元素本身又指向了另一个数组。你直接对grid做for...of拿到的不是数字而是三个数组对象[1, 2, 3]、[4, 5, 6]、[7, 8, 9]。这种“数组套数组”的结构在日常开发里一点都不罕见。处理表格数据时Excel导入后往往是二维数组做图像处理时像素矩阵是三维数组处理地理坐标时多边形的顶点可能是不定深度的嵌套数组游戏里的地图格子直接用二维数组表示最方便。一旦数据变成这种结构你的一维遍历工具就全部失效了因为你的目标不是“遍历第一层”而是“遍历到最后一层、拿到每一个具体的值”。这也是为什么多维数组遍历会成为一个值得单独拿出来讲的话题。1.2 多维数组的“形状”和“深度”是两回事要理解遍历方案先得把两个概念区分清楚数组的形状shape和数组的深度depth。形状是指每一层有多少个元素比如一个3×4的二维数组shape就是[3, 4]。深度是指嵌套的层数一维数组深度是1二维数组深度是2三维数组深度是3。遍历的时候你真正关心的是深度因为你必须深入到最里层才能拿到“叶子节点”。但这里有个非常现实的坑JS的数组根本不承诺“形状规整”。你声明一个二维数组完全可能第一行有3个元素第二行只有2个元素甚至某一个元素干脆就不是数组而是数字。这在其他语言里可能是异常但在JS里极其常见尤其是后端返回的JSON数据字段缺失、结构不一都是常态。所以设计遍历逻辑的第一步不是假设数组长什么样而是先判断“当前这个元素到底是不是数组”。这个判断会成为整个遍历方案的核心支点后面的两种方法本质上都是围绕它展开的。2. 方法一嵌套循环遍历最直观但也最容易写错2.1 二维数组的标准双层for循环写法嵌套循环的思路很好理解既然数组嵌套了多少层就用多少个for循环去逐层剥开。以二维数组为例标准写法长这样const grid [ [1, 2, 3], [4, 5, 6], [7, 8, 9] ]; for (let i 0; i grid.length; i) { for (let j 0; j grid[i].length; j) { console.log(grid[i][j]); } }外层循环遍历每一行内层循环遍历当前行里的每一列。这是教科书级别的写法也是初学者最容易理解的方式。但我在实际调试中见过很多次类似的错误有人在内层循环里写成了j grid.length导致当某一行比总行数短的时候访问grid[i][j]直接得到undefined还有人写成了j grid[i].length但外层循环变量写错导致数组越界。这类错误不会直接报错因为你访问不存在的下标只会得到undefined程序还能继续跑但结果就全错了。如果三维数组就得写三层循环四维就四层。这个方法最大的问题在于代码的层数是写死的。今天你写了一个专门处理二维数组的函数明天数据结构升维了你就得改代码加循环。这也正是嵌套循环的边界所在它能用但只适合维度明确且不会再变的场景。2.2 看起来更简洁的for...of和forEach也有自己的脾气同样是二维数组遍历很多人会为了少写几个字而改用for...offor (const row of grid) { for (const item of row) { console.log(item); } }这段代码的优点是清晰row和item的语义直观不再需要费心去记i和j各自代表什么。但它有一个隐含限制for...of依赖数组的迭代器而迭代器遍历的是“当前存在的元素”。如果你在遍历过程中往数组里新增元素for...of不会处理新增部分但这通常不是你想讨论的重点。真正要注意的是for...of无法在遍历时方便地拿到当前元素的索引如果你需要根据行列坐标做判断还得额外维护一个计数器这就有点得不偿失了。至于forEach它的问题更隐蔽。forEach会对稀疏数组“跳过空洞”也就是数组里某个位置没有赋值、是空位empty时forEach的回调根本不会执行但for循环会把它当作undefined来访问。这种细微差别在多层遍历中会被放大一旦数据源有缺项你很难排查为什么漏了某个元素。所以我的建议是追求代码简洁可以用for...of但如果你需要精确控制每一步、需要索引、或者需要处理不规则数据传统for循环才是最稳的。3. 方法二递归遍历一劳永逸的通用方案3.1 递归思想把一个“大问题”拆成“小问题”嵌套循环的痛点在于必须预先知道数组的深度。而递归的思路恰恰相反我不需要知道一共有多少层只需要约定一个规则——遇到数组就进入下一层遇到非数组就处理它。用一句话概括就是遍历一个多维数组等价于“对每一项做一个操作如果它是数组就继续遍历它否则就输出它”。这个思想在代码里长这样function traverseArray(arr, callback) { for (const item of arr) { if (Array.isArray(item)) { traverseArray(item, callback); } else { callback(item); } } } // 使用示例 const data [1, [2, [3, [4, 5]], 6], 7]; traverseArray(data, value console.log(value)); // 输出1 2 3 4 5 6 7函数traverseArray在遇到子数组时调用自身这就是“递归”两个字的含义。你看它处理四维、五维数组的代码长度和处理二维数组完全一样因为每次递归都只是把同样的规则应用到下一层。这是嵌套循环做不到的也是我说它是一种“一劳永逸”方案的原因。理解递归的关键在于“信任”你不需要在头脑里模拟出完整的调用栈你只需要相信两点——第一当前这层处理逻辑是正确的第二当递归调用发生时它会用同样的正确逻辑去处理更小的子问题。很多人第一次写递归会担心“会不会死循环”这就要求你必须保证递归有一个明确的出口。在这个例子里出口就是Array.isArray(item)为false即元素不是数组时回调函数执行完就直接结束不再递归下去。3.2 递归版本还可以更优雅reduce写法如果你已经跨过了“看得懂递归”的门槛可以试试更函数式的写法。用reduce把遍历和结果收集合并起来代码会短上一截function flattenRecursive(arr) { return arr.reduce((acc, item) { return acc.concat(Array.isArray(item) ? flattenRecursive(item) : item); }, []); } const data [1, [2, [3, [4, 5]], 6], 7]; console.log(flattenRecursive(data)); // 输出[1, 2, 3, 4, 5, 6, 7]reduce的妙处在于它的回调天然就是“把上一次的结果和当前值合并起来”。遇到数组就递归遇到数字就直接拼接这样下来最终得到的是一个完全扁平化的一维数组。如果你需要的是“把所有数取出来处理”这个写法比上面的forEach版本更直接。但要注意把多维数组完全扁平化有时并不是你想要的效果。如果你还需要保留层级信息比如统计每一层有几个元素那么reduce这种“直接摊平”的思路就不合适了。建议根据需求选择只关心最终值用reduce还需要层级结构用带深度参数的递归。这里补充一个我常用的进阶写法给递归函数加一个depth参数这样你可以知道当前元素在第几层适合处理“只遍历到某一层就停止”的需求function traverseWithDepth(arr, callback, depth 0) { for (const item of arr) { if (Array.isArray(item)) { traverseWithDepth(item, callback, depth 1); } else { callback(item, depth); } } }3.3 递归的两个性能隐患递归并不是万金油它有两个隐藏成本。第一是函数调用开销每进入一层数组就会产生一次函数调用层级非常深时比如几千层调用栈可能直接溢出报Maximum call stack size exceeded。要知道JS引擎的调用栈是有上限的并非无限。第二个隐患是代码的可读性对新手不友好如果团队里有同事不熟悉递归这种代码很容易成为日后的维护负担。在一些特殊场景你可以把递归改写成显式栈循环。本质上就是把“系统调用栈”换成你自己维护的一个数组来存放待处理的项function traverseIterative(arr, callback) { const stack [...arr]; while (stack.length) { const current stack.shift(); if (Array.isArray(current)) { stack.unshift(...current); } else { callback(current); } } }这段代码的思路是从待处理列表里取出一项如果它是数组就把它的内部元素依次放回列表头部继续循环如果不是数组就交给回调。这个方式是迭代而非递归所以不用担心调用栈溢出但要注意shift和unshift在大数组上的性能损耗因为每次操作都可能引起元素位置变动。如果用pop和push替代性能会好一些但输出顺序会和递归版本相反这点也要留意。4. 进阶偷懒方案Array.flat()到底能不能用4.1 一行代码扁平化多维数组聊完了两种正统方法我必须提一嘴flat()这个“作弊器”。不知道你有没有遇到过这种场景手里数据是[[1, 2], [3, [4, 5]]]但你的需求根本不需要关心层级只要把所有数字拿来求和就行。这种时候最简单的方法不是写循环也不是写递归而是const arr [[1, 2], [3, [4, 5]]]; const flatArr arr.flat(Infinity); console.log(flatArr); // 输出[1, 2, 3, 4, 5]flat(Infinity)的意思是不管嵌套多深全部摊平。Infinity在这里就是一个“无限深度”的约定值表达意图非常清楚。对于大多数只关心最终数据的场景这一行代码完全够用了根本不需要自己手写递归。但这里有两个容易踩的坑。第一flat()是ES2019才引入的方法太老的环境比如某些旧版本移动端浏览器内核可能不支持如果你要兼容老旧环境手写递归更保险。第二也是最关键的flat(Infinity)会把所有层级彻底抹平这意味着你丢失了整个结构信息。如果你后期还需要把数据恢复成原始多级结构那就不能用这个方法。4.2 当你有定制需求时flat帮不上忙举个具体的例子。假设你有一个三维数组需求是找出一共有多少个“奇数”元素你要做的不只是遍历还要在遍历过程中做条件判断。你当然可以flat(Infinity)之后再用filter但那就意味着你要创建两个新数组一个存储全部扁平化数据一个存储过滤结果。如果原数组非常大这个内存开销是可观的。而用递归遍历的方法一次循环就能同时完成“判断计数”不需要任何额外的中间数组let oddCount 0; traverseArray(data, value { if (value % 2 ! 0) oddCount; }); console.log(oddCount);再比如你想把多维数组里每一项的值都翻倍同时还要保持原有的数组层级结构。用flat就完全不行了——它只会给你一个一维数组。这种时候要么用嵌套循环按原始维度重建要么用递归构建一个同样形状的新数组function deepMap(arr, fn) { return arr.map(item Array.isArray(item) ? deepMap(item, fn) : fn(item)); }deepMap的定义特别短但功能很强大它遍历原数组遇到数字就应用函数遇到子数组就递归进入并保持结构。这个函数才是我日常工作中真正用得最多的“多维数组工具”。所以我的建议是flat()适合一次性数据处理递归遍历适合需要保留结构或深度控制的场景。5. 常见问题与实战排查记录5.1 陷阱一Array.isArray和typeof的判断误区一个非常容易犯的错误是拿typeof去判断数组。typeof []的结果是object而不是array这是JS的一大历史遗留。如果你用typeof item object来当递归的判定条件那么null也会被判定为object因为它typeof null object这就会导致你的递归逻辑把null当作数组继续“深入”结果要么报错、要么出现无法解释的遍历结果。判断数组请统一使用Array.isArray()这个方法是ES5就有的对所有环境都友好。它唯一的例外是某些跨iframe环境下的数组instanceof Array可能会失灵但Array.isArray不受影响这也是我推荐它的原因。5.2 陷阱二稀疏数组导致forEach漏元素前面提到过forEach会跳过空洞这里展开讲一下。稀疏数组指的是类似const arr [1, , 3]这样的东西中间那个位置没有值。它不会直接报错但forEach、map、filter这些方法都会自动跳过空洞而for循环不会。这意味着如果你用forEach去遍历一个存在空洞的多维数组明明8个元素你的回调可能只执行了7次而且看不出任何报错信息。这种问题非常难排查。顺带一提flat()对空洞的处理是“抹平为undefined”所以用它的结果和手写递归的结果也有细微差别。最稳妥的做法是拿不准数据里有没有空洞时统一用for...of或者传统for循环它们的语义是“访问每一个索引位置”空洞和undefined会原样暴露出来不会隐形跳坑。5.3 陷阱三遍历过程中修改原数组导致死循环在for...of遍历数组时如果在回调里对同一个数组进行元素新增操作会出现一个隐患迭代器会动态感知数组长度的变化可能导致遍历次数变多甚至陷入无限循环。举个极端例子const arr [1, 2, 3]; for (const item of arr) { if (item 2) { arr.push(4); } console.log(item); }这段代码会输出1、2、3、4因为push之后数组长度变了迭代器把新元素也访问了一遍。在多维数组递归遍历里如果回调里对某个子数组做了push可能会触发规划外的递归分支导致逻辑错乱。解决方案也很简单需要修改数组时先拷贝一份再遍历或者先收集要修改的索引遍历结束后统一处理。我自己的习惯是遍历逻辑里绝对不做“边遍历边增删”的操作这是减少诡异Bug的一个铁律。5.4 问题速查表现象可能原因解决方案遍历结果总是比预期少几个元素forEach遇数组空洞自动跳过了改用for...of或普通for循环判断条件typeof item object时出现异常null也被判定为object用Array.isArray()作为数组判断递归深度太深时报栈溢出数组嵌套超过引擎调用栈限制改成迭代显式栈的实现遍历时改了原数组导致死循环迭代器感知到长度变化先拷贝再遍历或先记录后修改某行元素打印出undefined内层循环长度写错越界访问内层循环条件用grid[i].length老环境不支持flat()浏览器实现了ES2019以后才有的API用递归方案替代或引入polyfill5.5 实际案例处理一个不规则的成绩单数据之前处理过一个成绩单需求后端返回的数据结构长这样const report [ { name: 张三, scores: [[85, 92], [78, 88]] }, { name: 李四, scores: [[90, 87]] }, { name: 王五, scores: [[76], [88, 99, 100]] } ];每个学生有两学期成绩每学期又分若干科目科目数量不固定。我需要计算每个学生的所有科目总分。如果用嵌套循环得先知道每一层的含义但科目数不固定循环层数也不好写死。换成递归遍历一切迎刃而解function collectNumbers(arr) { let sum 0; if (Array.isArray(arr)) { for (const item of arr) { sum collectNumbers(item); } } else if (typeof arr number) { sum arr; } return sum; } report.forEach(student { const total collectNumbers(student.scores); console.log(${student.name} 的总分是 ${total}); });这个案例很好地说明了多维数组遍历的难点不在于“循环怎么写”而在于“数据结构的不可预测性”。递归的意义就是让你从“预测结构”中解放出来。6. 工具选型建议到底该用哪一种遍历方法写到这里估计你要问那以后我到底该用哪种这事其实没有标准答案完全取决于你的具体场景。我通常按下面几个标准来判断数组维度固定、结构规整比如矩阵运算、表格渲染优先用嵌套循环。代码简单容易调试性能也最优。数组维度不确定、或者会变比如后端下发的树形配置数据、文件目录结构必须用递归或显式栈迭代。只是想快速拿到所有非数组元素不在乎保留层级用flat(Infinity)最简单。需要遍历过程中修改结构、保留层级、执行复杂业务逻辑用递归遍历或deepMap这种定制函数。从性能上看常规业务数据量下几千到几万个元素三种方法差别肉眼根本感觉不出来。只有当数据量达到十万、百万级别时循环嵌套的性能优势才会体现。而递归的栈溢出风险是你真正需要提前评估的尤其在处理深层嵌套数据之前先确认一下数据的最大深度避免上线后在用户浏览器里崩溃。在团队协作场景里我会额外考虑代码的可读性。如果维护代码的人不一定熟悉递归我宁可多写几个嵌套循环并配上详细注释也不留一个让同事看三遍都看不懂的魔法函数。这个判断标准说出来有点“不讲武德”但在工程实践里减少认知负担比炫技重要得多。7. 动手练一练从简单到硬核的三个练习只看不练代码能力永远上不来。这里给你留三个循序渐进的小练习建议自己写完后再对照上面的思路看看有没有不同解法第一个练习给你一个const matrix [[1, 2], [3, 4], [5, 6]]请用至少两种方式把它遍历出来分别打印每一个数字和它的行列索引。第二个练习写一个函数countLeaves(arr)输入是一个任意深度的嵌套数组里面可能有数字、字符串、布尔值返回“叶子节点”的总数量。比如countLeaves([1, [2, [3, 4]], a, true])应该返回5。这个练习考察的是对递归出口的判断。第三个练习是硬核一点的实现一个flattenByDepth(arr, depth)函数它能按指定深度“部分展平”数组。比如对[1, [2, [3, [4]]]]调用flattenByDepth(arr, 2)得到的是[1, 2, 3, [4]]。这个需求在实际工程项目里经常遇到——只摊平到你需要的层级保留更深层结构。实现它需要你对循环和递归都有不错的掌控。三道题做完你对多维数组的掌控力会有质的提升。这不是我夸张遍历是所有数据处理的基础而多维数组遍历又是其中最容易绕晕的一环搞定了它后面学map、reduce、flatMap组合拳都会轻松很多。最后再补一句我个人的心得体会写遍历代码时永远先问自己一句“这层结构是稳定的吗”如果答案是否定的那就放弃嵌套循环直接用递归。真正的高手不是能写多复杂的遍历而是能一眼看出这个数据到底该用什么方式去摸清它的底细。希望这篇文章能帮你少走几步弯路踩过的坑我们下次再聊。