JSON 工具箱

兩份 JSON 差在哪:按位置比、按 id 比,還是先正規化

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

入門約 6 分鐘發佈於 2026-10-30更新於 2026-10-30
兩份 JSON 差異比對封面

結論:先正規化再比較:鍵序、縮排與數字寫法用 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:斷言值,不斷言文字

最後落到團隊習慣。三條規則就夠:

  1. 測試裡斷言解析後的值,不要斷言 JSON.stringify 的結果。Jest 用 toEqual,Python 在 json.loads 之後 assert a == b,鍵序和縮排都不參與判定;
  2. 給動態欄位留白名單。requestId、timestamp、traceId 每次都不一樣,不排除就永遠紅。jsondiffpatch 的 propertyFilter、deepdiff 的 exclude_paths 都是做這個的;
  3. 比對失敗時輸出路徑而不是全文。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 排序再貼進去,插入位置固定,雜訊收斂到一筆。但如果陣列順序本身是資料(遷移步驟、搜尋排名、圖表資料點),就應按位置比對,不要排序。