JSON 工具箱

語法與格式常見問題:註釋、尾逗號與精度

結論:註釋能不能寫、尾逗號為什麼報錯、鍵名要不要加引號、9007199254740993 為什麼變樣,十二條結論全部對照 RFC 8259 給出。

入門約 12 分鐘發佈於 2026-10-30更新於 2026-10-30
語法與格式常見問題封面

結論:跨程序傳輸一律寫標準 JSON——雙引號、無註釋、無尾逗號、UTF-8 不帶 BOM;需要註釋的配置改用 JSONC 或 JSON5,並在執行時轉回標準 JSON;超過 2^53-1 的整數在服務端就序列化成字串。

這些問題的共同點是:看起來「應該可以」,規範裡其實寫得很死。

一、什麼算合法 JSON

JSON 和 JSON5、JSONC 有什麼區別?

標準 JSON 只認雙引號字串,不允許註釋與尾逗號。JSON5 與 JSONC 都是在它之上做加法:

特性 JSON (RFC 8259) JSONC JSON5
雙引號字串 支援 支援 支援
單引號字串 不支援 不支援 支援
註釋 不支援 支援 支援
尾逗號 不支援 不支援 支援
無引號鍵名 不支援 不支援 支援
十六進位制數字 不支援 不支援 支援
NaN / Infinity 不支援 不支援 支援
副檔名 .json .jsonc .json5

選擇原則很簡單:跨程序傳輸一律標準 JSON,配置檔案可以按編輯器支援程度選 JSONC 或 JSON5。 tsconfig.json、.vscode/settings.json 能寫註釋是因為編輯器按 JSONC 解析,把它們丟給嚴格的 JSON.parse 一樣會報錯。

JSON 能寫註釋嗎?

不能。RFC 8259 的語法定義裡沒有註釋產生式,任何 // 或 /* */ 都會導致解析失敗。

想在配置裡保留說明,有三種替代寫法:

{
  "_comment": "以下超時單位是毫秒",
  "timeout": 3000,
  "retry": {
    "$doc": "重試次數,0 表示不重試",
    "count": 3
  }
}
  • 用 _comment 這類約定欄位,讀取方忽略即可;
  • 用 $ 字首欄位,減少與業務欄位撞名的機率;
  • 乾脆換 JSONC / YAML 存配置,只在執行時轉成標準 JSON。

尾逗號為什麼會報錯?

因為語法裡陣列與物件都是「元素 + 分隔符」的結構,最後一個元素後面不允許再有逗號。

{
  "a": 1,
  "b": 2,
}

上面這段在 } 前多了一個逗號,錯誤位置通常報在逗號或右花括號上,具體取決於解析器的實現。 這也是從 JavaScript 遷移過來最容易踩的坑——JS 物件字面量允許尾逗號,JSON 不允許。

單引號可以嗎?

不可以。字串必須用雙引號,'text' 屬於語法錯誤。

如果資料是從 Python 的 repr()、Shell 指令碼或手寫配置裡複製過來的,先用工具做一次轉換, 不要指望解析器寬容處理。

鍵名可以不加引號嗎?

不可以。物件的成員名必須是雙引號包裹的字串。

{ name: "json" }

上面的寫法是 JavaScript 物件字面量,不是 JSON。加上引號才合法:

{ "name": "json" }

頂層可以是陣列或字串嗎?

可以。RFC 8259 允許頂層是任意 JSON 值:物件、陣列、字串、數字、true、false、null 都合法。

但工程上建議頂層只用物件或陣列,原因有兩個:

  1. 相容性:老版本解析器(RFC 4627 時代)要求頂層必須是物件或陣列;
  2. 安全性:頂層是陣列時歷史上存在 JSON 劫持風險,現代瀏覽器已修復,但老系統的約定仍在。

所以 "hello" 是合法 JSON,["a", "b"] 也是,但介面返回 {"data": [...]} 更穩妥。

二、編碼與字串

檔案開頭的 BOM 會導致解析失敗嗎?

會。RFC 8259 明確要求 JSON 文字以 UTF-8 編碼傳輸,且不得新增位元組序標記(BOM)。

BOM 是檔案最前面的三個位元組 EF BB BF,肉眼完全不可見,但會出現在字串的第一個位置, 導致解析器認為「第一個字元不是 {」而報錯,而且報錯行號往往指向第 1 行第 1 列,看起來莫名其妙。

Windows 記事本「另存為 UTF-8」預設就帶 BOM。檢查和清除:

# 檢查:輸出以 efbbbf 開頭即為帶 BOM
head -c 3 data.json | xxd

# 清除:用 tail 跳過前 3 位元組
tail -c +4 data.json > clean.json

現代 JavaScript 的 JSON.parse 會容忍 BOM(V8 內部會跳過),但 Java、Go、Python 的嚴格實現不會, 這也是「同一檔案在不同語言裡結果不同」的常見原因。

中文需要轉成 \uXXXX 嗎?

不需要。RFC 8259 要求 JSON 文字是 UTF-8,中文可以直接出現,{"name": "張三"} 完全合法。

\uXXXX 只是等價的另一種寫法,用於那些無法直接表示該字元的傳輸通道。判斷標準:

  • HTTP 請求體、檔案、資料庫:直接寫中文,省 3 位元組/字;
  • HTTP 頭、URL 引數、嵌入另一層字串:用轉義,避免編碼問題。

唯一必須轉義的是控制字元(U+0000 到 U+001F),例如換行必須寫成 \n, 不能直接在字串裡放一個真實換行。

字串裡能直接放 emoji 嗎?

可以,但要注意它佔兩個 UTF-16 碼元。

emoji 和很多生僻字(如「𠮷」)超出了 BMP 範圍,在 JavaScript 裡 "😀".length === 2。 按字元數做校驗時容易算錯:

const text = "😀";
console.log(text.length);          // 2,UTF-16 碼元數
console.log([...text].length);     // 1,碼點數
console.log(Buffer.byteLength(text)); // 4,UTF-8 位元組數

暱稱長度限制如果按 .length 算,一個 emoji 就佔了兩個名額;後端按位元組算又是 4 個。 對齊口徑時要寫清是碼點、碼元還是位元組。

三、數字與特殊值

大整數 9007199254740993 為什麼變成了 9007199254740992?

因為 JavaScript 的 JSON.parse 把數字轉成了 IEEE 754 雙精度浮點,安全整數上限是 2^53-1。

Number.MAX_SAFE_INTEGER === 9007199254740991  // true
9007199254740993                              // 實際得到 9007199254740992

只要數字超過 2^53-1,末位就可能被舍入。典型的受害者是雪花演算法生成的 19 位使用者 ID、 訂單號、時間戳納秒值。

三種修法,按推薦程度排序:

  1. 服務端把 ID 序列化成字串:"id": "9007199254740993",從根上避免精度問題;
  2. 用無損解析庫:JSON.parse 的替代實現可以保留大整數原文,例如 json-bigint;
  3. 只做展示時用正則提取:不解析整個文件,直接按文字匹配出目標欄位。

注意 JSON.stringify 也有對稱問題:超過安全整數範圍的數字在序列化後可能已經不是原值了。

NaN 和 Infinity 能放進 JSON 嗎?

不能。JSON 的數字產生式只允許十進位制表示,NaN、Infinity、-Infinity 都不是合法 JSON 值。

JavaScript 的處理方式是「靜默降級」:

JSON.stringify({ value: NaN });        // '{"value":null}'
JSON.stringify({ value: Infinity });   // '{"value":null}'

沒有報錯,NaN 直接變成 null。這就是「前端傳了 NaN,後端收到 null」的經典現場。 需要傳這類語義時,用字串或約定的哨兵值顯式表達:

{ "ratio": null, "ratioState": "not-a-number" }

數字能寫成 1e3 或 0.5 嗎?

能。JSON 數字支援小數與指數,1e3、1E3、0.5、-0.5 都合法。

但有幾個限制:

  • 不能以 . 開頭或結尾:.5 與 5. 都不合法,要寫 0.5 和 5;
  • 不能有前導零:01 不合法;
  • 不能用十六進位制:0x1F 不合法,那是 JSON5 的擴充套件;
  • -0 合法,但 JSON.stringify(-0) 輸出 0,符號會丟。

四、物件語義

重複鍵會怎樣?

行為由實現決定,RFC 8259 只說鍵名「應當」唯一,沒有規定重複時的處理。

實踐中絕大多數實現取最後一個值:

JSON.parse('{"a":1,"a":2}')  // { a: 2 }

但不能依賴它。Java 的某些庫、Go 的 encoding/json、Python 的 json 在不同版本上行為有差異, 有的取第一個,有的報錯,有的取最後一個。跨語言傳輸時把重複鍵當成錯誤處理最安全。

排查重複鍵不能靠肉眼——壓縮過的 JSON 裡同名鍵可能相隔幾千字元。用指令碼查:

# 提取同一層級出現多次的鍵,粗略檢測
jq -r 'paths(scalars) | join(".")' data.json | sort | uniq -d

undefined 會變成什麼?

分情況,而且是最容易出 bug 的地方:

JSON.stringify({ a: undefined });              // '{}'  鍵被整個丟掉
JSON.stringify({ a: undefined, b: 1 });        // '{"b":1}'
JSON.stringify([undefined]);                   // '[null]'  陣列裡變成 null
JSON.stringify({ a: function () {} });         // '{}'  函式同樣被丟掉

同一個 undefined,在物件裡是「欄位消失」,在陣列裡是「變成 null」。 後端如果按「欄位缺失」和「欄位為 null」走不同分支,這裡就會出現不一致。

需要顯式表達「沒有值」時,用 null 而不是 undefined。

{"__proto__": {...}} 解析後會發生什麼?

JSON.parse 本身是安全的:__proto__ 會被當成普通自有屬性,不會改原型。

危險的是解析之後的合併操作:

const patch = JSON.parse('{"__proto__": {"isAdmin": true}}');
Object.assign({}, patch);       // 某些實現會汙染原型

正規做法是遍歷自有屬性並跳過 __proto__、constructor、prototype, 細節見 效能與安全常見問題。

五、速查

寫法 合法 說明
{"a": 1,} 否 尾逗號
{'a': 1} 否 單引號
{a: 1} 否 鍵名缺引號
{"a": 1} // 註釋 否 註釋
"hello" 是 頂層字串合法,但介面少用
{"a": 1e3} 是 支援指數
{"a": 01} 否 前導零
{"a": NaN} 否 非法字面量,stringify 會變 null
\uFEFF{"a":1} 否 BOM 不屬於 JSON 文字
{"a":1,"a":2} 有爭議 實現取最後一個,但不能依賴