六种类型、数字精度与编码陷阱
结论:JSON 只有六种类型,但每种都有让数据悄悄变形的能力。这一篇把数字、字符串、null 的坑一次说透。

结论:先检查 ID、金额这类数字字段是否超过 2^53 - 1 或需要精确到小数后多位,再检查字符串长度按码点还是按 UTF-16 单元计数;超出安全整数范围的值一律用字符串承载,不要指望解析器替你保住精度。
JSON 的类型比多数人以为的更少,坑却更多:数字会变、字符串会断、null 与缺失键是两回事。
一、类型全景
| 类型 | 字面量示例 | JSON 中的约束 | 常见映射 |
|---|---|---|---|
| 对象 | {"a": 1} |
键必须是字符串,值任意 | Object / Map / struct |
| 数组 | [1, "a", null] |
元素可异构 | Array / List / slice |
| 字符串 | "文本" |
UTF-8,双引号 | String |
| 数字 | 1、-1.5、1e9 |
无前导零、无 +、无 NaN |
double / Decimal |
| 布尔 | true / false |
小写 | bool |
| null | null |
唯一空值 | null / Option::None |
注意两个「没有」:没有日期类型,没有整数类型。所有日期都是字符串,所有整数和浮点共用「数字」这一个类型。
二、数字:双精度是默认答案
2.1 安全整数边界
IEEE 754 双精度能精确表示的整数上限是 2^53 - 1 = 9007199254740991。超过之后,相邻整数之间会有间隙:
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992
JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'
这不是 JSON 的问题,是「文本 → 双精度」这一步的问题。任何以 JavaScript 为中间环节的链路(网关、BFF、前端、Node 服务)都会踩到。
处置方案(按推荐度排序):
- ID 全链路用字符串:
{"id": "9007199254740993"},最省心; - 服务端用高精度类型解析:Java 的
BigInteger/BigDecimal、Go 的json.Number、Python 的decimal.Decimal; - 前端用
JSON.parse的 reviver 或支持大数的解析器(如json-bigint)把字段转成BigInt,但要注意BigInt无法直接JSON.stringify。
2.2 小数不是精确值
0.1 + 0.2 // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"
金额一律用「最小货币单位的整数(分)」或字符串表示,不要用浮点做累加。
2.3 序列化时的数字陷阱
JSON.stringify({ a: 1e21 }) // {"a":1e+21}
JSON.stringify({ a: -0 }) // {"a":0}
JSON.stringify({ a: Infinity }) // {"a":null}
JSON.stringify({ a: NaN }) // {"a":null}
JSON.stringify({ a: 10n }) // TypeError: Do not know how to serialize a BigInt
Infinity 与 NaN 会被静默变成 null,这是最难排查的一类「数据凭空消失」。
2.4 各语言的默认行为对照
| 语言 | 默认数字类型 | 需要显式处理的情况 |
|---|---|---|
| JavaScript | double | 大整数、BigInt 序列化 |
| Go | float64(encoding/json) |
用 json.Number 或 Decoder.UseNumber() 保精度 |
| Java(Jackson) | int/long/double/BigDecimal 按字面量推断 |
强制 DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS |
| Python | int(任意精度)/ float |
用 parse_float=Decimal 保小数精度 |
| Rust(serde_json) | i64/u64/f64 |
任意精度需开 arbitrary_precision |
三、字符串:UTF-8 字节流,UTF-16 视角
3.1 长度不等于字符数
JavaScript 的 "🎉".length 是 2,因为 emoji 属于辅助平面,用一对代理项(surrogate pair)表示。
服务端按「字符数」限制长度、前端按 length 校验,两边必然对不上。
'🎉'.length // 2
Array.from('🎉').length // 1
[...'👨👩👧'].length // 5(含零宽连接符)
需要严谨计数时用 Intl.Segmenter(按字形簇)或后端按 Unicode 码点计数,并在接口文档里写清口径。
3.2 孤立代理项
"\ud800" 这种只有高位的孤立代理项在 JSON 语法上合法(\uXXXX 允许任意四位十六进制),但它不是合法的 Unicode 文本。
ES2019 起 JSON.stringify 会把孤立代理项转义成 \ud800,保证输出是「well-formed」的。
跨语言传输时如果对方严格校验 Unicode,这类值会被拒。
3.3 \u2028 与 \u2029
行分隔符与段分隔符在 JSON 里合法,但直接嵌进 JavaScript 源码或 <script> 标签会导致语法错误。
把 JSON 内联到 HTML 时,务必做转义或改用 JSON.parse 从字符串解析。
3.4 中文乱码的三种来源
- 文件本身是 GBK,被当作 UTF-8 读;
- 响应头声明了
charset=gbk,但实际是 UTF-8; - 经过一层「先解码成字符串再编码」的中间件,中文被二次转码。
排查顺序:先看字节(xxd),再看响应头,最后看中间件。
四、null 与「没有这个键」
这两者在 JSON 里是不同的:
{"nickname": null}
{}
- 前者表示「字段存在,值为空」;
- 后者表示「字段不存在」。
到了 TypeScript 就变成 string | null 与 nickname?: string 的区别;到了 Java 就变成「显式 null」与「默认值」的区别。
不做约定会出现这些分裂:
// 前端 A 的写法
if (data.nickname === undefined) { /* 当作没有昵称 */ }
// 前端 B 的写法
if (!data.nickname) { /* null 与空串一起处理 */ }
建议:接口文档规定「没有值统一用 null,不使用缺省」,或者反过来,但只选一种;
更新接口时用 PATCH 语义避免「不知道是没传还是传了 null」的歧义。
五、数组与对象
5.1 数组不是「同构的」
[1, "two", {"three": 3}, null]
语法合法,但类型定义会非常难写。接口里尽量保证数组元素同构,异构场景改成带 type 判别字段的对象数组。
5.2 对象键无序,但很多人依赖顺序
规范说对象是「无序的名称/值对集合」。实际上 V8 会保留插入顺序(整数键除外,整数键会被排序)。 依赖键顺序做逻辑(比如「取第一个键」)就是依赖实现细节。
5.3 稀疏与空洞不存在
JavaScript 的 [1, , 3] 序列化后是 [1,null,3]——空洞变 null。JSON 里没有「空位」概念。
六、一份可执行的检查清单
- [ ] 所有 ID、金额、超长整数在接口契约里明确为字符串;
- [ ] 接口文档写明
null与缺省字段的语义; - [ ] 字符串长度限制写明按码点还是按 UTF-16 单元;
- [ ] 序列化前过滤
Infinity/NaN/undefined,或显式转null; - [ ] 内联到 HTML 的 JSON 做转义;
- [ ] 前后端各写一条「大整数 + emoji + null」的契约测试用例。
常见问题
超过 2^53 - 1 的整数应该怎么处理?
最省心的做法是 ID 这类字段全链路用字符串传输,例如 {"id": "9007199254740993"}。服务端可以用 Java 的 BigInteger / BigDecimal、Go 的 json.Number、Python 的 decimal.Decimal 保住精度。前端用 json-bigint 之类解析器转成 BigInt 也可以,但要记住 BigInt 无法直接 JSON.stringify。
为什么 Infinity 和 NaN 序列化后变成了 null?
JSON 的数字类型不接受 NaN 与 Infinity 这类非标准字面量,JSON.stringify 会把它们静默转换为 null,不报任何错。这是最难排查的一类「数据凭空消失」。序列化前应显式过滤这些值,或按业务约定转成 null 并记录日志。
是否应该用 null 还是直接省略字段?
两种都行,但接口文档必须只选一种并写清楚,否则前端会各自假设。原文的示例里,{"nickname": null} 与 {} 在 TypeScript 中分别对应 string | null 与 nickname?: string。更新接口时用 PATCH 语义,可以避免「不知道是没传还是传了 null」的歧义。