JSON.parse 大数精度丢失:一个让前后端互相甩锅的 Bug

77 阅读5分钟

问题场景

某天上线后,运营反馈:"用户详情页打开后,跳转到其他页面时 ID 不对,每次点进去都是另一个人。"

排查发现,后端返回的用户 ID 是这样的:

{
  "userId": 19028374651234567891,
  "name": "张三"
}

前端用 JSON.parse 解析后,userId 的值变成了:

19028374651234567000

最后几位全变了。 跳转时拿这个错误的 ID 去请求,自然查到了另一个人。

最坑的是——这个 Bug 不是必现的。只有 ID 超过一定位数才会触发,小 ID 完全正常,导致开发环境测不出来。


原因分析

JavaScript 的数字限制

JavaScript 中所有数字都遵循 IEEE 754 双精度浮点数标准(64-bit)。它能安全表示的整数范围是:

- (2⁵³ - 1)  ~  2⁵³ - 1
即 -9007199254740991  ~  9007199254740991

超过这个范围的整数,位数字太多时,JS 会做舍入处理

console.log(19028374651234567891);
// 输出: 19028374651234567000
//        ↑ 末尾被截断,精度丢失

你可以用 Number.isSafeInteger() 来验证:

Number.isSafeInteger(19028374651234567891); // false

JSON.parse 的静默丢失

问题在于 JSON.parse 不会报错——它默默地返回一个精度丢失的数字,没有任何警告:

const data = JSON.parse('{"id": 19028374651234567891}');
console.log(data.id); // 19028374651234567000
// 完全静默,没有异常,没有 console.warn

这就是为什么这个 Bug 特别难排查:没有报错、没有警告,只是数据不对。

谁的锅?

论点分析
后端"我数据库存的明明是完整 ID"✅ 数据源没错
前端"我收到的 JSON 字符串也是完整的"✅ 传输层没错
问题出在 JSON.parse 的解析阶段💥 这里炸了

谁都不背锅,是 JSON 规范与 JS Number 类型之间天然的鸿沟


解决方案

方案一:后端改为字符串返回(推荐)

后端将大整数统一以字符串形式传给前端:

{
  "userId": "19028374651234567891",
  "name": "张三"
}

前端无感知,直接当 string 使用。但要注意接口文档明确标注哪些字段是字符串型 ID,避免混用。

方案二:后端改用 Lossless JSON 库(后端侧)

如果后端不方便改类型,可以用支持无损数字的序列化库:

  • Java: 使用 Jackson + @JsonSerialize(using = ToStringSerializer.class)
  • Go: 使用 json:"userId,string" tag
  • Node.js: 使用 lossless-json

方案三:前端自己用 reviver 处理(前端侧)

JSON.parse 接受第二个参数 reviver,可以在解析时对特定字段做处理:

function safeParse(json) {
  return JSON.parse(json, (key, value) => {
    // 对指定的大数字段做字符串保留
    if (key === 'userId' || key === 'id') {
      return String(value);
    }
    return value;
  });
}

const data = safeParse('{"userId": 19028374651234567891}');
console.log(data.userId); // "19028374651234567891" ✅

但这个方法有个致命缺陷:reviver 执行时,数字已经被截断了。所以如果 JSON 字符串里数字本身就超出了安全范围,reviver 拿到的已经是错误的值,String(value) 也救不了。

// reviver 拿到的是已经被截断的值
JSON.parse('{"id": 19028374651234567891}', (key, val) => {
  console.log(val); // 19028374651234567000 ❌ 已丢失精度
  return String(val); // "19028374651234567000" ❌ 错的
});

所以方案三只能用于后端已经返回字符串的场景,实际用途有限。

方案四:使用 BigInt + 自定义 JSON 解析

对于确实需要前端解析大整数的场景,可以用 json-bigint 库:

import JSONbig from 'json-bigint';

const data = JSONbig.parse('{"id": 19028374651234567891}');
console.log(data.id.toString()); // "19028374651234567891" ✅

原理是解析时将大整数转为 BigInt 类型。

方案五:HTTP 层面用 proto3 的 int64 类型(终极方案)

对于 gRPC / protobuf 项目,proto3 的 int64 在 JS 环境中默认被转为 string,天然避坑。


实际项目中的鉴别方法

如何快速知道你的项目有没有这个隐患?

// 在控制台跑一下
function checkSafeRange() {
  const testVal = 9007199254740993;
  console.log('原始值:', testVal);
  console.log('是否安全:', Number.isSafeInteger(testVal));
  console.log('解析后:', JSON.parse(String(testVal)));
}

// 更实用:扫描所有 ID 字段
fetch('/api/user/1')
  .then(r => r.json())
  .then(data => {
    Object.entries(data).forEach(([key, val]) => {
      if (typeof val === 'number') {
        console.log(key, Number.isSafeInteger(val) ? '✅' : '❌ 精度风险', val);
      }
    });
  });

要点总结

  1. JS 安全整数范围±2⁵³-1,超过就丢精度
  2. JSON.parse 静默丢失——不报错、无警告,极难排查
  3. 推荐方案:后端将大数字 ID 改为字符串格式返回
  4. reviver 不顶用——它拿到的已经是截断后的值
  5. Node.js/Go/Java 后端都有对应的序列化方案避免此问题
  6. 早发现:在开发阶段就可以用 Number.isSafeInteger 给所有数字 ID 做断言

一句话总结:JSON 里的超长数字在 JSON.parse 时会悄无声息地丢失精度。最省心的修法是后端把超过 16 位的数字全换成字符串。别让 JSON.parse 吃掉你的 ID。