前言
接口返回的 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()点路径函数 | 逐层下标写死、警告刷屏 |
| 批量提取 | 路径里用*通配符 | 手工写多层循环 |
| 需要路径 | 自写递归,显式传$prefix | array_walk_recursive丢上下文 |
| 大整数 | 加JSON_BIGINT_AS_STRING | 精度丢失 |
| 合法性预检 | json_validate()(PHP 8.3) | 解码开销白费 |
遍历嵌套 JSON 的难点不在循环怎么写,而在于先把「取不到值」这件事拆成三种不同的情况:结构不存在、值为null、类型不是预期。把这三者分别用array_key_exists、null比较和类型判断处理掉,再配上一个按路径取值的小函数,剩下的嵌套层级就只是数据,不再是麻烦。