假设接口返回订单号、数据库主键、雪花 ID、纳秒时间戳或高精度金额。响应是有效 JSON,请求也正常完成,但执行 JSON.parse() 后,数值却与服务器输出不一致。这不是 JSON 语法错误,而是 JSON 数字与 JavaScript 默认数字类型之间的能力差异。
复现 JSON.parse 大整数精度丢失
const json = '{"id":9007199254740993}';
const parsed = JSON.parse(json);
console.log(parsed.id); // 9007199254740992
console.log(parsed.id === 9007199254740993); // true
console.log(Number.isSafeInteger(parsed.id)); // false 第二个结果看起来不可能,是因为比较右侧的数字字面量同样发生了舍入,两边最终变成了相同的 JavaScript Number。因此,把解析结果与另一个不安全整数进行 Number 比较,并不能证明原始数字没有改变。
为什么边界是 253 − 1?
JavaScript 普通 Number 使用 IEEE 754 双精度浮点格式,包含 53 位整数精度(包括隐含的最高位)。因此,从 -(2^53 - 1) 到 2^53 - 1 之间的每个整数都可以被精确表示。
Number.MAX_SAFE_INTEGER // 9007199254740991 Number.MAX_SAFE_INTEGER + 1 // 9007199254740992 Number.MAX_SAFE_INTEGER + 2 // 9007199254740992 ← 与上一行相同
超过这个边界后,可表示数字之间的间隔逐渐变大,JavaScript 只能选择附近的值,多个不同整数可能落到同一个 Number 上。长小数和超出 Number 有限范围的指数也会遇到类似问题。
MDN 的 Number.MAX_SAFE_INTEGER 文档进一步解释了安全整数边界与双精度浮点数的关系。
JSON 本身限制整数精度吗?
JSON 定义的是文本数字语法,而不是统一的机器数字类型。900719925474099312345 是合法 JSON 数字,最终精度由解析器和目标语言决定。Java 的 BigInteger、数据库 Decimal 与 JavaScript Number 的范围并不相同。
所以,同一份数据在后端可能完全精确,到了浏览器或 Node.js 执行 JSON.parse() 时才改变。“JSON 合法”和“能够被 JavaScript Number 精确表示”是两个独立问题。
为什么普通 JSON.parse reviver 也来不及?
JSON.parse(source, (key, value) => {
if (key === "orderId") {
return BigInt(value);
}
return value;
}); 在传统 reviver 流程中,回调收到值之前,数字文本已经转换成 Number。把舍入后的 Number 再转换为 BigInt,只会保存已经错误的结果,无法找回原始数字。用正则表达式预先给大数字加引号也不可靠:字符串、转义、指数和嵌套语法都可能让简单正则误判,除非它实际上实现了完整 JSON 分词器。
四种可靠解决方案
1. 把 ID 编码成 JSON 字符串
{
"orderId": "900719925474099312345",
"createdAtNs": "1753859012345678901"
} 对于标识符,这是通常最安全的接口约定。ID 是标签,不是需要加减乘除的数量。字符串也更容易在浏览器、不同编程语言、数据库和日志系统之间保持一致。
2. 需要整数运算时,将字符串转换为 BigInt
const data = JSON.parse(
'{"balance":"900719925474099312345"}'
);
const balance = BigInt(data.balance); BigInt 可以精确表示任意长度整数,但不能表示小数,并且 JSON.stringify() 默认不能直接序列化 BigInt。再次发送数据时,应制定明确的传输格式,通常仍然使用十进制字符串。
3. 使用无损 JSON 解析器
如果无法修改数据生产方,应使用保留原始数字文本或映射到任意精度类型的解析器。JSONDock 采用的就是这种方式:格式化和树形查看不会先把所有数字挤进 JavaScript Number。
4. 金额使用最小单位整数或十进制字符串
BigInt 只能解决整数问题。0.1 或 1234567890.123456789 这类数值需要十进制策略。金融接口通常把金额表示为最小单位整数的字符串,或者使用交给 Decimal 类库处理的十进制字符串。
不同数据应该选择哪种方案?
| 数据类型 | 推荐表示方式 | 原因 |
|---|---|---|
| 订单号、用户 ID、数据库 ID、雪花 ID | JSON 字符串 | 标识符不应该参与算术 |
| 需要计算的超大整数 | 传输时用字符串,代码中用 BigInt | 保证整数运算精确 |
| 金额或高精度小数 | 最小单位整数字符串或十进制字符串 | 避免二进制浮点舍入 |
| 无法修改的第三方 JSON | 无损解析器 | 保留原始数字文本 |
| 普通计数和一般测量值 | 校验范围后使用 Number | 精度足够时最简单原生 |
如何发现有风险的数字?
解析完成后,Number.isSafeInteger(value) 可以判断一个 Number 是否适合精确整数比较和运算,但它不能恢复已经丢失的数字。最有效的校验位置是 API 边界,即不安全数字被转换成 Number 之前。
function assertSafeInteger(value, field) {
if (!Number.isSafeInteger(value)) {
throw new RangeError(`${field} 不是安全整数`);
}
} 已有接口应加入边界测试:覆盖略小于、等于和大于 Number.MAX_SAFE_INTEGER 的数字。如果业务允许,还要测试负数、长小数和超大指数。
JSONDock 如何避免静默舍入?
JSONDock 使用无损数字表示,保留原始数字文本,并贯穿格式化、修复、树形/表格查看、节点复制和 JSON 转 XML。当文档中存在不适合普通 JavaScript Number 的值时,状态栏也会显示“数字精度已保留”。
这不会改变业务代码中的 JavaScript 规则,但可以确保调试工具不会在你排查问题时先修改待检查的数据。