JSON 工具箱

六种类型、数字精度与编码陷阱

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

入门约 12 分钟发布于 2026-10-30更新于 2026-10-30
六种类型、数字精度与编码陷阱封面

结论:先检查 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 服务)都会踩到。

处置方案(按推荐度排序):

  1. ID 全链路用字符串:{"id": "9007199254740993"},最省心;
  2. 服务端用高精度类型解析:Java 的 BigInteger / BigDecimal、Go 的 json.Number、Python 的 decimal.Decimal;
  3. 前端用 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 中文乱码的三种来源

  1. 文件本身是 GBK,被当作 UTF-8 读;
  2. 响应头声明了 charset=gbk,但实际是 UTF-8;
  3. 经过一层「先解码成字符串再编码」的中间件,中文被二次转码。

排查顺序:先看字节(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」的歧义。