语法与格式常见问题:注释、尾逗号与精度
结论:注释能不能写、尾逗号为什么报错、键名要不要加引号、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,
细节见 性能与安全常见问题。
常见问题
为什么同一个 JSON 文件在 Chrome 能解析、在 Java 里却报错?
通常是 BOM。现代 JavaScript 的 JSON.parse 会跳过开头的 BOM,而 Java、Go、Python 的严格实现不会,于是同一个文件在不同语言里得到不同结果。先用 head -c 3 data.json | xxd 确认是否以 EF BB BF 开头,再用 tail -c +4 清掉。
怎么把 JSON5 或 JSONC 的配置变成合法 JSON?
用工具做一次转换,产出标准 JSON 后再发给后端或写进接口请求体。tsconfig.json、.vscode/settings.json 能写注释,只是因为编辑器按 JSONC 解析,把它们丢给严格的 JSON.parse 一样会报错。
为什么不能依赖重复键的最后一次取值?
RFC 8259 只说键名「应当」唯一,没有规定重复时的处理,行为由实现决定。Java 的某些库、Go 的 encoding/json、Python 的 json 在不同版本上有的取第一个、有的报错、有的取最后一个,跨语言传输时把重复键当错误处理最安全。
五、速查
| 写法 | 合法 | 说明 |
|---|---|---|
{"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} |
有争议 | 实现取最后一个,但不能依赖 |