☰
JS多维数组遍历全解:嵌套循环、递归与flat()技巧
2026/10/2 8:54:08 网站建设 项目流程

先别急着去翻文档,今天咱们来把“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...of:

for (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用递归方案替代或引入polyfill

5.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组合拳都会轻松很多。

最后再补一句我个人的心得体会:写遍历代码时,永远先问自己一句“这层结构是稳定的吗”,如果答案是否定的,那就放弃嵌套循环,直接用递归。真正的高手不是能写多复杂的遍历,而是能一眼看出这个数据到底该用什么方式去摸清它的底细。希望这篇文章能帮你少走几步弯路,踩过的坑我们下次再聊。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询