語法與格式常見問題:註釋、尾逗號與精度
結論:註釋能不能寫、尾逗號為什麼報錯、鍵名要不要加引號、9007199254740993 為什麼變樣,十二條結論全部對照 RFC 8259 給出。

結論:跨程序傳輸一律寫標準 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 都合法。
但工程上建議頂層只用物件或陣列,原因有兩個:
- 相容性:老版本解析器(RFC 4627 時代)要求頂層必須是物件或陣列;
- 安全性:頂層是陣列時歷史上存在 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、 訂單號、時間戳納秒值。
三種修法,按推薦程度排序:
- 服務端把 ID 序列化成字串:
"id": "9007199254740993",從根上避免精度問題; - 用無損解析庫:
JSON.parse的替代實現可以保留大整數原文,例如json-bigint; - 只做展示時用正則提取:不解析整個文件,直接按文字匹配出目標欄位。
注意 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} |
有爭議 | 實現取最後一個,但不能依賴 |