在后端接口对接或配置文件解析时,我们经常会遇到结构不固定的多维数组,外层可能包裹着若干层子数组,且深度完全不可预知。要完整提取其中的每一个标量值或对象,仅靠单层循环无法覆盖所有分支,必须借助函数自身调用自身的方式逐层下探。

一、递归遍历的核心原理
递归遍历的本质是“分而治之”。当程序遇到一个数组元素时,先判断它是否还是数组类型;如果是,就把它交给同一个遍历函数再次处理;如果不是,说明已经抵达最底层,可以直接读取或处理该值。这种自我调用的过程会在内存的调用栈中不断压入新的函数帧,直到所有分支都走到叶子节点后依次返回。
理解调用栈很关键。每一次递归调用都会保留当前层的索引与上下文,因此天然记录了“从根到当前节点”的路径。比起先把数组强行拉平再处理,递归能让你在回调中轻松拿到层级深度与祖先信息,对于生成树形菜单、计算节点权重等需求非常友好。当然,如果层级深到突破引擎限制(例如PHP默认数组递归过深会报段错误,JavaScript在万层以上可能爆栈),就需要改用后面提到的显式栈方案。
1.1 判断类型的细微差别
不同语言对“数组”的定义并不一致。在JavaScript里,数组是Array的实例,但后端常把对象也当关联数组用;在PHP中既有索引数组也有关联数组,用is_array就能统一识别。写递归前必须先明确:什么样的容器需要继续往下走,什么样的终点值需要被收集。
以JavaScript为例,如果用typeof判断,null也会返回object,造成误判。更稳妥的做法是Array.isArray()或者借助Object.prototype.toString.call()。只有在确认是数组(或可迭代集合)时才递归,否则执行叶子逻辑,这样才能避免死循环或类型错误。
二、JavaScript中的递归实现
下面是一段通用的JS递归遍历函数,它不关心数组有多少层,只要发现子元素是数组就继续深入,同时把当前路径传给回调,方便业务侧知道这个值处在哪一枝节点上。
function traverseArray(data, callback, path = []) {
if (Array.isArray(data)) {
data.forEach(function (item, index) {
var currentPath = path.concat(index);
traverseArray(item, callback, currentPath);
});
} else {
callback(data, path);
}
}
var nested = [1, [2, [3, 4]], [[5], 6]];
traverseArray(nested, function (value, path) {
console.log('值:' + value + ' 路径:' + path.join('->'));
});
上面的代码把每一层的索引拼成路径数组,在叶子节点触发callback。运行后会依次输出1、2、3、4、5、6以及它们各自的索引轨迹。由于函数内部始终用Array.isArray做守卫,即使数据里混着字符串、数字或null也不会中断。
如果你希望在遍历时顺带修改原数组,只需在递归前对data[index]重新赋值即可。但要注意,递归修改共享引用会带来副作用,大型项目中更推荐返回新结构而非直接改动入参,这样便于调试和做不可变数据管理。
2.1 使用生成器优化内存
当数组极其庞大且只需逐个消费时,可以用Generator把递归改成惰性产出,避免一次性构建结果集占用大量内存。
function* flattenGen(arr) {
for (var i = 0; i < arr.length; i++) {
if (Array.isArray(arr[i])) {
yield* flattenGen(arr[i]);
} else {
yield arr[i];
}
}
}
var big = [1, [2, [3, [4, 5]]]];
for (var v of flattenGen(big)) {
console.log(v);
}
生成器在遇到yield时暂停,调用方每次取一个值,特别适合流式处理日志或逐行写文件的场景。它仍然依赖调用栈保存状态,但不会因为收集全部结果而撑大堆内存。
三、PHP中的递归遍历示例
PHP没有Generator也能轻松写递归,且is_array可以识别一切数组形态。下面示例演示如何打印任意嵌套数组的所有标量,并标注深度。
function walk_deep($data, $depth = 0) {
if (is_array($data)) {
foreach ($data as $key => $val) {
walk_deep($val, $depth + 1);
}
} else {
echo str_repeat('--', $depth) . $data . PHP_EOL;
}
}
$list = array(1, array(2, array(3, 4)), array(array(5), 6));
walk_deep($list);
这段代码利用$depth参数跟踪层级,遇到数组就加一层并继续,遇到字符串或数字就按缩进输出。因为PHP的数组本身可以嵌套任意深度,只要服务器内存和栈空间足够,函数就能一直深入。
实际业务里,我们常把递归封装成类方法,在叶子节点调用用户传入的闭包,从而把“遍历”和“处理”解耦。例如电商类目树、省市区联动数据都可用同一套walk逻辑,仅替换callback就能实现搜索、统计或格式化导出。
3.1 显式栈避免爆栈
如果怀疑数据层级会超过语言栈上限,可以用数组模拟调用栈,把“待处理节点”压入队列,循环推出,这样深度只受内存限制。
function iter_deep($data) {
$stack = array(array('val' => $data, 'depth' => 0));
while (!empty($stack)) {
$cur = array_pop($stack);
if (is_array($cur['val'])) {
foreach ($cur['val'] as $v) {
$stack[] = array('val' => $v, 'depth' => $cur['depth'] + 1);
}
} else {
echo str_repeat('--', $cur['depth']) . $cur['val'] . PHP_EOL;
}
}
}
iter_deep($list);
显式栈写法虽然代码稍长,但彻底摆脱了函数递归的层次束缚,在处理未知来源的文件解析结果时更稳健。它的思路与浏览器渲染树的先序遍历一致,先处理当前再展开子级。
四、常见误区与注意点
很多人在写递归时会忘记设置终止条件,或者把“判断是否为数组”写错,导致函数反复调用自身进入死循环。务必保证每一次递归调用都让数据规模变小,或层级变深直至触达非数组类型。
另一个坑是循环引用:如果数组里某个子元素间接指向了祖先,纯递归会无限转圈。可在遍历时维护一个已访问对象的WeakMap或spl_object_id集合,遇到重复引用直接跳过。此外,递归虽优雅,但在浅层且结构固定的数据上,展开成循环往往更快,不必为了用递归而递归。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 函数递归 | 代码简短,路径天然保留 | 受调用栈深度限制 |
| 显式栈循环 | 支持极深结构,不易爆栈 | 需手动管理状态,稍繁琐 |
| 生成器递归 | 惰性产出,省内存 | 语言需支持Generator |
综上所述,面对无限层级的多维数组,优先用语言内置的数组判断加自我调用实现递归;当深度或内存成为瓶颈时,再切换到显式栈或生成器。理清“什么是容器、什么是叶子”这一边界,就能写出既安全又易维护的遍历逻辑。