前几天接手一个动态表单配置模块,后端把选项数据做成了三层嵌套的多维数组,前端要遍历每一层去匹配权限字段。我一开始图省事直接写了三层for循环,结果数据源里某个分支突然多嵌套了一层,页面直接白屏。这种坑踩多了,你会发现JS多维数组的遍历看着简单,实际上牵扯到for循环、递归、扁平化三条完全不同的技术路线,选错路线的代价就是后面填坑填到怀疑人生。这篇文章就把JS多维数组遍历这件事彻底聊透——两种主流方法:嵌套for循环和递归遍历,外加一个我常用的扁平化取巧方案,适合所有想把数组操作练扎实的前端开发者。
1. 先搞清楚要遍历的对象长什么样
1.1 从一张Excel表格说起
很多前端日常处理的数据,本质上都是多维数组。拿Excel来说,一个工作表就是典型的二维数组:行是外层索引,列是内层索引。读取某个单元格的值,就是sheet[row][col];要是你有多个工作表,再套一层,就是三维数组了。所以说,多维数组没那么玄乎,它只是"数组的元素还是数组"这个规则不断嵌套的结果。
const excel = [ ['姓名', '部门', '薪资'], ['张三', '技术部', 12000], ['李四', '产品部', 15000], ];这个数组有3行3列,遍历它需要两层循环。第一层循环行,第二层循环列,就能拿到每一个单元格。
1.2 现实世界里的"不规则多维数组"
教科书喜欢画方方正正的矩阵,但现实数据很少这么规整。比如一份全国省市区数据,每个省下面的城市数量不一样,每个城市下面的区县数量也不一样,这就构成了"不规则"的二维或三维数组:
const regions = [ ['浙江省', ['杭州市', '西湖区']], ['浙江省', ['杭州市', '拱墅区']], ['浙江省', ['宁波市', '海曙区']], ['江苏省', ['南京市', '玄武区']], ];注意第二层的长度各不相同。如果你写死内层循环的结束条件,比如for (let j = 0; j < 3; j++),一旦某个区域换成4个区就会漏数据。所以无论用什么方式遍历,都要尊重每个子数组自己的length。这也是后面选择遍历方案时的一个重要判断依据。
1.3 遍历的本质是"访问每一个叶子"
如果说得再直白一点,多维数组的遍历,本质上就是"找到所有不再是数组的普通元素并逐个处理"。这个视角很重要,因为递归遍历的实现思路就是建立在它之上的。你先别管嵌套几层,也不用关心每一层的索引编号,只要沿着数组一层一层往里走,走到不是数组的元素就停下来处理,这就已经把多维数组遍历的核心逻辑抓住了。至于循环嵌套的办法,更像是"我已经提前知道总共有几层,然后用下标把每一层都走一遍"的物理式打法。
2. 方法一:嵌套for循环,直白但需要知道层数
2.1 二维数组的双层循环写法
先看最常见的情况:二维数组。假设现在有一个成绩矩阵,每个元素是[科目, 分数]:
const scores = [ ['语文', 92], ['数学', 97], ['英语', 89], ];双层for循环的写法如下:
for (let i = 0; i < scores.length; i++) { for (let j = 0; j < scores[i].length; j++) { console.log(`scores[${i}][${j}] = ${scores[i][j]}`); } }外层i遍历科目,内层j遍历科目下的具体字段。这里有个细节强调一下:内层循环的结束条件不要写成scores[0].length,而要写scores[i].length,因为每一行的长度可能不同。哪怕你肉眼确认所有行等长,也别写死一个固定数字。这是我见过很多新手写BUG的第一个来源。
2.2 三维数组:再套一层
三维数组的场景一般出现在分组数据里,比如"按年级-按班级-按学生":
const students = [ [ ['张三', 85], ['李四', 90], ], [ ['王五', 78], ['赵六', 88], ], ];三层循环结构非常直白:
for (let i = 0; i < students.length; i++) { for (let j = 0; j < students[i].length; j++) { for (let k = 0; k < students[i][j].length; k++) { console.log(students[i][j][k]); } } }可以看到,每多一个维度,就要多加一层循环。代码结构上的"嵌套感"跟数组本身的"嵌套感"一一对应,这是嵌套for循环最大的优点:可读性极强,任何人看到这段代码都知道你在按维度依次访问数据。
2.3 用forEach替代for的写法
很多前端更习惯用forEach,代码更简洁,而且不容易出现"下标越界"这种问题:
students.forEach((grade) => { grade.forEach((classItem) => { classItem.forEach((student) => { console.log(student); }); }); });但这两种写法有一个关键差异:for循环可以用break提前终止,forEach做不到。假如你想在遍历时找到第一个分数大于90的学生就停下来,用forEach就不好办了,要么抛出异常,要么用一个标志位配合every或some曲线救国。
2.4 嵌套for循环的天然短板
嵌套for循环最大的短板是:维度不固定的时候,代码没法写。后端某天把二维数组升级成了三维数组,你的两层循环就得改写成三层。如果数据源本身既可能是二维也可能是三维,你甚至要写条件分支去判断。更极端的情况是树形结构的JSON,深度未知,比如多级评论、多级菜单。这种数据你用for循环嵌套是写不下去的。这就是为什么我们需要第二种方法——递归。
3. 方法二:递归遍历,让代码去适应任意深度
3.1 递归遍历的核心思想
前面说过,多维数组遍历的本质是"访问每一个叶子"。递归恰好就是为这种场景设计的。它的工作方式是:遍历当前数组,如果遇到一个元素本身还是数组,就调用自己继续往里走;遇到普通元素,就处理它。代码如下:
function traverseArray(arr) { for (let i = 0; i < arr.length; i++) { if (Array.isArray(arr[i])) { traverseArray(arr[i]); } else { console.log(arr[i]); } } }这段代码无论数组嵌套多少层,都能完整走一遍。关键就一句话:Array.isArray(arr[i])决定了你是继续递归还是处理叶子。类型判断在这里非常重要,typeof arr[i] === 'object'是不行的,因为typeof null返回的也是'object',而且普通对象也会被误判成数组。
这种"处理当前节点,再递归进入子节点"的模式,跟二叉树里的前序遍历本质上是同一个套路。你如果写过二叉树的遍历代码,再看多维数组的递归,会发现就是同一套思维换了个容器而已。
3.2 给递归加上深度和路径信息
实际业务里,我们往往不只是想打印出所有值,还想知道每个值在数组里的位置。比如做树形表格的展开收起,就得知道某个叶子节点的完整路径。这时可以给递归函数增加两个参数:当前深度和已走过的路径。
function traverseArray(arr, path = [], depth = 0) { arr.forEach((item, index) => { if (Array.isArray(item)) { traverseArray(item, [...path, index], depth + 1); } else { console.log(`值: ${item}, 深度: ${depth}, 路径: ${[...path, index].join(' -> ')}`); } }); } traverseArray([[1, 2], [3, [4, 5]]]); // 值: 1, 深度: 2, 路径: 0 -> 0 // 值: 2, 深度: 2, 路径: 0 -> 1 // 值: 3, 深度: 2, 路径: 1 -> 0 // 值: 4, 深度: 3, 路径: 1 -> 1 -> 0 // 值: 5, 深度: 3, 路径: 1 -> 1 -> 1提供路径信息的能力,是嵌套for循环很难做到的事情。你用N层循环的时候,虽然也能拼出[i][j][k]这样的索引,但那是因为你已经提前知道了层数。递归不需要知道层数,路径是它在探索过程中自然累积的。
3.3 递归中最容易踩的两个坑
第一个坑是"想用 return 把结果传出来"。新手经常写类似下面的代码,然后发现返回的是 undefined:
function collectLeaves(arr) { const result = []; arr.forEach((item) => { if (Array.isArray(item)) { return collectLeaves(item); // 注意这里,返回值被丢掉了 } else { result.push(item); } }); return result; }这个问题的本质是:内层return只是退出了当前的forEach回调函数,并不会把结果传给外层。正确做法是把子数组的递归结果收集起来再合并:
function collectLeaves(arr) { const result = []; arr.forEach((item) => { if (Array.isArray(item)) { result.push(...collectLeaves(item)); } else { result.push(item); } }); return result; }第二个坑是"想在中途终止递归"。比如想在找到某个目标值后立刻停止。递归虽然也能通过返回值做判断,但写起来远不如for循环的break直观。遇到这种情况,我一般会先问自己:数据规模大不大?如果不大,遍历完也可以接受,代码简单优先;如果数据量很大,那就改用显式栈加迭代的写法,后面会细说。
3.4 递归的性能边界与栈溢出
递归不是银弹,它有一个物理上的限制——调用栈。JS引擎的调用栈深度通常有几万层到十几万层,具体取决于引擎和运行环境。对于业务数据来说,数组嵌套几百层已经极其罕见了,所以栈溢出在日常开发中基本碰不到。但如果你写的是一个工具函数,数据由用户传入,那就得考虑防御。最稳的办法是把递归改写成显式栈的迭代版本:
function traverseIterative(root) { const stack = [root]; while (stack.length > 0) { const current = stack.pop(); if (Array.isArray(current)) { for (let i = current.length - 1; i >= 0; i--) { stack.push(current[i]); } } else { console.log(current); } } }这段代码用while循环加一个栈数组,模拟了递归的"往下钻"行为。它的好处是不消耗调用栈,嵌套十万层也不会爆。代价是代码读起来没那么直观,而且要小心入栈的顺序,因为栈是后进先出,如果你想保持原来的从左到右遍历顺序,需要反着入栈。
如果你需要的是"一层一层横向遍历",也就是算法里常说的层序遍历,那就要用队列替换栈,shift出队、push入队。这和前面的深度优先遍历是两种不同的访问顺序,很多面试题里的"多维数组按层输出"就是在考这个点。
4. flat()扁平化:第三种偷懒方案,但别盲目用
4.1 一行代码把多层数组拍平
ES2019之后,数组有了.flat()方法,它能把嵌套数组展平。默认只拍平一层,传数字表示拍平的层数,传Infinity表示不管多少层都拍平:
const nested = [[1, 2], [3, [4, 5]]]; console.log(nested.flat(Infinity)); // [1, 2, 3, 4, 5]配合forEach就能完成遍历:
nested.flat(Infinity).forEach((item) => { console.log(item); });如果要在扁平化的同时做数据变换,还有flatMap。它相当于先map再flat(1):
const list = [['张三', 85], ['李四', 92]]; list.flatMap(([name, score]) => score > 90 ? [name] : []); // ['李四']4.2 flat的副作用一:丢失维度信息
扁平化最大的副作用是丢失结构信息。拍平之后你只知道5这个值存在,但不知道它在原来的数组里是第二层的第三个,还是在第三层的某个角落。如果你只需要对所有元素做统一的求和、过滤、搜索,flat很合适;如果还要根据维度或者父子关系做逻辑判断,就必须回到递归。
4.3 flat的副作用二:返回新数组,不能指望它改原数据
flat返回的是一个新数组,原数组保持不变。如果需求是"把多维数组中的每个值都乘以2再放回到原位置",扁平化就帮不上忙。这种"就地修改"的场景,递归带上路径信息反而好处理,或者干脆用嵌套循环。
4.4 flat的副作用三:稀疏数组会丢失空位
[1, , 3].flat()会返回[1, 3],空位会被直接过滤掉。这在某些场景下是个隐藏坑。如果你要保留稀疏位置,就别用flat。顺带提醒一下,forEach本身也会跳过稀疏数组的空位,只有普通的for循环会真正访问到那些空位(返回undefined)。这几个细节叠加在一起,会导致同样一份数据,用不同方式遍历出来的结果长度不一样,这一点在数据一致性校验的时候要特别留意。
这四个副作用叠加起来,就是我对flat的态度:它适合"无脑遍历",不适合"带着结构信息去遍历"。用之前先想清楚自己到底要不要保留维度信息。
5. 实战选型与踩坑记录
5.1 场景一:省市区三级联动
很多后台系统里有省市区三级联动。数据源通常长这样,是一个嵌套了层级的数组:
const regionTree = [ { name: '浙江省', children: [ { name: '杭州市', children: [{ name: '西湖区' }, { name: '滨江区' }] }, { name: '宁波市', children: [{ name: '海曙区' }] }, ], }, ];严格说这已经不是"多维数组"而是"树形对象结构",但遍历思路完全一样:遇到对象就进children,遇到叶子就取值。用改造版的递归可以轻松收集所有区县名称:
function collectRegionNames(data, result = []) { for (const node of data) { result.push(node.name); if (Array.isArray(node.children) && node.children.length > 0) { collectRegionNames(node.children, result); } } return result; }这类场景如果用for循环嵌套,最大的问题是层级是3层还是4层完全由后端数据决定,改一次数据结构就要改一次代码。递归则天然免疫。
5.2 场景二:不规则二维数组的求和
再回到纯多维数组的场景。后端返回一个二维数组,表头和数据混在一起,要求把所有数字单元格加起来:
const tableData = [ ['姓名', '一月', '二月'], ['张三', 120, 150], ['李四', 98, 60], ['王五', 200, 50], ]; let sum = 0; for (let i = 0; i < tableData.length; i++) { for (let j = 0; j < tableData[i].length; j++) { const value = tableData[i][j]; if (typeof value === 'number') { sum += value; } } } console.log(sum); // 678这个例子里二维数组的行长度其实是一致的,用两层for循环清晰直接。如果行长度不一致,只要内层循环用tableData[i].length也依然正确。
5.3 场景三:把多维数组拍平后去重
面试题经常出现"把任意深度的数组去重"。用flat + Set三行搞定:
const deepArr = [[1, 2], [2, [3, 4]], [4, [5, [6]]]]; const unique = [...new Set(deepArr.flat(Infinity))]; console.log(unique); // [1, 2, 3, 4, 5, 6]不过面试官通常希望你手写递归版本,因为flat(Infinity)看起来太"作弊"了。我个人建议两种都准备,递归用来讲思路,flat用来展示你对现代API的熟悉度。
5.4 三种方案的选型表格
| 需求特点 | 推荐方案 | 理由 |
|---|---|---|
| 知道固定层数,且需要下标操作 | 嵌套for循环 | 直观、可控、可break |
| 层数不固定或为树形结构 | 递归遍历 | 自适应深度,可携带路径 |
| 只需要遍历所有叶子做统计/过滤 | flat + forEach | 代码最少 |
| 要保留维度信息做逻辑判断 | 递归带路径 | 信息最完整 |
| 数据极深且需要中途终止 | 栈迭代 | 不爆栈,可终止 |
5.5 我踩过的两个真实坑
第一个坑在forEach和break上。有一次我要遍历二维数组判断是否存在某个配置项,存在就立刻跳出。用双重forEach写完后发现没法break,只能用一个布尔标志位在回调里判断,但外层forEach已经停不下来了,还会继续把后续行也遍历完。改成双重for循环后,一行break就解决了。所以遍历方法的选择,一定要先想清楚"我要不要提前终止"。
第二个坑在递归的返回值上。我第一次写多维数组求和时,return的位置放错了,导致嵌套子数组的结果没加回来,每次遇到数组就直接返回一个局部数组。后来我养成了一个习惯:递归函数里,凡是"子问题"的返回值,一律要么传给参数、要么合并进结果数组,绝不在原地return。理解这句话之后,递归相关的BUG少了一大半。
我现在处理多维数组遍历,基本流程是固定的:先问自己三个问题——层数固定吗?需要维度信息吗?会不会提前终止?根据答案去选嵌套循环、递归还是扁平化。多数场景下递归是我最先考虑的,因为业务数据深度不可控的概率太高了,写一次递归可以应对后续所有数据结构变化。如果你也在为树形菜单、多级联动或者矩阵数据处理发愁,顺着这篇文章把两种方法的边界想清楚,再动手写代码,会少踩很多我之前踩过的坑。