คำถามที่พบบ่อยเรื่องไวยากรณ์และรูปแบบ: comment, trailing comma และความแม่นยำ
สรุปสำคัญ: comment อนุญาตหรือไม่, ทำไม trailing comma ถึง error, key ต้องมี quote ไหม, ทำไม 9007199254740993 เปลี่ยนค่า — สิบสองข้อสรุปที่แต่ละข้อเทียบกับ RFC 8259

สรุปสั้น ๆ: สำหรับข้อมูลใด ๆ ที่ข้ามขอบเขตระหว่างโปรเซส ให้เขียน JSON แบบ RFC 8259 ธรรมดา — เครื่องหมายคำพูดคู่ ไม่มี comment ไม่มี trailing comma เป็น UTF-8 ที่ไม่มี BOM; ใช้ JSONC หรือ JSON5 สำหรับ config ที่ต้องมี comment แล้วแปลงกลับตอนรันไทม์; และให้ serialize จำนวนเต็มที่เกิน 2^53-1 เป็น string ฝั่งเซิร์ฟเวอร์
สิ่งที่คำถามเหล่านี้มีร่วมกัน: มันดูเหมือน "ควรจะใช้ได้" แต่มาตรฐานนั้นเข้มงวดมากจริง ๆ
1. อะไรถือเป็น JSON ที่ถูกต้อง
JSON, JSON5 และ JSONC ต่างกันอย่างไร?
JSON มาตรฐานรู้จักเฉพาะ string ที่ใช้เครื่องหมายคำพูดคู่ และไม่อนุญาตทั้ง comment และ trailing comma ส่วน JSON5 กับ JSONC ต่างก็เพิ่มความสามารถบนพื้นฐานนี้:
| ความสามารถ | JSON (RFC 8259) | JSONC | JSON5 |
|---|---|---|---|
| string ที่ใช้เครื่องหมายคำพูดคู่ | รองรับ | รองรับ | รองรับ |
| string ที่ใช้เครื่องหมายคำพูดเดี่ยว | ไม่รองรับ | ไม่รองรับ | รองรับ |
| comment | ไม่รองรับ | รองรับ | รองรับ |
| trailing comma | ไม่รองรับ | ไม่รองรับ | รองรับ |
| key ที่ไม่มีเครื่องหมายคำพูด | ไม่รองรับ | ไม่รองรับ | รองรับ |
| เลขฐานสิบหก | ไม่รองรับ | ไม่รองรับ | รองรับ |
NaN / Infinity |
ไม่รองรับ | ไม่รองรับ | รองรับ |
| นามสกุลไฟล์ | .json |
.jsonc |
.json5 |
หลักการเลือกง่ายมาก: เมื่อข้อมูลข้ามขอบเขตระหว่างโปรเซส ให้ใช้ JSON มาตรฐานเสมอ ส่วนไฟล์ config จะใช้ JSONC หรือ JSON5 ก็ได้ ขึ้นกับว่า editor ของคุณรองรับอะไร
tsconfig.json และ .vscode/settings.json มี comment ได้เพราะ editor parse เป็น JSONC แต่ถ้าป้อนให้
JSON.parse ที่เข้มงวดก็ยัง error
JSON มี comment ได้ไหม?
ไม่ได้ ไวยากรณ์ใน RFC 8259 ไม่มี production สำหรับ comment ดังนั้น // หรือ /* */ ใด ๆ ทำให้ parse ล้มเหลว
ถ้าต้องการเก็บคำอธิบายไว้ใน config มีทางเลือกสามทาง:
{
"_comment": "以下超时单位是毫秒",
"timeout": 3000,
"retry": {
"$doc": "重试次数,0 表示不重试",
"count": 3
}
}
- ใช้ฟิลด์ตามธรรมเนียมเช่น
_commentที่ผู้อ่านแค่ข้ามไป; - ใช้ฟิลด์ที่ขึ้นต้นด้วย
$เพื่อลดโอกาสชนกับฟิลด์ธุรกิจ; - เปลี่ยน config ไปใช้ JSONC หรือ YAML แล้วแปลงเป็น JSON มาตรฐานตอนรันไทม์
ทำไม trailing comma ถึงทำให้ error?
เพราะในไวยากรณ์ ทั้ง array และ object เป็นโครงสร้างแบบ "สมาชิก + ตัวคั่น" และไม่อนุญาตให้มี comma หลังสมาชิกตัวสุดท้าย
{
"a": 1,
"b": 2,
}
ตัวอย่างข้างบนมี comma เกินมาหนึ่งตัวก่อน }, และ error มักถูกรายงานที่ comma หรือที่ปีกกา closing ขึ้นกับ implementation ของ parser
นี่คือกับดักที่ง่ายที่สุดเมื่อย้ายมาจาก JavaScript — object literal ของ JS อนุญาต trailing comma แต่ JSON ไม่
ใช้เครื่องหมายคำพูดเดี่ยวได้ไหม?
ไม่ได้ string ต้องใช้เครื่องหมายคำพูดคู่ 'text' เป็น syntax error
ถ้าข้อมูลถูกคัดลอกจาก repr() ของ Python, จาก shell script หรือจาก config ที่เขียนด้วยมือ ให้แปลงด้วยเครื่องมือก่อน
อย่าหวังว่า parser จะผ่อนปรน
key ไม่มีเครื่องหมายคำพูดได้ไหม?
ไม่ได้ ชื่อสมาชิกของ object ต้องเป็น string ที่อยู่ในเครื่องหมายคำพูดคู่
{ name: "json" }
นั่นคือ JavaScript object literal ไม่ใช่ JSON การใส่เครื่องหมายคำพูดทำให้ถูกต้อง:
{ "name": "json" }
ระดับบนสุดเป็น array หรือ string ได้ไหม?
ได้ RFC 8259 อนุญาตให้ค่า JSON ใด ๆ อยู่ที่ระดับบนสุด: object, array, string, number, true, false และ null ล้วนถูกต้อง
แต่ในทางวิศวกรรมจริง แนะนำให้ใช้เฉพาะ object หรือ array ที่ระดับบนสุด ด้วยเหตุผลสองข้อ:
- ความเข้ากันได้: parser รุ่นเก่า (ยุค RFC 4627) บังคับให้ระดับบนสุดเป็น object หรือ array;
- ความปลอดภัย: array ที่ระดับบนสุดเคยมีความเสี่ยง JSON hijacking เบราว์เซอร์สมัยใหม่แก้ไปแล้ว แต่ระบบเก่ายังถือธรรมเนียมนี้อยู่
ดังนั้น "hello" เป็น JSON ที่ถูกต้อง และ ["a", "b"] ก็เช่นกัน แต่ API ที่คืน {"data": [...]} ปลอดภัยกว่า
2. การเข้ารหัสและ string
BOM ที่ต้นไฟล์ทำให้ parse พังไหม?
พัง RFC 8259 กำหนดชัดว่า JSON text ต้องส่งแบบ UTF-8 และ ห้าม ใส่ byte order mark (BOM)
BOM คือสามไบต์ EF BB BF ที่ต้นไฟล์ มองไม่เห็นเลย แต่มันกินตำแหน่งแรกของ string
ดังนั้น parser จึงสรุปว่า "อักขระตัวแรกไม่ใช่ {" แล้ว error — และบรรทัดกับคอลัมน์ที่รายงานมักเป็นบรรทัดที่ 1 คอลัมน์ที่ 1 ซึ่งดูงงมาก
"Save as UTF-8" ของ Windows Notepad ใส่ BOM ไว้โดยค่าเริ่มต้น วิธีตรวจและลบ:
# ตรวจสอบ: ถ้า output ขึ้นต้นด้วย efbbbf แสดงว่ามี BOM
head -c 3 data.json | xxd
# ลบ: ข้าม 3 ไบต์แรกด้วย tail
tail -c +4 data.json > clean.json
JSON.parse ของ JavaScript สมัยใหม่ทน BOM ได้ (V8 ข้ามให้ภายใน) แต่ implementation ที่เข้มงวดใน Java, Go และ Python ไม่ทน
นี่คือสาเหตุพบบ่อยที่ "ไฟล์เดียวกันให้ผลต่างกันในแต่ละภาษา"
ต้องแปลงอักขระจีนเป็น \uXXXX ไหม?
ไม่ต้อง RFC 8259 กำหนดให้ JSON text เป็น UTF-8 ภาษาจีนจึงปรากฏตรง ๆ ได้ {"name": "张三"} ถูกต้องสมบูรณ์
\uXXXX เป็นเพียงการสะกดทางเลือกที่ให้ความหมายเท่ากัน ใช้กับช่องทางที่ไม่สามารถแทนอักขระนั้นได้โดยตรง กฎง่าย ๆ:
- HTTP request body, ไฟล์, ฐานข้อมูล: เขียนภาษาจีนตรง ๆ ประหยัด 3 ไบต์ต่ออักขระ;
- HTTP header, URL parameter, การฝังใน string อีกชั้น: ใช้ escape เพื่อเลี่ยงปัญหาการเข้ารหัส
อักขระเดียวที่ ต้อง escape คือ control character (U+0000 ถึง U+001F) ตัวอย่างเช่น ขึ้นบรรทัดใหม่ต้องเขียนเป็น \n
คุณไม่สามารถใส่บรรทัดใหม่จริง ๆ ลงใน string ได้
ใส่อีโมจิลงใน string ได้โดยตรงไหม?
ได้ แต่จำไว้ว่ามันกินสอง UTF-16 code unit
อีโมจิและอักขระหายากจำนวนมาก (เช่น 𠮷) อยู่นอก BMP ดังนั้นใน JavaScript "😀".length === 2
การตรวจสอบด้วยการนับจำนวนอักขระจึงพลาดได้ง่าย:
const text = "😀";
console.log(text.length); // 2 คือจำนวน UTF-16 code unit
console.log([...text].length); // 1 คือจำนวน code point
console.log(Buffer.byteLength(text)); // 4 คือจำนวนไบต์ UTF-8
ถ้าจำกัดความยาวชื่อเล่นด้วย .length อีโมจิหนึ่งตัวจะกินสองช่อง ถ้า backend นับไบต์ก็จะกินสี่ช่อง
เมื่อตกลงนิยามร่วมกัน ให้ระบุชัดว่าหมายถึง code point, code unit หรือไบต์
3. ตัวเลขและค่าพิเศษ
ทำไมจำนวนเต็มขนาดใหญ่ 9007199254740993 กลายเป็น 9007199254740992?
เพราะ JSON.parse ของ JavaScript แปลงตัวเลขเป็น IEEE 754 double precision และจำนวนเต็มที่ปลอดภัยมากที่สุดคือ 2^53-1
Number.MAX_SAFE_INTEGER === 9007199254740991 // true
9007199254740993 // จริง ๆ แล้วได้ 9007199254740992
ตัวเลขใดที่เกิน 2^53-1 อาจถูกปัดเศษในหลักท้าย เหยื่อทั่วไปคือ user ID 19 หลักที่สร้างด้วยอัลกอริทึม snowflake หมายเลขคำสั่งซื้อ และ timestamp ระดับนาโนวินาที
วิธีแก้สามแบบ เรียงตามความเหมาะสม:
- serialize ID เป็น string ฝั่งเซิร์ฟเวอร์:
"id": "9007199254740993"แก้ปัญหาความแม่นยำที่ต้นเหตุ; - ใช้ไลบรารี parse ที่ไม่สูญเสียข้อมูล: ตัวแทนของ
JSON.parseที่เก็บจำนวนเต็มขนาดใหญ่ไว้ได้ตามเดิม เช่นjson-bigint; - ดึงด้วย regular expression เพื่อแสดงผลเท่านั้น: ไม่ parse ทั้งเอกสาร แค่จับคู่ฟิลด์เป้าหมายเป็นข้อความ
โปรดทราบว่า JSON.stringify มีปัญหาสมมาตรกัน: ตัวเลขที่อยู่นอกช่วงจำนวนเต็มปลอดภัยอาจไม่คงค่าเดิมหลัง serialize
NaN และ Infinity ใส่ใน JSON ได้ไหม?
ไม่ได้ production ของ number ใน JSON อนุญาตเฉพาะสัญกรณ์ทศนิยม และ NaN, Infinity, -Infinity ไม่ใช่ค่า JSON ที่ถูกต้อง
วิธีของ JavaScript คือ "ยอมจำนนอย่างเงียบ":
JSON.stringify({ value: NaN }); // '{"value":null}'
JSON.stringify({ value: Infinity }); // '{"value":null}'
ไม่มี error NaN กลายเป็น null ไปเฉย ๆ นี่คือฉากคลาสสิกของ "frontend ส่ง NaN ไป แต่ backend ได้ null"
เมื่อต้องสื่อความหมายเช่นนี้ ให้แสดงออกมาชัดเจนด้วย string หรือค่าส่งสัญญาณที่ตกลงกัน:
{ "ratio": null, "ratioState": "not-a-number" }
เขียนตัวเลขเป็น 1e3 หรือ 0.5 ได้ไหม?
ได้ number ของ JSON รองรับทศนิยมและเลขยกกำลัง ดังนั้น 1e3, 1E3, 0.5 และ -0.5 ล้วนถูกต้อง
แต่มีข้อจำกัด:
- ห้ามขึ้นต้นหรือลงท้ายด้วย
.:.5และ5.ไม่ถูกต้อง ให้เขียน0.5และ5; - ห้ามมีเลขศูนย์นำหน้า:
01ไม่ถูกต้อง; - ห้ามใช้เลขฐานสิบหก:
0x1Fไม่ถูกต้อง นั่นเป็นส่วนขยายของ JSON5; -0ถูกต้อง แต่JSON.stringify(-0)ส่งออก0ทำให้เสียเครื่องหมาย
4. ความหมายของ object
key ซ้ำกันจะเกิดอะไรขึ้น?
พฤติกรรมขึ้นกับ implementation; RFC 8259 บอกเพียงว่าชื่อ key "ควร" ไม่ซ้ำ และไม่ได้กำหนดว่าเกิดอะไรขึ้นเมื่อซ้ำ
ในทางปฏิบัติ implementation ส่วนใหญ่ใช้ค่าตัว สุดท้าย:
JSON.parse('{"a":1,"a":2}') // { a: 2 }
แต่คุณพึ่งมันไม่ได้ ไลบรารี Java บางตัว, encoding/json ของ Go และ json ของ Python ต่างกันในแต่ละเวอร์ชัน:
บางตัวใช้ค่าตัวแรก บางตัว error บางตัวใช้ค่าตัวสุดท้าย การถือว่า key ซ้ำเป็น error เป็นแนวทางที่ปลอดภัยที่สุดเมื่อข้ามภาษา
คุณหา key ซ้ำด้วยตาไม่ได้ — ใน JSON ที่ minify แล้ว key เดียวกันอาจห่างกันหลายพันอักขระ ให้ใช้สคริปต์:
# ดึง key ที่ปรากฏมากกว่าหนึ่งครั้งในระดับเดียวกัน เป็นการตรวจหยาบ ๆ
jq -r 'paths(scalars) | join(".")' data.json | sort | uniq -d
undefined จะกลายเป็นอะไร?
ขึ้นกับบริบท และนี่คือจุดที่พังง่ายที่สุด:
JSON.stringify({ a: undefined }); // '{}' key ถูกตัดออกทั้งตัว
JSON.stringify({ a: undefined, b: 1 }); // '{"b":1}'
JSON.stringify([undefined]); // '[null]' กลายเป็น null ใน array
JSON.stringify({ a: function () {} }); // '{}' function ก็ถูกตัดออกเช่นกัน
undefined เดียวกันหมายถึง "ฟิลด์หายไป" เมื่ออยู่ใน object และ "กลายเป็น null" เมื่ออยู่ใน array
ถ้า backend แยกการทำงานระหว่าง "ไม่มีฟิลด์" กับ "ฟิลด์เป็น null" คุณจะเจอความไม่สอดคล้องตรงนี้
เมื่อต้องแสดง "ไม่มีค่า" อย่างชัดเจน ให้ใช้ null ไม่ใช่ undefined
หลัง parse {"__proto__": {...}} เกิดอะไรขึ้น?
JSON.parse เองปลอดภัย: __proto__ ถูกมองเป็น own property ธรรมดาและไม่เปลี่ยน prototype
สิ่งที่อันตรายคือการ merge หลัง parse:
const patch = JSON.parse('{"__proto__": {"isAdmin": true}}');
Object.assign({}, patch); // implementation บางตัวปนเปื้อน prototype
วิธีที่ถูกต้องคือวนเฉพาะ own property และข้าม __proto__, constructor และ prototype
รายละเอียดอยู่ใน คำถามที่พบบ่อยเรื่องประสิทธิภาพและความปลอดภัย
5. สรุปเร็ว
| ไวยากรณ์ | ถูกต้อง | หมายเหตุ |
|---|---|---|
{"a": 1,} |
ไม่ | มี trailing comma |
{'a': 1} |
ไม่ | ใช้เครื่องหมายคำพูดเดี่ยว |
{a: 1} |
ไม่ | key ไม่มีเครื่องหมายคำพูด |
{"a": 1} // comment |
ไม่ | มี comment |
"hello" |
ใช่ | string ที่ระดับบนสุดถูกต้อง แต่น้อยครั้งที่ใช้ใน API |
{"a": 1e3} |
ใช่ | รองรับเลขยกกำลัง |
{"a": 01} |
ไม่ | มีเลขศูนย์นำหน้า |
{"a": NaN} |
ไม่ | literal ไม่ถูกต้อง; stringify จะเปลี่ยนเป็น null |
\uFEFF{"a":1} |
ไม่ | BOM ไม่ใช่ส่วนหนึ่งของ JSON text |
{"a":1,"a":2} |
โต้แย้ง | implementation ใช้ค่าตัวสุดท้าย แต่อย่าพึ่งมัน |