DevKitLab Logo DevKitLab
JSON / 差异比较 / 开发工作流

如何比较两个 JSON 文件,只看真正发生的变化

逐行比较 JSON 往往被缩进和键顺序淹没。本文用终端、编辑器、代码与浏览器工具说明怎样把注意力放回真正变更的数据。

两份 JSON 的结构化对比视图,变化的邮箱字段被高亮

你改了一个接口,只想确认改动正好是预期的:这里多了一个字段,那里换了一个值,没有牵连别处。把旧响应和新响应放进普通 diff 后,屏幕却满是红绿行;真正改了两处,视觉上却像整个文件被重写。

很多时候代码没有问题,问题在于比较工具回答错了问题。普通 diff 比的是文本,而语义相同的 JSON 可以有很多写法:键的顺序不同、缩进是两格还是四格、一个压缩而另一个已经格式化。你真正要比较的是数据。下面分别看终端、编辑器、代码和浏览器中适合的做法,以及它们各自的边界。

本文使用的示例

这是同一个 /users 接口的两个版本。新版本在数组开头新增了一位用户,并修正了另一位用户邮箱域名的笔误;这就是全部变化。

// before.json
{
  "users": [
    { "id": 1, "name": "Ada", "email": "ada@example.com", "role": "admin" },
    { "id": 2, "name": "Grace", "email": "grace@example.com", "role": "user" }
  ]
}
// after.json:新增用户 3,并修正 Grace 的邮箱域名
{
  "users": [
    { "id": 3, "name": "Linus", "email": "linus@example.com", "role": "user" },
    { "id": 1, "name": "Ada", "email": "ada@example.com", "role": "admin" },
    { "id": 2, "name": "Grace", "email": "grace@example.org", "role": "user" }
  ]
}

先澄清一个常见误解

“文本 diff 无法处理 JSON”并不准确。现代行级 diff 对插入和删除已经做得很好。把上面的两份文件交给 git diff,它能正确识别新增的一行和改动的一行:

@@ -1,6 +1,7 @@
 {
   "users": [
+    { "id": 3, "name": "Linus", "email": "linus@example.com", "role": "user" },
     { "id": 1, "name": "Ada", "email": "ada@example.com", "role": "admin" },
-    { "id": 2, "name": "Grace", "email": "grace@example.com", "role": "user" }
+    { "id": 2, "name": "Grace", "email": "grace@example.org", "role": "user" }
   ]
 }

这里新增和修改都很清楚。真正麻烦的是文本变了、数据却没有变的情况。

文本 diff 在哪里制造噪声

真实 JSON 中有三类变化尤其常见。

格式不同。 一边是压缩 JSON,另一边是格式化 JSON;数据完全一致,diff 却会显示整段都变了:

@@ -1 +1,6 @@
-{"users":[{"id":1,"name":"Ada"},{"id":2,"name":"Grace"}]}
+{
+  "users": [
+    { "id": 1, "name": "Ada" },
+    { "id": 2, "name": "Grace" }
+  ]
+}

键顺序不同。 JSON 对象的键没有语义顺序,但文本 diff 不知道这一点。同一条记录由不同服务或不同语言的序列化器输出时,键顺序变了就会被标为改动:

@@ -1,5 +1,5 @@
 {
+  "email": "ada@example.com",
   "name": "Ada",
-  "role": "admin",
-  "email": "ada@example.com"
+  "role": "admin"
 }

Ada 的数据其实没有改变。数组顺序也会产生类似问题:同一批元素只是换了位置,行级 diff 可能把后半段都显示成删除后重新加入。保存时自动格式化、序列化器排序键、不同环境字段顺序不一致,都会把真正要看的那一处变化埋起来。

在终端中先做规范化

最低成本的处理方式是比较前把两边规范化。jq -S 会解析 JSON,再以统一格式和排序后的键输出,因此空白和键顺序的差异会消失:

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

对上面的“仅格式不同”和“仅键顺序不同”两种情况,这条命令会没有输出,因为规范化后两边完全相同。对真正的改动,它只留下新增用户和邮箱变化。若想要颜色和熟悉的展示,也可以使用 git diff --no-index before.json after.json,它能比较两个未被 Git 跟踪的文件。

jq -S 只会排序,不会排序数组元素。数组只是重新排列时,它仍然无法区分移动和改值;这时需要结构化比较。

编辑器的直接比较

如果两个文件已经在编辑器里打开,通常不必先去终端。大多数现代编辑器都能直接选中两个文件并启动比较,得到同步滚动的并排视图和行内高亮。这依然是文本比较,所以格式和顺序的限制仍然存在;但当两边格式相近时,它是最快的第一步。

在代码或测试中比较

当检查需要放进测试或脚本时,应当比较解析后的结构。Python 的 deepdiff 可以忽略数组顺序:

from deepdiff import DeepDiff
import json

before = json.load(open("before.json"))
after = json.load(open("after.json"))

print(DeepDiff(before, after, ignore_order=True))

这里有一个必须理解的细节。启用 ignore_order=True 后,元素起初按照内容匹配。新增的 Linus 没问题,但 Grace 的对象本身发生了变化;仅凭内容,比较器可能把她报告为“删除一项,再新增一项”,而不是“邮箱被修改”。

解决方法是为数组元素提供稳定身份:先判断两条记录是否是同一个实体,再比较它们的字段。

from deepdiff.helper import CannotCompare

def same_user(left, right, level=None):
    try:
        return left["id"] == right["id"]
    except (TypeError, KeyError):
        raise CannotCompare() from None

diff = DeepDiff(before, after, ignore_order=True, iterable_compare_func=same_user)

这样相同 id 的对象会先配对,Grace 的邮箱成为值变化,Linus 成为新增项。DeepDiff 使用自己的 root[...] 位置记法;若你需要 $.users[2].email 这样的 JSONPath 地址,应使用能直接输出 JSONPath 的工具或库。测试里通常不打印差异,而是断言期望值和实际值没有差异:

assert DeepDiff(expected, actual, ignore_order=True) == {}

无论使用哪种语言,核心都一样:解析两侧数据,遍历结构,返回带类型的变化。数组按身份匹配而不是按位置硬比,才能把“整段数组都变了”还原成“这里只改了一项”。

变化需要可引用的地址

结构化比较能提供行级 diff 很难给出的信息:每个变化在数据结构中的地址。不同库记法不同,而交互工具可以把同一位置表示成 JSONPath,例如 $.users[2].email。审查深层配置或大响应时,“第 214 行变了”几乎无法沟通;“$.users[2].email 从 X 改成 Y”则可以直接写进评审、缺陷单或测试。行号描述文件,路径描述数据。

临时进行可视化审查

代码中的结构化比较适合自动化,但日常要做的事情通常更简单:粘贴两段 payload,快速而仔细地确认变化。每次都为数组写身份函数很麻烦,长字符串中究竟改了哪几个字符也不直观;这正是交互式 diff 有价值的地方。

把两边粘贴到 JSON Diff。它会优先寻找数组对象的稳定字段,例如 idkeynameuuid_id,因此开头插入 Linus 不会让后面的用户错位。Ada 与 Grace 保持对齐,用户 3 显示为新增,Grace 的记录显示为修改,字符串内的 com → org 片段也会被标出。旁边的变更列表会按 JSONPath 列出每一项:

JSON Diff 工具:修改后的一侧高亮了新增用户,Grace 邮箱只标出变化片段,旁边面板按 JSONPath 列出两项变化
数组按 id 对齐,字符串内有词级高亮,并提供按 JSONPath 编址的变更列表。

如果数组是标量列表,或对象没有上述稳定字段,工具会退回按位置匹配;这和终端、编辑器的限制相同。它还可以忽略键顺序进行语义比较,折叠未变区域,避免在巨大配置里被无关内容淹没。所有计算都在浏览器中完成,适合审查生产响应或不希望上传到第三方服务的令牌载荷。它不替代流水线里的 jq 和结构化比较库,但很适合不安装任何东西就做一次认真核查。

典型使用场景

  • 审查 API 变化。 比较接口的旧响应和新响应,确认只有预期字段变化;深层对象和长字符串也能看清。
  • 审查配置。 tsconfig.jsonpackage.json、lint 配置、功能开关。键重排在这里很常见,语义比较能过滤这类噪声。
  • 检查夹具和种子数据。 比较测试夹具改前改后,确认没有悄悄影响相邻用例。

还不是有效 JSON 时怎么办

上面的方法都要求两边能解析。编辑过程中常有尾逗号、未闭合括号或只粘了一半的 payload;解析器在第一处语法错误就会停止,结构化比较无法继续。先用 jq . file.jsonpython -m json.tool file.json 找到首个错误的行与列。若两份未完成的文本很接近、你想先看哪里分叉,使用逐行的 文本比较;修复语法后再回到结构化比较。

该选哪一种方法

情况建议使用
只有格式或键顺序不同终端中的 jq -Sdiff
数组元素重新排序结构化比较,或 JSON 感知的工具
文件已经在编辑器中打开编辑器自带的文件比较
在测试或脚本中比较带身份匹配的 deepdiff,或对应语言的结构化比较库
临时可视化审查、长字符串改动浏览器中的 JSON Diff
输入暂时无法解析先用 jq .python -m json.tool 定位错误,再用 文本比较

问题从来不在于“用了 diff”。问题在于把数据交给只理解文本的比较方式,然后看到整份文件变红,就误以为发生了大改动。让数据按数据的方式比较,数组按身份匹配,变化按路径说明,最后留下的才是你真正做出的改动。