同一份 JSON,四個工具給出四種結果:格式化工具怎麼選
結論:格式化不等於無損:帶前導零、BOM 或大整數的同一份 JSON,經 Python、Node、jq、json.tool 處理後有的報錯、有的靜默改值。10 份樣本實測四條解析路徑,給出按場景選型的對照表與一段可複製的嚴格校驗腳本。

結論:格式化工具之間最要命的差別不是縮進幾個空格,而是同一份輸入,有的報錯、有的悄悄把資料改了。選工具前先拿一段帶邊界值的樣本跑一遍,比看功能列表有用得多。
用戶以為格式化只是「重新排版」,實際上每條路徑都在用自己的解析器重新解釋這份文本。解析器一寬鬆,本該擲出的報錯就變成了靜默改資料,而頁面上依然是一片綠色的「格式化成功」。
一、現象:同一份輸入,四條路徑給出四種結果
實測環境是 Python 3.11.1、Node v22.13.1、jq 1.7,樣本為 10 段手工構造的短 JSON,全部經標準輸入送入、不落盤、不改設定。四條路徑分別是 json.loads、JSON.parse、jq -c .、python -m json.tool。表中 ERR 表示拋例外並中斷,靜默→ 表示不報錯但輸出值與輸入已經不一致。
| 樣本 | python json.loads | node JSON.parse | jq -c . | python -m json.tool |
|---|---|---|---|---|
S1 {"a":1,"b":[1,2]} |
OK | OK | OK | OK |
S2 {"a":1,} |
ERR | ERR | ERR | ERR |
S3 {'a':1} |
ERR | ERR | ERR | ERR |
S4 {"a":01} |
ERR char 6 | ERR position 6 | 靜默→ {"a":1} |
ERR |
S5 {"a":1 /*x*/} |
ERR char 7 | ERR | ERR | ERR |
S6 \ufeff{"a":1} |
ERR utf-8-sig | ERR position 0 | 靜默→ {"a":1} |
ERR |
S7 {"id":9007199254740993} |
OK 保真 | 靜默→ 9007199254740992 | OK 保真 | OK |
S8 {"a":1,"a":2} |
靜默→ {"a":2} |
靜默→ {"a":2} |
靜默→ {"a":2} |
OK |
S9 {"a":1 |
ERR char 6 | ERR | ERR EOF | ERR |
S10 <html>502</html> |
ERR char 0 | ERR Unexpected token '<' | ERR | ERR |
十個樣本裡有四個出現「不報錯但資料變了」:S4、S6、S7、S8。也就是說,只看「格式化成功」這盞綠燈,有四成概率你拿到的已經不是原來那份資料。真正讓人踩坑的是這幾條都不在語法錯誤列表裡——文件能打開、能複製、能導出,錯只在內容。
二、原因:語法層和語義層根本是兩件事
RFC 8259 第 1.2 節說得很直白:ECMA-404 允許若干實踐,而 RFC 為了最大互操作性建議避免;第 4 節要求物件名 SHOULD 唯一,卻沒有規定重複鍵怎麼處理;第 6 節討論數字時只給出互操作建議。ECMA-404 更進一步,明確寫「對數字的語義不可知」,只規定數字序列的表示,不規定語言內部怎麼存。換句話說,規範把大量邊界留給了實現者。
於是「格式化」這一步就分化成兩層:語法層判斷文本是否合法,語義層決定這段文本變成什麼值。前者有標準答案,後者沒有。jq 把 01 讀成 1、把開頭的 BOM 當空白跳過,是它在語義層的選擇;Node 把 20 位整數塞進 IEEE 754 雙精度,是 JavaScript 執行時的選擇;四條路徑對 {"a":1,"a":2} 都取最後一個值,是主流解析器的共同默契。這些都不違反「能解析」,但都改變了資料。
JSON 解析報錯怎麼修:Unexpected token 逐條排查 處理的是語法層,把報錯原文翻譯成坐標;本文處理的是它前面那一步——如果工具壓根沒報錯,你連坐標都拿不到。
三、四類靜默改資料,逐條看它們改了什麼
把四個「靜默」樣本單獨拎出來,改動都很小,小到肉眼掃一遍不會發現:
{"a": 01} // 前導零:jq 輸出 {"a":1}
\ufeff{"a": 1} // BOM:jq 直接跳過開頭三位元組
{"id": 9007199254740993} // 20 位整數:JSON.parse 輸出 ...992
{"a": 1, "a": 2} // 重複鍵:四條路徑都只留 {"a":2}
前導零和 BOM 屬於「輸入本來就不合規,但工具替你寬容了」。01 在標準 JSON 裡非法(RFC 8259 第 6 節禁止前導零),BOM 也不該出現在 JSON 文本開頭;Python 與 Node 都明確報錯,jq 卻選擇繼續。這類寬容在日誌清洗時很方便,在契約校驗時是災難:上遊以為自己發的是非法資料需要修,下遊已經拿到一份「看起來正常」的結果。
大整數屬於「輸入完全合規,是執行時裝不下」。9007199254740993 是合法 JSON 數字,但超過 2^53-1,JSON.parse 只能表示成 9007199254740992。Python 的 int 與 jq 都保住了原值。如果這份 JSON 裡是訂單編號或雪花 ID,末位被改一位就意味著對不上帳。
重複鍵屬於「規範沒規定,實現各自選」。四條路徑全部靜默取後者,沒有任何一條給出警告。RFC 8259 只說物件名 SHOULD 唯一,所以這不是 bug,但它是拼接設定、合併修補時最難查的一類問題:《從 RFC 8259 讀懂 JSON 語法邊界》對這類「允許但不建議」有更細的展開。
四、報錯定位能力:誰給行列,誰只給一句
再看出錯的六個樣本,四條路徑給出的資訊量差別很大。Python 的 JSONDecodeError 同時給 msg、lineno、colno、pos;Node v22 的報錯帶 position N (line 1 column M) 並附上出錯處的原文片段;python -m json.tool 復用 Python 的資訊;jq 只給一句 parse error: ... at line N,不給字元偏移。
單行壓縮 JSON 上,行列號基本失效——所有內容都在 line 1。這時候要的是字元偏移,下面這段指令碼把校驗統一成「報錯 + 偏移 + 上下文」:
import json, sys
def strict_check(text: str) -> None:
try:
json.loads(text)
print("OK")
except json.JSONDecodeError as e:
print(f"ERR {e.msg} | char {e.pos} | line {e.lineno} col {e.colno}")
lo, hi = max(0, e.pos - 30), min(len(text), e.pos + 30)
print("...", text[lo:hi].replace("\n", "\\n"), "...")
sys.exit(1)
strict_check(open(sys.argv[1], encoding="utf-8").read())
它比 jq . 嚴格(會拒絕前導零與 BOM),而且給出 e.pos。具體怎麼把 line 1 column 23611 換算成位元組位置、中文文檔裡字元偏移和位元組偏移為什麼會差三倍,單行 JSON 報錯怎麼定位:從 line 1 column 23611 找到出錯的那個位元組 已經講透,這裡不重複。
五、怎麼改:把格式化拆成「嚴格校驗」+「美化」兩步
結論很簡單:不要用一個「格式化」按鈕同時承擔校驗與美化。先用嚴格解析器判合法性,再決定用什麼工具排版。一行命令就能把第一步獨立出來:
python3 -m json.tool input.json > /dev/null && echo "語法 OK" || echo "語法不合法"
python3 -c "import json,sys; d=json.load(open(sys.argv[1],encoding='utf-8')); print(type(d).__name__, len(d))" input.json
第一步只回答「能不能解析」,第二步再檢查「解析出來是不是你期望的結構」。若資料裡有大整數,改用 Python 或 jq 讀取,別走 JSON.parse;若源資料可能帶 BOM,讀入時顯式指定 encoding='utf-8-sig' 而不是指望工具自動跳過;若鍵可能重複,得在解析前單獨掃一遍,因為四條路徑都不會提醒你。把這幾條寫進 CI,就是 編輯器、命令列與自動化工具鏈 講的流程,只是多了一層資料保真斷言。
六、怎麼選:四條路徑的選型表
| 路徑 | 強項 | 風險 | 適合 |
|---|---|---|---|
python json.loads / json.tool |
嚴格、給行列與字元偏移、整數不丟精度 | 拒絕前導零與 BOM,需顯式處理編碼 | 契約校驗、CI、日誌排查 |
node JSON.parse |
瀏覽器原生、報錯附帶原文片段 | 大整數靜默截斷、BOM 報錯 | 前端調試、瀏覽器內工具 |
jq -c . |
管道友好、最快出結果 | 前導零與 BOM 靜默通過、不給字元偏移 | 運維管道、快速取欄位 |
瀏覽器格式化工具 /parse、/editor |
樹形瀏覽、鍵排序、複製路徑、無需裝環境 | 行為取決於網頁用的解析器,必須先驗一次 | 人工檢視、臨時除錯 |
選型的關鍵不是「哪個功能多」,而是「它對我的資料會不會撒謊」。帶訂單編號、金額、設備 ID 的資料,優先 Python 或明確聲明保真的工具;只想看結構、不落庫的調試,瀏覽器工具最省事。本站 /parse 走的是瀏覽器內的語法校驗與格式化,適合「先看清楚長什麼樣」,但涉及大整數時仍建議按上面的指令碼覆核一次。
七、隱私只是其中一條邊界
「資料會不會上傳」是選在線工具時最常被問的一條,但它和「資料會不會被改」是兩件獨立的事:本地執行、斷網可用的工具,照樣可能把 20 位整數截斷;反過來,一個嚴格報錯的在線工具,也可能把原文 POST 到伺服器。兩條要分別驗證,不能互相替代。《在線 JSON 格式化會上傳資料嗎》那篇給了三個兩分鐘能驗完的動作;本文補的是另一半——拿下面這份樣本清單,確認工具不會靜默改值。
八、一份可複製的自檢清單
把 S1–S10 這十段存成一個 cases.txt,每行一段,貼進任何候選工具,核對三件事:報錯的樣本是否真的報錯、報了錯是否給到具體位置、沒報錯的樣本輸出是否與輸入逐字一致。特別核對 S4、S6、S7、S8 四行——只要有一行「靜默→」,就說明這條路徑不能單獨用來做資料校驗。
再補三條工程約束:把嚴格校驗放進提交前勾子,別等上線才發現大整數對不上帳;多語言環境下統一用 UTF-8 無 BOM 寫文件;對來自第三方的 JSON 保留原始回應內容,格式化後的結果只用於閱讀,不用於回寫。《編輯器、命令行與自動化工具鏈》裡那套 pre-commit 設定可以直接接上這份清單。
常見問題
格式化通過,是不是就說明 JSON 沒問題?
不是。S4、S6、S7、S8 四個樣本都「通過」了格式化,但前導零、BOM、20 位整數和重複鍵都被改寫或吞掉。格式化只證明文本能被某個解析器接受,不證明解析後的值等於你發出的值;關鍵欄位要單獨做逐值斷言。
為什麼 jq 會接受 Python 報錯的前導零和 BOM?
jq 有自己的詞法分析器,對 01 這類數字採取寬鬆策略,也會跳過開頭的 BOM;Python 與 Node 按 RFC 8259 嚴格拒絕。這不是版本 bug,是「容錯解析器」與「嚴格解析器」的取捨。做清洗時前者省事,做契約校驗時必須用後者。
大整數被改成 9007199254740992,怎麼避免?
JavaScript 的 Number 是 IEEE 754 雙精度,安全整數上限是 2^53-1。用 JSON.parse 讀 20 位 ID 必然截斷。改用字元串保存這類 ID,或改用 Python int、jq、以及支持 BigInt 的解析庫;前端確實需要數值時,在解析前把該欄位替換成字元串再處理。
在線工具和本地命令行,到底該用哪個?
按資料敏感度與用途分:只是看結構、找欄位,瀏覽器工具最快;要進流程、要斷言資料保真,用 python -m json.tool 加嚴格校驗指令碼。兩者都要先跑一遍 S1–S10 樣本,確認報錯與輸出符合預期,再決定輸入哪一類資料。