JSON 工具箱

JSONPath 过滤器进阶:递归、切片与多条件筛选

结论:递归下降、数组切片与过滤器组合起来才是 JSONPath 的真正能力,也是误用最集中的地方。本文用可运行样例拆解多条件筛选、结果形态与各库差异。

进阶约 10 分钟发布于 2026-10-31更新于 2026-10-31
JSONPath 过滤器进阶封面

结论:先用确定的层级定位、再叠加切片与过滤条件,不要一上来就写 $..;过滤器里的裸路径只判存在,判布尔真值必须写 @.stock == true。

单条 $.store.book[0].title 谁都会写;真正把 JSONPath 用出问题的是 $.. 叠加切片和多个条件之后——结果多了、少了,或者顺序完全不是你以为的那样。

一、先把样例准备好

{
  "store": {
    "book": [
      { "title": "JSON 权威指南", "price": 8.95, "stock": true, "tags": ["json", "spec"] },
      { "title": "数据交换格式史", "price": 12.99, "stock": false, "tags": ["history"] },
      { "title": "解析器实现原理", "price": 8.99, "stock": true, "tags": ["parser", "json"] },
      { "title": "流式解析实践", "price": 9.5, "stock": true, "tags": [] }
    ],
    "bicycle": { "color": "red", "price": 19.95 }
  },
  "updatedAt": "2026-10-31"
}

下面所有表达式都以这份文档为输入。先记住两个前提:结果是节点集,顺序遵循文档顺序而不是条件书写的顺序;过滤器里的 @ 只代表当前候选节点。

二、递归下降 $..:方便,但代价最高

.. 表示「从当前位置开始,对任意深度递归查找」。RFC 9535 把它定义为 descendant segment,它可以和名字、通配、下标、切片、过滤器组合:

$..price                  // 所有层级里名为 price 的成员
$..book[?(@.price < 10)]  // 任意层级下的 book,再过滤
$..[?(@.stock)]           // 任意层级下 stock 存在的节点

需要理解三件事:

  1. $..book 找到的是所有名为 book 的节点,不管它在第几层;
  2. .. 的遍历是深度优先的,输出顺序依赖实现访问子节点的顺序;
  3. .. 无法利用任何索引,复杂度直接是整棵文档的节点数。

因此 $..price 与 $.store.book[*].price 在结果上可能只差 bicycle 的价格,在性能上却差一个数量级。能确定层级时,永远优先写确定的路径。如果只是想比较两套路径语言的能力边界,可以对照 JSONPath 与 JSON Pointer 的选择。

三、数组切片与通配符:组合时的三个陷阱

切片语法是 [start:end:step],含头不含尾,负数从尾部计数:

写法 含义 结果(以 4 本书为例)
[0:2] 前两本 第 1、2 本
[1:] 从第 2 本到末尾 第 2、3、4 本
[:2] 前两本 第 1、2 本
[-2:] 最后两本 第 3、4 本
[::2] 每隔一本取一本 第 1、3 本
[::-1] 逆序 第 4、3、2、1 本

三个陷阱:

  • 步长为 0 非法。RFC 9535 要求 step 非零,部分实现会静默返回空集;
  • 负步长不是所有库都支持。Goessner 原版支持,个别精简实现只接受正步长;
  • 切片后再过滤,范围不会扩大。[0:2][?(@.stock)] 只在切出来的两本里筛选,不会回到整个数组。

通配符 [*] 与切片可以叠加,但 [*] 得到的是「所有子节点」,如果子节点是对象,后面接 .title 就是所有 title;如果子节点本身是数组,.title 不会自动再展开一层。需要展开就再写一次 [*],不要指望隐式扁平化。

四、多条件筛选:比较、逻辑与存在性

过滤器 [?(...)] 里能写四类东西:比较、逻辑运算、存在性判断和函数。一个常见的多条件查询:

$..book[?(@.price < 10 && @.stock)]

它等价于「价格低于 10 并且 stock 为真」。注意几个语义细节:

  • @.stock 是存在性判断,不是「值为 true」。字段存在但值为 false 时,这个条件仍然成立。想表达布尔真值必须写 @.stock == true;
  • && 的优先级高于 ||,混用时加括号,不要靠记忆;
  • 比较两边的类型必须一致。数字与字符串不会做隐式转换,@.price < "10" 在严格实现里是 false 或直接报错;
  • 缺失字段参与比较一律为 false。@.missing < 10 不会报错,只是不匹配任何节点。

这也是「把过滤条件写成 JavaScript」最容易翻车的地方:JavaScript 里 if (obj.stock) 判的是真值,JSONPath 里判的是存在性。关于标准语法与函数的完整说明,可以回看 JSONPath 查询语言实战。

五、脚本表达式:能力越大,越不该用

Goessner 原版和 jsonpath-plus 支持用 [?(@.price < $.max)] 这种跨节点比较,甚至允许任意脚本。RFC 9535 明确只允许访问当前节点 @,不允许 $ 出现在过滤器内部。

// 老实现可以,RFC 9535 不允许
$..book[?(@.price < $.expensive)]

// 标准做法:阈值由调用方传入
query(document, '$..book[?(@.price < threshold)]', { threshold: 10 })

脚本表达式带来三个问题:

  1. 可移植性归零。换一个库就要重写全部表达式;
  2. 安全边界消失。表达式由用户输入时,脚本求值等于把代码执行权交出去;
  3. 无法静态优化。实现只能逐节点解释执行,慢且难以缓存。

如果确实需要跨节点比较,把阈值作为查询参数、在应用层做二次过滤,通常比依赖扩展语法更稳。

六、结果形态:你以为拿到的是值,其实拿到的是节点

同一个表达式在不同库里的返回值至少有三种形态:

["JSON 权威指南", "解析器实现原理", "流式解析实践"]              // 值数组
[{ "path": "$['store']['book'][0]['title']", "value": "..." }] // 路径 + 值
[{ "pointer": "/store/book/0/title", "value": "..." }]          // JSON Pointer + 值

这直接影响三件事:前端取值、结果去重,以及把查询结果转成写操作。RFC 9535 定义的 nodelist 是「节点引用」,但 JavaScript 实现通常只返回值,因为引用无法跨进程传递。

实践建议:

  • 取单值统一走 [0] 或 value(),不要假设结果一定是标量;
  • 需要回写时选返回 JSON Pointer 的实现,或自己把路径拼出来;
  • 结果为空和结果为 [null] 是两回事,判断时用长度而不是真值。

七、各库差异:迁移前先跑一致性用例

能力 Goessner 原版 jsonpath-plus RFC 9535 严格实现
过滤器括号 ?() 必需 兼容 可省略
跨根引用 $.x 支持 支持 禁止
脚本表达式 支持 支持 禁止
负下标与负步长 支持 支持 支持
返回路径 可配置 可配置 规范未规定
函数 length() 等 无 有扩展 有标准函数

迁移时至少验证四项:括号是否必需、$ 能否出现在过滤器里、负步长行为、结果结构。官方一致性测试套件能覆盖大部分语法差异,用它可以避免「换库后少了三条数据」这类静默错误。

八、性能与误用清单

性能上只有一句话:JSONPath 是一次全量遍历,.. 是全量遍历里最贵的那一种。

  • 十万节点以上的文档,先用确定的路径缩小范围,再对子集过滤;
  • 表达式编译一次、复用多次,别在循环里反复解析字符串;
  • 需要聚合(求和、分组、排序)时换 jq 或代码,JSONPath 只负责选择;
  • 条件越靠前越好,$.store.book[?(@.price < 10)] 比 $..[?(@.price < 10)] 少扫很多无关节点。

误用清单:

  • [ ] 把存在性判断当成布尔真值判断;
  • [ ] 用 $.. 代替已知层级;
  • [ ] 依赖脚本表达式做跨节点比较;
  • [ ] 假设结果一定是值数组;
  • [ ] 在用户可控的表达式上没有长度与嵌套限制。

常见问题

为什么 @.stock 在字段为 false 时仍然匹配?

因为单独的路径在过滤器里表示「字段存在」,不表示「值为真」。要判断布尔值必须显式比较:@.stock == true。这是 JSONPath 与 JavaScript 真值判断最大的语义差异。

递归下降的结果顺序稳定吗?

不稳定。RFC 9535 规定的是节点集语义,遍历顺序依赖实现对子节点的访问顺序。需要固定顺序时,先定位数组再按下标取值,或者拿到结果后在应用层排序。

切片和过滤器可以混用吗?

可以,但要记住作用范围。$.store.book[0:2][?(@.stock)] 只在切片得到的两本书里筛选;如果想先过滤再切片,写成 $.store.book[?(@.stock)][0:2],顺序不同结果就不同。

jsonpath-plus 支持 $.expensive,为什么标准不允许?

因为过滤器只定义了对当前节点 @ 的访问,跨根引用会让表达式依赖整棵文档的上下文,难以静态分析和跨语言移植。需要阈值时把它作为参数传入,而不是写进表达式。

如何避免递归下降扫到大量无关节点?

能确定层级时写确定路径,先缩小范围再对子集过滤,不要用 $.. 代替已知层级。条件越靠前越好,$.store.book[?(@.price < 10)] 比 $..[?(@.price < 10)] 少扫很多无关节点。十万节点以上的文档尤其要先做这一步。