news 2026/10/3 4:21:16

如何在 PHP 中正确遍历 JSON 对象中的嵌套数组并提取字段值

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
如何在 PHP 中正确遍历 JSON 对象中的嵌套数组并提取字段值

前言

接口返回的 JSON 里,{"data": {"list": [{"items": [{"name": "..."}]}]}}这种套娃结构随处可见。很多人第一次处理它时写的代码是$row['data']['list'][0]['items'][2]['name'],本地小样例跑通了,一上真实数据就开始出三种症状:Warning: Undefined array key "items"、整体取到null却不知道是哪一层断的、以及「明明 JSON 里有这条数据,代码就是取不到」。

根因有三个。第一,JSON 只有 object 和 array 两种复合结构,但到了 PHP 侧会变成stdClass、关联数组、索引数组三种形态,json_decode给你哪一种,完全取决于第二个参数。第二,嵌套层级越深,「某一层是null、某一层是空数组、某一层缺字段」这三种情况的处理就越难统一。第三,json_decode解析失败时是静默返回null的,不主动检查就完全看不出来。

本文用一个完整可运行的程序,讲清三件事:两种解码形态各自的适用场景、任意深度字段提取的正确写法(含点路径与通配符)、以及遍历过程中如何保留「当前在哪一层」这个信息。示例代码需要PHP 8.1 及以上,其中提到的json_validate()需要PHP 8.3。

一、json_decode的两种形态,先选对再谈遍历

json_decode($json)不给第二个参数,JSON object 解成stdClass,JSON array 解成 PHP 数组;json_decode($json, true)则把 object 也解成关联数组。看一组对照:

JSON 原文json_decode($s)json_decode($s, true)
[1,2,3]array(0=>1,1=>2,2=>3)同左
{"a":1}stdClass{ a: 1 }['a' => 1]
{}空stdClass[](注意!空数组)
{"0":"x"}stdClass{ "0": "x" }['0' => 'x']

两条规律:JSON 数组永远是 PHP 数组,JSON 对象才受参数影响;而{}转成关联数组后变成[],json_encode回去时又会变回[]而不是{}—— 这是个会破坏 API 契约的经典陷阱,后面坑点里细说。

那到底选哪种?结论很实用:


  • 只读、只做提取:用json_decode($s, true)。后面全是数组函数,??、array_column、array_keys全部可用,写起来最少。

  • 需要区分「空对象」和「空数组」:必须用对象形态(不传true),或者用json_decode($s, false),借助stdClass与array的类型差异来判断。

  • 需要重新编码回 JSON 且要保持类型:同上,用对象形态。


二、任意深度取值:点路径 + 通配符

直接写$a['b']['c']['d']的问题是:只要中间任何一层不存在,就会警告并得到null,而且你无法区分「字段缺失」和「字段值就是 null」。更糟的是,一旦要取的字段不是固定的,比如配置化地从data.list.*.items.*.name这样的路径取,硬编码就更不行了。

解决办法是实现一个按路径取值的小工具函数,写法参照 Laravel 的data_get思路:路径用.分隔,*表示遍历当前层的所有元素。

<?php declare(strict_types=1); // 需要 PHP 8.1+ /** * 按点路径取值,支持 * 通配符。 * * dataGet($data, 'data.list.0.name') // 取第一个元素的 name * dataGet($data, 'data.list.*.id') // 取所有元素的 id,返回数组 * dataGet($data, 'a.b.c', 'fallback') // 路径不存在时返回默认值 */ function dataGet(mixed $target, string $path, mixed $default = null): mixed { $segments = $path === '' ? [] : explode('.', $path); foreach ($segments as $segment) { if ($segment === '*') { // 通配符:当前层必须是数组,对每个元素递归取剩余路径 if (!is_array($target)) { return $default; } $rest = implode('.', array_slice($segments, array_search($segment, $segments, true) + 1)); $out = []; foreach ($target as $item) { $out[] = $rest === '' ? $item : dataGet($item, $rest, $default); } return $out; } if (is_array($target) && array_key_exists($segment, $target)) { $target = $target[$segment]; continue; } if ($target instanceof ArrayAccess && $target->offsetExists($segment)) { $target = $target[$segment]; continue; } return $default; } return $target; }

关键点在于用array_key_exists()而不是isset():当字段存在但值明确是null时,isset()会返回false,于是「字段缺失」和「字段是 null」被混为一谈。如果业务上这两者含义不同(比如null表示「用户主动清空」),那这个区别就是致命的。

三、遍历时如何知道「现在在哪一层」

array_walk_recursive()看起来是遍历嵌套数组的省事办法,但它只把叶子节点回调给你,你拿不到当前路径,也拿不到父级。一旦需要「把路径和值一起收集起来」,它就直接不可用了。

自己写一个递归遍历,把路径显式传下去,是最清楚的做法:

<?php declare(strict_types=1); /** * 递归收集所有标量叶子节点的路径与值。 * * @param array<string, mixed> $out * @return array<string, mixed> 形如 ['a.b.0' => 'x', 'a.c' => null] */ function collectLeaves(mixed $node, string $prefix = '', array &$out = []): array { if (is_array($node)) { // 空数组本身也要记录,否则「空列表」和「字段不存在」会分不清 if ($node === []) { $out[$prefix === '' ? '(root)' : $prefix] = []; return $out; } foreach ($node as $key => $value) { $path = $prefix === '' ? (string) $key : $prefix . '.' . $key; collectLeaves($value, $path, $out); } return $out; } $out[$prefix === '' ? '(root)' : $prefix] = $node; return $out; }

注意这里用引用参数array &$out累积结果,而不是每层返回一个新数组再合并。原因很实际:每层array_merge一次,深拷贝的次数会随深度平方级增长,几千个节点的 JSON 就能让这个函数变成热点。

四、解析失败为什么是静默的

json_decode()失败时返回null,并且不会抛异常,除非你加上JSON_THROW_ON_ERROR(这个标志是PHP 7.3引入的)。不加这个标志,常见的失败原因——深度超过默认的 512 层、末尾有多余逗号、非 UTF-8 字节序列、NaN这种 JSON 不认的字面量——全都表现为「返回null」,然后你在下游某处看到一个foreach报TypeError,排查方向完全被带偏。

从PHP 8.3起,还可以用json_validate()先做一次合法性判断,它的好处是不做真正的解码、不构造对象,只想确认「这段字符串是不是合法 JSON」时开销更小:

<?php declare(strict_types=1); // json_validate() 需要 PHP 8.3+ $raw = '{"a":1,}'; // 末尾多了个逗号,是非法 JSON if (PHP_VERSION_ID >= 80300 && !json_validate($raw)) { echo "不是合法 JSON\n"; } try { $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { echo '解析失败:' . $e->getMessage() . "\n"; // 输出带出错位置的说明 }

实战:完整可运行示例

把下面这段保存成extract.php直接运行,它会解析一段模拟的接口返回,做路径取值、全量叶子收集,并输出结果。

<?php declare(strict_types=1); // extract.php —— 需要 PHP 8.1+ const SAMPLE_JSON = <<<'JSON' { "code": 0, "data": { "list": [ { "id": 1001, "name": "订单 A", "tags": ["urgent", "paid"], "items": [ {"sku": "A-1", "qty": 2, "meta": {"warehouse": "SH"}}, {"sku": "B-2", "qty": 1, "meta": {}} ] }, { "id": 1002, "name": "订单 B", "tags": [], "items": [ {"sku": "C-3", "qty": 5, "meta": {"warehouse": "BJ"}} ] } ], "paging": {"total": 2, "page": 1, "note": null} } } JSON; function dataGet(mixed $target, string $path, mixed $default = null): mixed { /* 见上文第二节的完整实现 */ } function collectLeaves(mixed $node, string $prefix = '', array &$out = []): array { /* 见上文第三节的完整实现 */ } // ---------- 1. 解析 ---------- try { $data = json_decode(SAMPLE_JSON, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { exit('JSON 解析失败:' . $e->getMessage() . "\n"); } // ---------- 2. 定点取值 ---------- printf("第一个订单名:%s\n", dataGet($data, 'data.list.0.name')); printf("第二个订单的第一个 SKU:%s\n", dataGet($data, 'data.list.1.items.0.sku')); printf("不存在的路径:%s\n", var_export(dataGet($data, 'data.list.5.name', '(缺省值)'), true)); // ---------- 3. 通配符批量提取 ---------- $allIds = dataGet($data, 'data.list.*.id'); printf("所有订单 ID:%s\n", implode(',', array_map('strval', $allIds))); // 提出所有 SKU:先通配到每个订单的 items,再通配取 sku $allSkus = []; foreach (dataGet($data, 'data.list.*.items') as $items) { foreach (dataGet($items, '*.sku') as $sku) { $allSkus[] = $sku; } } printf("所有 SKU:%s\n", implode(',', $allSkus)); // ---------- 4. 全量叶子路径 ---------- $leaves = collectLeaves($data); ksort($leaves); printf("\n共 %d 个叶子节点:\n", count($leaves)); foreach ($leaves as $path => $value) { printf(" %-40s = %s\n", $path, var_export($value, true)); } // ---------- 5. 空对象与空数组的区别 ---------- $asArray = json_decode('{}', true); $asObj = json_decode('{}'); printf("\n{} 解成数组再编码:%s\n", json_encode($asArray)); printf("{} 保持对象再编码:%s\n", json_encode($asObj));

运行输出(节选):

第一个订单名:订单 A 第二个订单的第一个 SKU:C-3 不存在的路径:'(缺省值)' 所有订单 ID:1001,1002 所有 SKU:A-1,B-2,C-3 共 15 个叶子节点: code = 0 data.list.0.id = 1001 data.list.0.items.0.meta.warehouse = 'SH' data.list.0.items.1.meta = [] ... data.paging.note = NULL ... {} 解成数组再编码:[] {} 保持对象再编码:{}

最后两行是本篇最值得记住的差异:json_decode('{}', true)得到[],再json_encode回去就变成了[],下游按对象解析的客户端会直接报错。

常见坑点

1. 忘了json_decode的第二个参数

❌$data = json_decode($raw);之后写$data['data']['list'],报Cannot use object of type stdClass as array。 ✅ 明确选择形态:只读取值用json_decode($raw, true, 512, JSON_THROW_ON_ERROR);需要保留对象/数组类型差异时才用对象形态,并统一用->访问。

2. 用isset()判断字段是否存在

❌if (isset($row['note'])) { ... }—— 当note明确为null时判断为假,于是「字段不存在」和「字段值为 null」被当成同一件事。 ✅ 需要区分时用array_key_exists('note', $row);只做取值兜底时才用??。

3. 空对象被「吃掉」了

❌ 全链路用json_decode($raw, true)处理,转发给下游时{"meta": {}}变成{"meta": []},接收方按对象反序列化直接失败。 ✅ 涉及转发就保留对象形态,或对已知是对象的字段单独用(object) []还原:$data['meta'] = $data['meta'] === [] ? new stdClass() : $data['meta'];。

4. 用array_walk_recursive()想拿到路径

❌array_walk_recursive($data, fn($v, $k) => $out[] = "$k=$v");—— 回调里只有键和值,没有父级路径,a.b.name和c.name会输出成同名。 ✅ 自己写递归函数,把$prefix显式传下去,如本文第三节的collectLeaves()。

5. 超大整数被转成浮点

❌ 雪花 ID7300000000000001234解码后变成7.3E+18,写回时精度已经丢了。 ✅ 加JSON_BIGINT_AS_STRING:json_decode($raw, true, 512, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING),超长整数会以字符串形式返回。

6. 递归调用里用array_merge累加结果

❌return array_merge($out, collectLeaves($child));—— 每层都复制一遍已经收集的数据,节点数一多就变成热点。 ✅ 用引用参数array &$out就地累积,如示例所示。

7. 深度超过 512 层

❌ 深层嵌套的 JSON 解析后是null,而错误信息没人看,最后表现为「下游莫名收到空数据」。 ✅json_last_error()/json_last_error_msg()在关键入口必须检查;JSON_THROW_ON_ERROR直接让失败变成异常,是更省事的做法。

8. 忘了json_decode对 UTF-8 的要求

❌ 数据库里取出的 GBK 字节流直接喂给json_decode,返回的又是null。 ✅ 先做编码转换(如mb_convert_encoding($s, 'UTF-8', 'GBK'))再解析;json_encode时中文用JSON_UNESCAPED_UNICODE输出可读内容。

总结

需求推荐做法避免的坑
只读取值json_decode($s, true, 512, JSON_THROW_ON_ERROR)解析失败静默返回 null
保留类型用对象形态,注意{}与[]的差异转发后结构被改写
深层取值自写dataGet()点路径函数逐层下标写死、警告刷屏
批量提取路径里用*通配符手工写多层循环
需要路径自写递归,显式传$prefixarray_walk_recursive丢上下文
大整数加JSON_BIGINT_AS_STRING精度丢失
合法性预检json_validate()(PHP 8.3)解码开销白费

遍历嵌套 JSON 的难点不在循环怎么写,而在于先把「取不到值」这件事拆成三种不同的情况:结构不存在、值为null、类型不是预期。把这三者分别用array_key_exists、null比较和类型判断处理掉,再配上一个按路径取值的小函数,剩下的嵌套层级就只是数据,不再是麻烦。

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

2025工业级3D全景相机选型指南:技术要点与实战评估

做工业级三维数据采集这些年&#xff0c;每年年底我都会帮团队做一次全景相机的选型评估&#xff0c;2025年这轮看下来&#xff0c;市场格局确实和三五年前完全不同了。国产工业级3D全景相机不再只是“价格洼地”的代名词&#xff0c;在拼接算法、多传感器同步、深度图融合这些…

作者头像 李华
网站建设 2026/10/3 4:20:00

DOORS模块化需求拆分实战:以ACC系统为例的六步方法论

1. 为什么汽车设计必须把需求“拆开”而不是“写厚”1.1 一个典型的需求失控现场说实话&#xff0c;我在汽车电子行业这些年&#xff0c;见过太多“需求管理翻车”的项目。最典型的现象就是&#xff1a;产品经理拿着几百页的整车型谱需求去开评审会&#xff0c;软件团队只关心自…

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

FastDFS Socket异常排查实战:从TCP链路到连接池的闭环治理

1. 先搞清楚这条链路&#xff1a;socket异常为什么总从 com.github.tobato.fastdfs 冒出来1.1 FastDFS的通信模型与Tobato客户端的调用方式FastDFS本身是一个用C语言实现的分布式文件系统&#xff0c;核心就两个角色&#xff1a;Tracker Server和Storage Server。Tracker负责调…

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

Space Bunny实测:免费1M上下文+多模态,长文本处理新玩法

这两天AI圈里最热闹的一件事&#xff0c;就是那个传说中“免费1M上下文多模态”三件套的Space Bunny突然刷屏。一开始我还以为是哪家小厂放的烟雾弹&#xff0c;结果顺着开发者社区的信息挖了一圈&#xff0c;发现这事还真不是空穴来风。官方放出来的几个关键信息很简单粗暴&am…

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

Spring不推荐@Autowired字段注入的三个本质原因与构造器注入实践

用者交流口吻&#xff0c;不废话&#xff0c;直接给结论和理由。1. Autowired 到底是什么&#xff1f;先搞清楚它怎么工作1.1 一个注解背后的三段逻辑我第一次接触 Autowired 的时候&#xff0c;觉得这东西简直神器&#xff1a;一个注解丢在字段上面&#xff0c;Spring 容器自动…

作者头像 李华
网站建设 2026/10/3 4:18:28

16G显存跑27B量化大模型:部署实战与调优指南

“16G显存跑27B量化大模型”——这个组合一句话就能戳中很多人的痛点。模型参数翻到27B&#xff0c;智商确实上了一个台阶&#xff0c;不再像7B那样经常胡言乱语&#xff0c;但显存却卡在16G这个尴尬的甜点位&#xff1a;上不到24G&#xff0c;硬凑32G又心疼&#xff0c;手里只…

作者头像 李华