news 2026/10/7 4:42:45

JS多维数组遍历全解析:for循环、递归与flat()选型指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JS多维数组遍历全解析:for循环、递归与flat()选型指南

前几天接手一个动态表单配置模块,后端把选项数据做成了三层嵌套的多维数组,前端要遍历每一层去匹配权限字段。我一开始图省事直接写了三层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少了一大半。

我现在处理多维数组遍历,基本流程是固定的:先问自己三个问题——层数固定吗?需要维度信息吗?会不会提前终止?根据答案去选嵌套循环、递归还是扁平化。多数场景下递归是我最先考虑的,因为业务数据深度不可控的概率太高了,写一次递归可以应对后续所有数据结构变化。如果你也在为树形菜单、多级联动或者矩阵数据处理发愁,顺着这篇文章把两种方法的边界想清楚,再动手写代码,会少踩很多我之前踩过的坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 4:42:11

认识Linux操作系统:从内核到发行版,零基础入门与实战指南

我已经完全理解这个任务了。将严格按照要求生成一篇围绕《第一章 认识Linux操作系统》的、可直接发布的高质量技术博文。绝不包含任何前置说明或后置元信息&#xff0c;严格遵守标题编号、字数、安全规范和语言风格要求。Linux这个东西&#xff0c;你肯定没少听说过。服务器、嵌…

作者头像 李华
网站建设 2026/10/7 4:42:10

Python返回随机数全链路:random、numpy.random与secrets的选型与实战

写Python这些年&#xff0c;我几乎每天都会跟"python返回随机数"这件事打交道。做数据分析要抽样、写爬虫要随机延时、搞量化要模拟行情、开发小工具要生成验证码——随机数几乎是无处不在的。很多人以为随机数就是import random之后调个random.random()&#xff0c;…

作者头像 李华
网站建设 2026/10/7 4:41:19

macOS本地AI助手替代方案:Jev-like轻量模型部署指南

1. Jev 是什么&#xff1f;它在 macOS 生态里到底解决了哪类真实问题&#xff1f;先说结论&#xff1a;Jev 并不是一个广为人知的、有官方文档或 GitHub 主页的主流开源模型项目。从全网公开信息来看&#xff0c;它既未出现在 Hugging Face Model Hub 的主流榜单中&#xff0c;…

作者头像 李华
网站建设 2026/10/7 4:40:43

Godot移植鸿蒙PC:底层兼容性与图形栈适配深度解析

1. 为什么“Godot 移植鸿蒙 PC”不是个简单打包问题最近在几个开源游戏开发群和鸿蒙开发者社区里&#xff0c;频繁看到类似提问&#xff1a;“Godot 能不能直接跑在鸿蒙 PC 上&#xff1f;”“有没有现成的鸿蒙版 Godot 下载&#xff1f;”——语气里带着期待&#xff0c;也藏着…

作者头像 李华
网站建设 2026/10/7 4:38:40

BMS电桥法绝缘电阻检测原理与工程实践

1. 项目概述&#xff1a;为什么BMS绝缘电阻检测不能“差不多就行”在电池 pack 装车前做一次绝缘测试&#xff0c;用万用表测下正负极对壳体的电阻——这种操作我见过太多次了。去年帮一家电动叉车厂做BMS验收&#xff0c;他们产线工程师拿着DT-9205A万用表&#xff0c;红表笔接…

作者头像 李华