{ } JSONDock EN

JavaScript · JSON 精度

JavaScript 处理 JSON 大整数为什么会丢失精度?

JSON 中合法的数字,可能超过 JavaScript Number 能够安全表示的范围。更危险的是,JSON.parse 通常不会报错,而是成功解析并悄悄改变数字。

precision-demo.js
const source = '{"orderId":900719925474099312345}'; const data = JSON.parse(source); console.log(data.orderId); // 900719925474099300000
解析成功,没有异常

但原始订单号已经丢失。

1JSON 语法完全合法
2数字被转换为 Number
3只能选择附近可表示的值
4没有异常提醒开发者

假设接口返回订单号、数据库主键、雪花 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 比较,并不能证明原始数字没有改变。

真正危险的地方解析过程不会抛出异常。被改变的 ID 可能继续进入日志、缓存、页面状态、后续请求甚至数据库查询,直到很久以后才暴露问题。

为什么边界是 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、雪花 IDJSON 字符串标识符不应该参与算术
需要计算的超大整数传输时用字符串,代码中用 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 规则,但可以确保调试工具不会在你排查问题时先修改待检查的数据。