如何比较两个 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。它会优先寻找数组对象的稳定字段,例如 id、key、name、uuid 和 _id,因此开头插入 Linus 不会让后面的用户错位。Ada 与 Grace 保持对齐,用户 3 显示为新增,Grace 的记录显示为修改,字符串内的 com → org 片段也会被标出。旁边的变更列表会按 JSONPath 列出每一项:

如果数组是标量列表,或对象没有上述稳定字段,工具会退回按位置匹配;这和终端、编辑器的限制相同。它还可以忽略键顺序进行语义比较,折叠未变区域,避免在巨大配置里被无关内容淹没。所有计算都在浏览器中完成,适合审查生产响应或不希望上传到第三方服务的令牌载荷。它不替代流水线里的 jq 和结构化比较库,但很适合不安装任何东西就做一次认真核查。
典型使用场景
- 审查 API 变化。 比较接口的旧响应和新响应,确认只有预期字段变化;深层对象和长字符串也能看清。
- 审查配置。
tsconfig.json、package.json、lint 配置、功能开关。键重排在这里很常见,语义比较能过滤这类噪声。 - 检查夹具和种子数据。 比较测试夹具改前改后,确认没有悄悄影响相邻用例。
还不是有效 JSON 时怎么办
上面的方法都要求两边能解析。编辑过程中常有尾逗号、未闭合括号或只粘了一半的 payload;解析器在第一处语法错误就会停止,结构化比较无法继续。先用 jq . file.json 或 python -m json.tool file.json 找到首个错误的行与列。若两份未完成的文本很接近、你想先看哪里分叉,使用逐行的 文本比较;修复语法后再回到结构化比较。
该选哪一种方法
| 情况 | 建议使用 |
|---|---|
| 只有格式或键顺序不同 | 终端中的 jq -S 加 diff |
| 数组元素重新排序 | 结构化比较,或 JSON 感知的工具 |
| 文件已经在编辑器中打开 | 编辑器自带的文件比较 |
| 在测试或脚本中比较 | 带身份匹配的 deepdiff,或对应语言的结构化比较库 |
| 临时可视化审查、长字符串改动 | 浏览器中的 JSON Diff |
| 输入暂时无法解析 | 先用 jq . 或 python -m json.tool 定位错误,再用 文本比较 |
问题从来不在于“用了 diff”。问题在于把数据交给只理解文本的比较方式,然后看到整份文件变红,就误以为发生了大改动。让数据按数据的方式比较,数组按身份匹配,变化按路径说明,最后留下的才是你真正做出的改动。