← 全部文章

用 jq、语义化 diff 和 JSON Patch 比较两份 JSON 文件

通过归一化键和空白来比较 JSON,用 jq 查看并转换差异,并在 diff 需要变成一次 API 更新时,在 JSON Patch 与 JSON Merge Patch 之间做出选择。

比较两份 JSON 文件,要回答的是一个很实际的问题:到底什么数据变了? 而不是"哪些对象键被反序打印了",不是"一份压缩了另一份缩进两格",也不是"某次发布后序列化器换了一种方式包数组"。

在你评审配置改动、上线后检查 API 响应、比对 Webhook 载荷,或者排查快照测试为什么挂了的时候,这个区别就很要紧。纯文本 diff 对代码很好用,但对 JSON 来说,不先归一化的话通常噪声太大。

可靠的流程是:

  1. 解析两份文件。
  2. 递归排序对象的键。
  3. 用一致的方式格式化两份文档。
  4. 对格式化后的行做比较,得到好读的并排 diff。
  5. 遍历解析后的结构,统计字段级别的新增、删除、修改和未变数量。

fixjson.org 上的 JSON Diff 用的就是这套思路:先解析,忽略对象键顺序,然后把人能据此行动的改动展示出来。

最快的答案

如果你只需要在命令行里查一下,而且两份文件都是合法 JSON,那就先归一化:

diff -u <(jq -S . before.json) <(jq -S . after.json)

jq -S . 会解析 JSON、排序对象键并美化输出,这就把空白和键顺序带来的假阳性去掉了一大半。

以下情况该用支持 JSON 的可视化 diff:

  • 你需要一份并排视图,放进 PR 或故障记录里。
  • 你想要新增、删除、修改字段的汇总数量。
  • 你要比对的是粘贴进来的 API 响应,而不是硬盘上的文件。
  • 你想用同一套语义来比较 YAML。
  • 某一份文档可能是非法的,你需要在比较前先看到解析错误。

只有当你关心的就是格式和键顺序时,才用纯文本 diff。对 JSON 数据来说这种需求不常见,但如果你要逐字节审计生成的产物,它就很重要了。

jq 速查:查看与比较前的准备

jq 会先解析 JSON 再过滤,所以处理嵌套数据比 grep 更安全、表达力也更强。下面这些命令覆盖了大部分比较前的准备工作:

jq . data.json                         # 校验并美化输出
jq -S . data.json                      # 递归排序对象的键
jq -c . data.json                      # 紧凑输出
jq -r '.data.token // empty' data.json # 原始字符串,不带 JSON 引号
jq '.items[] | select(.active)' data.json
jq '{id, name, plan: (.plan // "free")}' data.json
jq empty data.json                     # 只做校验

对于记录数组,可以在 diff 之前按一个稳定的键排序,做一次业务上定义的归一化:

jq -S '.items |= sort_by(.id)' before.json > before.normalized.json
jq -S '.items |= sort_by(.id)' after.json  > after.normalized.json
diff -u before.normalized.json after.normalized.json

只有在顺序确实没有意义时才这么做。给防火墙规则、迁移步骤、搜索排名或事件日志排序,可能会把真实的行为变更藏起来。

--arg--argjson 传递 shell 变量,别把它们直接插进过滤器字符串里:

jq --arg id "$USER_ID" '.items[] | select(.id == $id)' data.json
jq --argjson limit 10 '.items[:$limit]' data.json

脱敏和更新:

jq 'del(.headers.authorization, .cookie)' response.json
jq '.settings.theme = "dark"' config.json

jq 把转换结果写到标准输出,它没有安全的原地编辑能力。请先写临时文件、确认成功,再替换原文件。另外记住 -r 是有意去掉 JSON 字符串引号的,所以它的输出可能含有换行或对 shell 有特殊含义的文本 —— 把不可信数据传给下一个命令时,记得加引号。

为什么纯文本 diff 处理 JSON 会出错

JSON 在磁盘上是文本,但它的含义是一个数据结构。这层错位会制造三类常见的假阳性。

对象键被重排

JSON 对象是无序的名值对集合。下面两份文档承载的是同样的数据:

// before.json
{ "name": "Ada", "plan": "pro", "active": true }

// after.json
{ "active": true, "name": "Ada", "plan": "pro" }

行级 diff 可能把整个对象标成"变了",而 JSON diff 应该报告"数据没变"。

这种情况在下面这些改动之后会不断出现:

  • 换了 JSON 序列化器
  • 跑了一个会排序键的格式化器
  • 从 map 或字典重新生成了测试数据
  • 从一个语言运行时迁移到了另一个

格式噪声

字符串之外的空白在 JSON 里是无意义的:

{"active":true,"plan":"pro"}

和:

{
  "active": true,
  "plan": "pro"
}

解析出来是同一个值。文本 diff 会看到一堆行变更,而支持 JSON 的 diff 一处都看不到。

真实变更被噪声淹没

最糟的情况不是漏报,而是真正的改动被埋在几百处格式变化里:

// before
{ "user": { "id": 42, "plan": "pro", "quota": 1000 } }

// after,用不同的键顺序重新生成
{
  "user": {
    "quota": 1000,
    "id": 42,
    "plan": "team"
  }
}

重要的改动是 $.user.planpro 变成了 team。先归一化,它才会浮出来。

JSON diff 工具该用的流水线

一个好的 JSON 比较工具,同时做两件相关的事:

  • 为人产出一份好读的行级 diff
  • 产出一份语义化 diff,让汇总数量和路径不被格式所迷惑。

实现可以很简单,但顺序很关键。

第 1 步:解析两份文档

先解析。任何一份不合法,就中止并把解析错误报出来。拿坏掉的 JSON 当纯文本去 diff,往往会让你追着症状跑,而不是解决真正的问题。

在 JavaScript 里,严格的写法是这样:

const before = JSON.parse(beforeText);
const after = JSON.parse(afterText);

在 fixjson.org 上,JSON Diff 工具使用本站的解析器,并把整条 diff 流水线放在 Web Worker 里跑,这样比较大文档时,你打字或点 Compare 都不会卡住编辑器。YAML 模式先解析 YAML,之后复用同一套结构比较逻辑。

如果某份文件解析不了,先修好或校验过再来比较:

  • 尾随逗号:把逗号修掉,再 diff
  • 单引号或没加引号的键:先转成合法 JSON
  • API 响应被截断:重新拉一次响应体,别信任基于残缺数据的 diff

有一侧格式错误时,JSON Fix 是更合适的第一站。只有两边都能解析,diff 才有意义。

第 2 步:为展示做归一化

为了得到好读的并排视图,请用同一套规则重新序列化两个解析后的值:

  • 递归排序对象的键
  • 保持数组顺序不变
  • 使用一致的缩进
  • 每一侧输出一个稳定的字符串
function sortJsonKeys(value) {
  if (Array.isArray(value)) {
    return value.map(sortJsonKeys);
  }

  if (value !== null && typeof value === 'object') {
    return Object.keys(value)
      .sort((a, b) => a.localeCompare(b))
      .reduce((acc, key) => {
        acc[key] = sortJsonKeys(value[key]);
        return acc;
      }, {});
  }

  return value;
}

function formatForDiff(value) {
  return JSON.stringify(sortJsonKeys(value), null, 2);
}

归一化之后,前面那两个被重排的例子就完全一致了:

{
  "active": true,
  "name": "Ada",
  "plan": "pro"
}

有一条重要的界线:默认不要给数组排序。 JSON 不在乎对象键的顺序,但数组顺序通常是在乎的。给数组排序,可能会把日志、搜索结果、导航项、优先级规则这类有序数据的真实变更藏起来。

第 3 步:构建语义化 diff 树

行视图告诉人"视觉上什么变了",语义树告诉工具"结构上什么变了"。

语义化 diff 会在相同路径上同时遍历两个解析后的值:

function diffValue(key, path, before, after) {
  if (before === undefined) {
    return { key, path, status: 'added', after };
  }

  if (after === undefined) {
    return { key, path, status: 'removed', before };
  }

  if (isPlainObject(before) && isPlainObject(after)) {
    const keys = Array.from(
      new Set([...Object.keys(before), ...Object.keys(after)])
    ).sort((a, b) => a.localeCompare(b));

    const children = keys.map((childKey) =>
      diffValue(childKey, `${path}.${childKey}`, before[childKey], after[childKey])
    );

    return {
      key,
      path,
      status: children.every((child) => child.status === 'unchanged')
        ? 'unchanged'
        : 'changed',
      children,
    };
  }

  if (Array.isArray(before) && Array.isArray(after)) {
    const length = Math.max(before.length, after.length);
    const children = Array.from({ length }, (_, index) =>
      diffValue(String(index), `${path}[${index}]`, before[index], after[index])
    );

    return {
      key,
      path,
      status: children.every((child) => child.status === 'unchanged')
        ? 'unchanged'
        : 'changed',
      children,
    };
  }

  return deepEqual(before, after)
    ? { key, path, status: 'unchanged', before, after }
    : { key, path, status: 'changed', before, after };
}

它能产出有用的路径:

路径 变更前 变更后 状态
$.user.plan "pro" "team" changed
$.user.quota 1000 2000 changed
$.features.betaSearch 缺失 true added
$.deprecated "legacy" 缺失 removed

汇总那一行来自遍历这棵树并统计叶子节点。父对象可能因为某个子节点变了而被标记为 changed,但有用的计数通常在叶子层面 —— "1 处修改"应该表示一个值变了,而不是路径上每一层容器都算一次。

第 4 步:对归一化后的输出跑行级 diff

两边都按同样方式格式化之后,行级 diff 又重新变得有用了。

经典方法是最长公共子序列(LCS):找出在两份文件里以相同顺序出现的最长行集合,其余的全部标为新增或删除。

例子:

Before: [
  '  "active": true,',
  '  "name": "Ada",',
  '  "plan": "pro"'
]

After: [
  '  "active": true,',
  '  "name": "Ada",',
  '  "plan": "team"'
]

LCS:
[
  '  "active": true,',
  '  "name": "Ada",'
]

得到的操作序列是:

same     "active": true
same     "name": "Ada"
deleted  "plan": "pro"
added    "plan": "team"

并排视图接着可以把相邻的删除/新增配成一行"修改"。

第 5 步:把删除/新增块配成修改行

原始的 LCS 输出只有三种操作:相同、删除、新增。这在技术上没错,但读起来不舒服。人期望"一行值变了"对应"一行显示为修改":

left:  "plan": "pro"
right: "plan": "team"

配对这一步会把相邻的删除/新增连续段收集起来,两两配对:

const deleted = [];
const added = [];

while (ops[i] && ops[i].type !== 'same') {
  if (ops[i].type === 'del') deleted.push(ops[i].line);
  else added.push(ops[i].line);
  i++;
}

const pairs = Math.min(deleted.length, added.length);

for (let index = 0; index < pairs; index++) {
  rows.push({
    type: 'modified',
    left: deleted[index],
    right: added[index],
  });
}

多出来的删除行就老老实实保持删除状态,不做任何硬凑。这样在插入大块对象时可读性依然良好,也不会硬说"每一行新增都替换了某一行旧内容"。

浏览器里真正重要的性能细节

朴素的 LCS 动态规划表是 m * n 大小,m 是左侧行数,n 是右侧行数。

中小型 JSON 文件完全没问题,但文件一大,内存就吃得飞快。

实用的优化:

  • 先裁掉公共前缀和后缀。 如果前 400 行和后 200 行完全相同,那就只 diff 中间变化的部分。
  • 用扁平的类型化数组。 Int32Array 比嵌套的、装箱数字的 JavaScript 数组便宜得多。
  • 设一个上限。 在 fixjson.org 的实现里,如果变化的中间部分需要超过 2_000_000 个矩阵单元,工具就不再构建那张巨表,而是把中间整体显示为一个替换块。
  • 放进 Web Worker 跑。 解析、格式化和 diff 都不占主线程,界面保持响应。

这不是什么理论上的洁癖,而是产品决策。对一个正在粘贴超大测试数据的人来说,一个稍欠优雅的 diff,好过一个卡死的浏览器标签页。

如果你面对的是仓库规模的超大 diff,请用专门的文件工具或流式 diff 方案。而对粘贴进来的 API 响应、配置、测试数据和 Webhook 载荷来说,"解析 → 归一化 → LCS"这条流水线通常是最合适的平衡点。

比较数组:这部分要你自己拿主意

JSON 里数组是有序的,所以最稳妥的默认行为是按位置比较:

// before
[
  { "id": "a", "enabled": true },
  { "id": "b", "enabled": false }
]

// after
[
  { "id": "b", "enabled": false },
  { "id": "a", "enabled": true }
]

按位置的语义 diff 会报告 [0][1] 都变了,尽管对象的集合完全相同。这未必是错的 —— 在有些 JSON 文件里,顺序本身就是数据:菜单项、防火墙规则、搜索排名、迁移步骤和日志事件,都依赖先后次序。

如果在你的业务里数组顺序没有意义,那就在 diff 之前先把数组归一化。比如按稳定的 id 给对象数组排序:

function sortArraysById(value) {
  if (Array.isArray(value)) {
    return value
      .map(sortArraysById)
      .sort((a, b) => {
        const left = typeof a === 'object' && a !== null ? a.id : undefined;
        const right = typeof b === 'object' && b !== null ? b.id : undefined;
        return String(left ?? '').localeCompare(String(right ?? ''));
      });
  }

  if (value !== null && typeof value === 'object') {
    return Object.fromEntries(
      Object.entries(value).map(([key, child]) => [key, sortArraysById(child)])
    );
  }

  return value;
}

只有在你确定这个数组本质上是个集合时才这么做。通用的在线 JSON diff 不应该替你猜。

深度相等 vs JSON diff

有时候你压根不需要 diff,只需要知道两个值相不相等。

用深度相等来做:

  • 测试断言
  • 缓存失效判断
  • "有没有东西变了"这类检查
  • 在做更昂贵的工作之前快速挡一道

用结构化 JSON diff 来做:

  • 代码评审
  • 排查 API 回归
  • 写发布说明
  • 客服问题排查
  • 生成一份 patch
  • 向别人解释配置漂移

deepEqual(before, after) 给你一个布尔值,而 diff 给你一份报告。

把 diff 变成 JSON Patch

有了语义树之后,就可以产出 RFC 6902 定义的 JSON Patch 操作:

[
  { "op": "replace", "path": "/user/plan", "value": "team" },
  { "op": "add", "path": "/features/betaSearch", "value": true },
  { "op": "remove", "path": "/deprecated" }
]

Patch 的路径要按 JSON Pointer 转义:

  • ~ 变成 ~0
  • / 变成 ~1

所以一个叫 a/b 的键,路径是 /a~1b,而不是 /a/b

当你需要一份可移植的更新文档用于 HTTP PATCH 请求,或者一次可重放的迁移时,JSON Patch 很有用。如果只是想给人看"改了什么",并排 diff 更好读。

JSON Patch 与 JSON Merge Patch

HTTP PATCH 规定的是方法,不是请求体的格式。请求应该声明自己携带的是哪种 patch 文档。

JSON Merge Patchapplication/merge-patch+json)看起来就像一份局部资源:

{
  "displayName": "Ada L.",
  "deprecatedField": null
}

对象成员会被递归合并,但 null 表示删除某个成员,数组则整体替换。用来更新普通的资料或设置很简洁,但它区分不了"存一个真正的 null"和"删掉这个成员"。

JSON Patchapplication/json-patch+json)是一个有序的操作列表:

[
  { "op": "test", "path": "/version", "value": 7 },
  { "op": "replace", "path": "/displayName", "value": "Ada L." },
  { "op": "add", "path": "/roles/-", "value": "editor" },
  { "op": "remove", "path": "/deprecatedField" }
]

它支持 addremovereplacemovecopytest,能精确定位数组位置,也能表示真正的 null。但这份额外的精确也带来更多校验工作:每条路径、每个操作、值的类型、授权规则、数组下标和体积上限都得检查。

需求 更合适的默认选择
改几个普通的对象字段 JSON Merge Patch
删除就用发 null 表示 JSON Merge Patch
要存一个真正的 null JSON Patch
插入或删除某一个数组元素 JSON Patch
文档内部的条件更新 test 的 JSON Patch
给人看的局部对象 JSON Merge Patch

Patch 请求同样需要并发控制。请用 ETag 配合 If-Match,或者等价的版本检查,免得一个"合法"的 patch 覆盖掉更新的资源。JSON Patch 的 test 能表达文档层面的前置条件,但它替代不了服务端的授权和事务边界。

从 diff 推导出 patch 很方便,但并不自动就是安全的。应用之前先想清楚:数组改动是不是按位置的、"键缺失"和 null 是不是同一回事、目标 API 允不允许改这些字段。

值得知道的边界情况

非法 JSON

两边都能解析之前,JSON diff 做不了任何有意思的事。先修,再比。拿坏掉的原始文本去比较,往往会把最初的解析错误掩盖掉。

重复的对象键

JSON 解析器通常保留重复键的最后一个值:

{ "plan": "pro", "plan": "team" }

解析之后只剩 "team"。如果重复键本身就是你要审计的内容,那请在 diff 之前用一个会报告重复键的校验器或解析器。

大整数

fixjson.org 的解析器把数字存成 JavaScript 的 number,这意味着超过 Number.MAX_SAFE_INTEGER 的整数可能丢精度:

9007199254740993

在解析过程中就可能被四舍五入。想保住精确位数的话,比较时请把 ID、账目金额或 snowflake 类标识符存成字符串。

null 与"键缺失"

这两者不同:

{ "deletedAt": null }

和:

{}

语义 diff 应该把前者报告为"键存在、值为 null",把后者报告为"键被删除"。对那些用 null 表示"明确置空"、用缺失表示"保持不变"的 API 来说,这个区别很关键。

类型变化

"42"42 不是同一个 JSON 值。即使在界面上看着差不多,语义 diff 也应该把这种类型变化报告为值变更。

常见问题

怎么比较两份 JSON 文件?

解析两份文件,递归排序对象键,用一致的方式格式化,然后比较归一化后的输出。想要可视化结果,用 JSON Diff,它还会显示新增、删除、修改和未变的字段数量。

为什么纯文本 diff 对 JSON 效果不好?

JSON 对象不受键顺序和空白的影响。一个只是重排过的等价对象,会被文本 diff 标记成"变了"。而支持 JSON 的 diff 先解析,所以能忽略这些噪声。

什么是语义化 JSON diff?

语义化 JSON diff 在 $.user.plan$.items[2].id 这类路径上比较解析后的值。它报告的是值级别的变化,而不只是显示哪几行文本变了。

数组该按顺序比还是按 ID 比?

稳妥的默认是按顺序,因为 JSON 数组是有序的。如果你的数组装的是无序记录、且带有稳定 ID,那就在 diff 之前按 ID 排序。通用工具不应该替你做这个假设。

比较 JSON 会丢数值精度吗?

会。基于 JavaScript 的解析器把 JSON 数字存成 IEEE 754 浮点数,超大整数可能丢精度。需要精确的标识符和高精度值,请在 diff 之前当成字符串处理。

能把 JSON diff 变成 JSON Patch 吗?

可以。语义化 diff 能用 JSON Pointer 路径产出 addremovereplace 操作。需要机器可读的更新文档时用 JSON Patch;需要人来评审改动时用并排 diff。

JSON Patch 和 JSON Merge Patch 有什么区别?

JSON Merge Patch 发送一份局部对象,用 null 表示删除成员,数组整体替换。JSON Patch 发送的是带 JSON Pointer 路径的有序操作,因此能精确定位数组元素、保住真正的 null,还能表达 test、move 和 copy。

怎么用 jq 比较 JSON?

先用 jq -S . 归一化两份文件,再对输出跑普通的 diff。只有在明确知道数组顺序无关紧要时,才加上业务相关的数组排序。

试试 JSON Diff 工具

fixjson.org 上的 JSON Diff 走的就是上面这套流程:解析两份文档、排序对象键、用一致方式格式化每一侧、从解析后的结构统计汇总数量,再展示并排的行级 diff。它支持 JSON 和 YAML,比较过程完全在你的浏览器本地运行。

参考资料

最后校订于 2026 年 8 月。