兩份 JSON 差在哪:按位置比、按 id 比,還是先正規化
結論:50 筆資料插進 1 筆,為什麼比對工具報了 51 處差異?講清文字 diff 在 JSON 上的三類誤報、陣列按位置與按 id 的取捨、jq 正規化指令,以及比出差異後怎麼變成可套用的修補檔。

結論:先正規化再比較:鍵序、縮排與數字寫法用 jq -S 或穩定序列化消掉,陣列確認無序時按主鍵排序或建索引再比,最後才考慮把差異轉成修補檔。
JSON 的差異不是文字的差異。把兩個檔案當字串比,鍵序一換就滿畫面標紅;把陣列按位置比,開頭插一筆就能把後面 50 筆全標成「改過」。
訂單服務上線了一個修補,你手上只有兩份回應:改動前 50 筆,改動後 51 筆,中間新插了一筆。打開比對工具,結果報了 51 處變更,紅通通一片,你根本看不出真正動的是哪一筆。
這不是工具壞了,是「比較」這件事在 JSON 上有三種完全不同的做法,而多數人預設用了最不利的那種。
先重現一次誤報:插 1 筆,報 51 處
用 Node 22 寫 30 行程式,造 50 筆訂單({id, sku, qty}),分別在尾端追加一筆和開頭插入一筆,用「按陣列索引逐項對齊」的方式統計差異:
| 操作 | 資料量 | 按位置回報的差異數 |
|---|---|---|
| 尾端追加 1 筆 | 50 → 51 | 1 處 |
| 開頭插入 1 筆 | 50 → 51 | 51 處 |
同樣是「多了一筆」,雜訊量差 51 倍。原因很樸素:按索引比的時候,新元素擠進第 0 位,原本的每一筆都往後挪了一格,第 0 位和第 0 位、第 1 位和第 1 位——每一對都不相等。
換成按 id 建 Map 再比,同一份資料只報 1 處:$[id=ORD-999] added。
這個實驗說明的判斷規則,比任何工具推薦都重要:陣列到底是有序序列,還是無序集合,決定了你該用哪種比法。
文字 diff 在 JSON 上說謊的三種方式
diff a.json b.json 比較的是字元,JSON 承載的是資料結構,這層錯位會穩定產出三類假差異。
1. 物件鍵被重排
RFC 8259 第 4 節寫得很清楚:物件是「無序的名值對集合」(an unordered collection of name/value pairs)。所以下面兩份文件是同一個值:
{ "id": 42, "name": "Ada", "active": true }
{ "active": true, "name": "Ada", "id": 42 }
但行級 diff 會認為三行全變了。我拿一個 10 個鍵的物件把鍵序反轉,pretty-print 之後逐行比對:10 行全部標紅,而結構比較判定兩者相等。雜訊比 10:0。
鍵序為什麼會變?切換序列化函式庫、給 formatter 開了排序、從字典/雜湊表重建物件、換語言執行環境——任何一條都足以讓同一份資料在磁碟上長得不一樣。
2. 空白與縮排
{"a":1} 和 {\n "a": 1\n} 解析出來是同一個值。文字 diff 看到的是換行和空格,全標。
3. 數字和轉義的不同寫法
1.0 與 1、1e2 與 100、"\u00e9" 與 "é",解析後都是同一個值,序列化後文字不同。這類差異比前兩類輕,因為它只影響少量行,但在設定檔比對裡足夠讓你以為某個數值被改過。
反過來,有一類差異文字看不出來、結構看得見:5 和 "5"。字元上多了一對引號,語意上是型別漂移——這通常是 bug,不是更新。結構比較會老實回報 modify。
三種比法,各管一段
| 比法 | 比較對象 | 雜訊來源 | 輸出 | 適合情境 |
|---|---|---|---|---|
| 文字 diff | 字元行 | 鍵序、縮排、數字寫法 | 行號 + 紅綠 | 在地化文案、鍵序穩定的 fixture |
| 結構 diff | 解析後的樹,按路徑對齊 | 陣列按位置對齊 | 路徑 + 增刪改 | API 回應、設定檔、webhook |
| 語意 diff | 樹 + 業務主鍵比對 | 主鍵選錯才有雜訊 | 路徑 + 移動/插入識別 | 記錄清單、訂單、庫存 |
結構 diff 解決的是「鍵序和格式」這一層,語意 diff 再往前一步,解決「陣列元素身分」這一層。三者的差別不在實作複雜度,在於你願不願意告訴工具「這筆記錄的身分證是哪個欄位」。
陣列是分水嶺:位置對齊還是按 id 比對
先問一句:這個陣列的順序本身是不是資料?
- 順序即資料:遷移步驟、搜尋排名、圖表資料點、簽核流程。位置變了就是行為變了,按位置比才對。
- 順序無意義:標籤、權限 ID 集合、訂單明細、庫存清單。插入一筆不該影響其他筆的判定。
順序無意義的情境,做法是抽主鍵建索引再比,而不是按索引硬比:
// 按 id 比對:插入/刪除只報一筆,其餘項目不受位置影響
function diffById(before, after, path = '$') {
const out = [];
const beforeMap = new Map(before.map((item) => [item.id, item]));
const afterMap = new Map(after.map((item) => [item.id, item]));
for (const [id, value] of afterMap) {
if (!beforeMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'added' });
}
for (const [id, value] of beforeMap) {
if (!afterMap.has(id)) out.push({ path: `${path}[id=${id}]`, type: 'removed' });
else if (JSON.stringify(value) !== JSON.stringify(afterMap.get(id))) {
out.push({ path: `${path}[id=${id}]`, type: 'changed' });
}
}
return out;
}
jsondiffpatch 這類成熟函式庫把這件事做成了可設定項:陣列差分用 LCS 找最長公共子序列,但物件元素預設只按位置比對,官方文件特別強調「要按內容比對陣列裡的物件,必須提供 objectHash 函式」。很多人第一次用覺得「這函式庫怎麼這麼笨」,其實是沒傳主鍵。
選主鍵也有講究。用 id 最穩;用 sku 只在 SKU 唯一時成立;用整個物件做 hash 則會把「改了一個欄位」變成「刪一筆加一筆」,比按位置還糟。
命令列裡最快的一招:jq 正規化
不想裝函式庫、只想在終端看一眼,做法是先正規化再 diff。jq -S 遞迴排序物件鍵並統一縮排:
# 正規化後逐行比對,鍵序和縮排造成的雜訊會被消掉
diff -u <(jq -S . before.json) <(jq -S . after.json)
# 陣列確認無序時,先按主鍵排序再比
diff -u <(jq -S '.items |= sort_by(.id)' before.json) \
<(jq -S '.items |= sort_by(.id)' after.json)
我實測 printf '{"b":2,"a":1,"arr":[1,2]}' | jq -S .,輸出是 {"a":1,"arr":[1,2],"b":2}——鍵排了,arr 沒動。這一點必須記住:-S 只排物件鍵,不排陣列元素。陣列要用 sort / sort_by 明確處理,或接受位置比較的雜訊。
兩個坑:
jq沒有原地編輯能力,輸出到標準輸出。要用程序替換(<(...)),或寫暫存檔確認後再替換;-r會去掉字串引號,輸出裡可能帶換行或對 shell 有特殊含義的字元。把不可信資料餵給下一條指令時記得加引號。
本站 /diff 能做什麼、不能做什麼
站內的 JSON 比對工具 用的是同一套結構比較思路:兩邊都解析成樹,再按路徑遞迴對齊,輸出 path + type(add/remove/modify) + before/after,物件鍵序不影響結果。全程在瀏覽器裡算,貼上的封包不會上傳。
但它的陣列是按索引對齊的——這是明確的取捨,不是缺陷:
- 尾端追加、同位置修改、整批替換,它能給你乾淨的結果;
- 開頭或中間插入一筆記錄,它會把後面所有項目都標成
modify,你得自己看出「其實只多了一筆」。
所以用它處理訂單清單這類資料時,正確姿勢是:先按 id 排序兩邊再貼進去。排序後插入位置固定,雜訊收斂到一筆。這一招不需要裝任何東西,能解決八成日常情境。
如果要做的是「產生可套用的修補檔」「識別元素移動」,站內工具不做這件事,往下看下一節。
比出差異之後:修補檔有兩種,別選錯
人看差異用 diff,程式套用差異用 patch。JSON 有兩種修補格式,語意完全不同。
| 維度 | JSON Patch(RFC 6902) | JSON Merge Patch(RFC 7396) |
|---|---|---|
| 形態 | 操作陣列,op + path + value |
一個 JSON 物件,結構同原文件 |
| 能改陣列嗎 | 能,按索引,/items/0、/- 追加 |
不能,陣列整批替換 |
| 能表達「刪鍵」嗎 | 能,remove |
只能靠 null,且無法刪除本身就是 null 的欄位 |
| 能表達移動嗎 | 能,move / copy |
不能 |
| 適用 | 含陣列的資源、稽核記錄、需要原子性 | 純物件的部分更新、設定下發 |
JSON Patch 裡有個常被忽略的操作 test:
[
{ "op": "test", "path": "/version", "value": 3 },
{ "op": "replace", "path": "/status", "value": "shipped" }
]
test 不通過,整個修補被拒絕。這等於把樂觀並行控制塞進了協定裡——不用額外的 If-Match 標頭,也能保證「我看到的是第 3 版,我才改」。做 PATCH 介面時,這條比任何文件約定都管用。
路徑語法是 JSON Pointer(RFC 6901),~0 轉義 ~、~1 轉義 /。鍵名裡帶斜線(例如把 MIME 類型當鍵)就必須轉義,漏了就會指到別處。這部分細節在 JSON Pointer 與 Patch 裡展開過。
還有一個現實問題:由 diff 自動產生的修補檔,陣列部分往往是按索引寫的。索引在生產環境裡很脆弱——並行插入一次,/items/3 指的就不是原本那一筆了。所以自動產生的修補檔要嘛限定在物件欄位,要嘛在套用前用 test 校驗關鍵欄位。
把比對放進 CI:斷言值,不斷言文字
最後落到團隊習慣。三條規則就夠:
- 測試裡斷言解析後的值,不要斷言
JSON.stringify的結果。Jest 用toEqual,Python 在json.loads之後assert a == b,鍵序和縮排都不參與判定; - 給動態欄位留白名單。
requestId、timestamp、traceId每次都不一樣,不排除就永遠紅。jsondiffpatch 的propertyFilter、deepdiff 的exclude_paths都是做這個的; - 比對失敗時輸出路徑而不是全文。5 MB 的封包打滿日誌沒人看,
$.data.items[id=ORD-042].qty: 2 → 3一眼就能定位。
設定漂移的情境同理:把 staging 和 production 的兩份設定都 jq -S 之後比對,再按業務欄位排序陣列,剩下的差異才是真差異。這類「同一份資料在不同環境長得不一樣」的問題,本質上是契約沒定死,可以順著 API 契約設計 那篇往下看。
FAQ
JSON 鍵的順序不同,算不算有差異?
不算。RFC 8259 明確規定物件是無序集合,{"a":1,"b":2} 和 {"b":2,"a":1} 是同一個值。任何把鍵序當差異報出來的工具,做的是文字比較,不是 JSON 比較。
為什麼插入一筆記錄後,所有項目都被標成「變更」?
因為工具按陣列索引逐項對齊。新元素插在第 0 位,原本每筆都後移一格,每對索引的內容都不相等。解決辦法是按主鍵(如 id)排序或建索引後再比,而不是按位置硬比。
線上比對工具會不會把我的資料傳上去?
看實作。伺服器端渲染的工具會把封包送到後端,可能寫進日誌;純前端工具在瀏覽器裡解析,不上傳。比對生產設定、使用者封包這類資料前,先確認頁面是本地計算,或直接斷網測一次。
比對結果能直接當修補檔用嗎?
不能直接用。比對輸出是「哪裡不一樣」,JSON Patch 是「按順序執行哪些操作」,中間還差一層轉換。而且自動產生的修補檔常用陣列索引定位,並行情境下索引會失效,套用前建議加 test 校驗。
幾十 MB 的大檔案怎麼比?
先用串流方式(jq -c 輸出每行一筆)或只抽取關心的子樹再比,例如 jq '.data.items'。整棵樹載入記憶體做遞迴比對,幾十 MB 還撐得住,上百 MB 就要考慮分片或按主鍵抽取。記憶體與耗時的具體量級可以看 大檔案 JSON 效能。
要不要先按 id 排序再比對陣列?
要。站內 JSON 比對工具 的陣列按索引對齊,開頭或中間插入一筆就會把後面所有項目標成 modify;先按 id 排序再貼進去,插入位置固定,雜訊收斂到一筆。但如果陣列順序本身是資料(遷移步驟、搜尋排名、圖表資料點),就應按位置比對,不要排序。