大整数精度丢失不用改后端了:JSON.parse 的 source 参数实战

去年年末碰到一个挺典型的线上问题。用户点了「查看订单详情」,页面弹出来一个「订单不存在」。查后端日志,请求收到了,数据库里也有这条记录,返回的 JSON 也正常。让用户截图发过来一看——地址栏里的订单号比实际的少了几位末尾数字,还不一样。

不用猜,这又是大整数精度丢失。后端用 Java 的 Long 存订单 ID,值大概是 1234567890123456789 这种。走 JSON 传到前端,JSON.parse 一转,就变成了 1234567890123456800。用户在详情页点「再来一单」,前端拿这个被改过的 ID 去请求,后端一脸懵。

这个问题存在了太多年,主要原因是标准的解法都要动后端——让后端把这些字段序列化成字符串。但现实情况是,你经常改不动后端,或者改起来要走一堆流程。最近 JavaScript 加了一个新参数,可以在前端自己解决,而且不用引入任何依赖。

问题的根在哪

JavaScript 里的数字只有一种类型:IEEE 754 双精度浮点数。它能精确表示的最大整数是 2^53 - 1,也就是 9007199254740991,十六位。

console.log(Number.MAX_SAFE_INTEGER);
// 9007199254740991

console.log(JSON.parse('{"id": 1234567890123456789}'));
// { id: 1234567890123456800 }

看最后那个 800,原始值的末尾是 789。不是「就近取整」这样的规律,而是尾数位不够了,后面的数被截断并补了零。

订单 ID、用户 ID、雪花算法生成的 ID、时间戳(纳秒精度)、支付流水号——这些东西都很容易超过十六位。前端的痛点是:随便碰一下,ID 就坏了,而且坏得悄无声息。

为什么 reviver 救不了

JSON.parse 其实一直有个 reviver 参数,可以自定义每个值怎么转换:

JSON.parse('{"id": 1234567890123456789}', (key, value) => {
  console.log(typeof value, value);
  return value;
});

打印出来的是 number 1234567890123456800。也就是说,reviver 拿到的已经是解析之后的数了。精度已经丢了,你拿到的只是一具尸体,回去的路找不到。

这就是问题的根源——reviver 能看到值,但看不到生成这个值的原始文本。没有原文,就没法恢复。

source 参数:把原始文本还给 reviver

新的 JSON Source Text Access 提案给 reviver 加了第三个参数,叫 context。它身上有个 source 属性,装着这个值在原始 JSON 字符串里对应的那一小段文本。

JSON.parse('{"id": 1234567890123456789}', (key, value, context) => {
  console.log('解析后的值:', value);
  console.log('原始文本:', context.source);
  return value;
});
// 解析后的值: 1234567890123456800
// 原始文本: 1234567890123456789

有了原文,恢复就变得直接了:判断这个值是否是「合法的整数字符串,且超过了安全整数范围」,如果是,就用 BigInt 重建。

function parseSafe(text) {
  return JSON.parse(text, function (key, value, context) {
    if (
      typeof value === 'number' &&
      context &&
      typeof context.source === 'string'
    ) {
      const src = context.source;
      // 只处理纯整数形式,且超长
      if (/^-?d+$/.test(src) && src.replace('-', '').length >= 16) {
        return BigInt(src);
      }
    }
    return value;
  });
}

const data = parseSafe('{"id": 1234567890123456789}');
console.log(data.id);          // 1234567890123456789n
console.log(typeof data.id);   // 'bigint'

几个细节要留意:

context 不一定存在。如果浏览器不支持这个提案,第三个参数压根不会传进来。所以用之前一定要判空——这也是让代码向后兼容的关键。

正则判断的部分要小心 -。用 src.replace('-', '').length 判断位数,比用 src.length 稳一点,负数前缀不会干扰位数计算。阈值的 >= 16 只是粗略的,实际应用里保险起见可以放宽到 15。

指数形式的数字(比如 1e20)不会被这个正则匹配到,会安静地走回原来的丢失逻辑。1e20 这种形式在真实业务 ID 里基本不会出现,可以忽略;但如果你的数据源里确实有,得再加别的判断。

JSON.rawJSON:让序列化也能保真

解析的问题解决了,但还有个反方向的问题:把大整数再序列化回去。BigInt 是不能直接被 JSON.stringify 处理的:

JSON.stringify({ id: 1234567890123456789n });
// TypeError: Do not know how to serialize a BigInt

注意 1234567890123456789n 后面那个 n,如果你直接往 JSON.stringify 里塞 BigInt,会抛错。加个 replacer 转成字符串是最省事的做法:

JSON.stringify(data, (key, value) => {
  return typeof value === 'bigint' ? value.toString() : value;
});
// '{"id":"1234567890123456789"}'

但这样发出去的 ID 变成字符串了,后端如果是严格的 Long 类型接收,还得配个转换器。同提案引入的 JSON.rawJSON 可以解决这个问题——它可以把一段原始 JSON 文本原样嵌到输出里:

const raw = JSON.rawJSON('1234567890123456789');
console.log(JSON.stringify({ id: raw }));
// '{"id":1234567890123456789}'

输出里 ID 后面没有引号,是个数字字面量,后端看起来和正常数字 JSON 没区别。

用它配合前面的 parseSafe,可以做一个无损的 JSON 往返:

const original = '{"id": 1234567890123456789, "amount": 100}';
const parsed = parseSafe(original);

const roundtrip = JSON.stringify(parsed, (key, value) => {
  return typeof value === 'bigint' ? JSON.rawJSON(value.toString()) : value;
});

console.log(roundtrip === original); // true

注意 JSON.rawJSON 的入参必须是合法的 JSON 文本。传一个 1,2 这种进去,会在调用的时候就抛 SyntaxError,不用等到 JSON.stringify 执行。这是种保护,避免你写错的东西被拼进输出流里。

一个能直接用的工具模块

把上面这些东西收拾一下,做成一个可以放进项目 utils 里的小模块:

const INT_PATTERN = /^-?d+$/;
const BIG_THRESHOLD = 15;

function isOversizedInt(source) {
  if (typeof source !== 'string') return false;
  if (!INT_PATTERN.test(source)) return false;
  return source.replace('-', '').length >= BIG_THRESHOLD;
}

function parseWithBigInt(text) {
  return JSON.parse(text, function (key, value, context) {
    if (
      typeof value === 'number' &&
      context &&
      isOversizedInt(context.source)
    ) {
      return BigInt(context.source);
    }
    return value;
  });
}

function stringifyWithBigInt(value, space) {
  return JSON.stringify(
    value,
    function (key, val) {
      return typeof val === 'bigint' ? JSON.rawJSON(val.toString()) : val;
    },
    space
  );
}

export { parseWithBigInt, stringifyWithBigInt };

用起来很自然:

import { parseWithBigInt, stringifyWithBigInt } from './safe-json.js';

// 模拟后端返回
const raw = '{"orderNo": 9876543210987654321, "goodsId": 10086}';
const order = parseWithBigInt(raw);

console.log(order.orderNo);  // 9876543210987654321n
console.log(order.goodsId);  // 10086  (没超长,还是普通 number)

// 回传给后端
const payload = stringifyWithBigInt({ orderNo: order.orderNo });
console.log(payload); // '{"orderNo":9876543210987654321}'

工具模块里有几个设计决定值得解释一下。

阈值定在 15 位而不是 16 位,是因为边界数字的量级判断有时候会差一位。MAX_SAFE_INTEGER 是 16 位,但到 16 位的数字并不一定超长。放低一位,宁可多做一次 BigInt 转换,也不让边界数字溜过去。转换成本可以忽略,数据错了不好查。

只处理纯整数形式。浮点数、科学计数法、十六进制字符串(JSON 本来也不支持)都不在范围里。真实业务里的长 ID 都是整数,这个范围够用了。

不做全局 monkey patch。JSON.parse = ... 这种改法在多人项目里非常危险,其他库看到的行为和标准不一样,出问题特别难排查。显式调用 parseWithBigInt 更安全。

性能这件事要说清楚

带 reviver 的 JSON.parse 和原生的 JSON.parse 不是一回事。原生版本是 C++ 侧实现的,reviver 版本要回到 JS 层调用每个节点的处理函数,慢是必然的。

我拿一份大概 400KB 的 JSON 测了一下(Chrome 131,笔记本,多次取中位数):

  • JSON.parse 原生:约 2.1 毫秒
  • JSON.parse + reviver:约 7.8 毫秒

大概三倍多的差距。绝对值不大,但如果你有个接口一次返回几兆数据,或者每秒几十次调用,这个差距还是能感觉到的。

有个小优化:先判断整份文本里是不是真的存在超长数字,没有的话直接走原生 JSON.parse

function parseWithBigInt(text) {
  // 粗糙但足够用的探测:找出所有大于等于 15 位的连续数字
  if (!/d{15,}/.test(text)) {
    return JSON.parse(text);
  }
  return JSON.parse(text, /* ...reviver... */);
}

大部分接口返回的 JSON 里数字都不长,这个探测能让多数请求走快路径。代价是遇到超长数字时多扫一遍字符串,但那种请求本来就慢,多几毫秒无所谓。

踩过的几个坑

一、reviver 会被应用到顶层对象。传入的 JSON 如果本身就是一个大整数(比如接口直接返回 1234567890123456789),reviver 的第一个 key 是空字符串 ''value 是被丢过精度的 number。这个没什么问题,走相同的逻辑就能恢复。但如果你在 reviver 里写了 if (!key) return value; 这类快速跳过,就漏了。

二、source 里可能有转义字符。虽然纯数字形式的 source 一般不会有转义,但如果你的判断逻辑扩展到字符串,就要小心 context.source 里返回的是原始 JSON 文本(带引号、带转义),而不是解码之后的字符串。这个是 context.source 和 reviver 的 value 一个本质区别,别搞错了。

三、数组下标作为 key 会调用一次。每个数组元素也会过一遍 reviver,key 是字符串形式的索引 "0""1"。如果你写了 if (key === '') ... 之类的逻辑,记着数组元素也要走正常流程。

四、BigInt 和 Number 不能混用。BigInt 遇到 + 号会报错:

const id = 1234567890123456789n;
// id + 1 会抛 TypeError
// 必须写成 id + 1n

你的业务代码里如果到处在算 ID 的加减,改成 BigInt 之后要全检查一遍。普遍的做法是:ID 只用于比较和传递,不做运算。真要做运算,看需求选 big.jsdecimal.js 这类库更合适。

五、不要把 BigInt 直接丢给模板字符串用。这一点其实不算坑,反而是 BigInt 的好处之一——`${bigintValue}` 会调 toString(),出来的就是完整的字符串。不像 Number 那样丢精度。所以在「ID 当作 URL 参数」这种场景里,BigInt 比 Number 天然安全。

兼容性怎么处理

写这篇文章的时候,context.source 的支持情况是这样的:Chrome 114+ 已经能用(早期叫 JSON.parse 的 source text access),Safari 和 Firefox 还在推进中。这意味着生产环境用之前必须做降级。

降级的思路很简单,检测一下 reviver 的第三个参数存不存在:

const SUPPORTS_SOURCE = (() => {
  try {
    let hit = false;
    JSON.parse('1', (key, value, context) => {
      hit = !!context && typeof context.source === 'string';
      return value;
    });
    return hit;
  } catch {
    return false;
  }
})();

在不支持的浏览器里,只能退回到老方案。手上有这么几个选择:

一是用正则做预处理,把超长数字加上引号,让它变成字符串再解析,然后手动转 BigInt。这个做法脆,容易匹配到字符串内部的数字。只能作为临时过渡。

二是直接依赖后端返回字符串。最稳,但不总是可行。

三是引入 json-bigint 这类库。它自己实现了一整套 JSON 解析器,不依赖原生 JSON.parse,所以不受浏览器支持情况的限制。代价是体积(压缩后大约 10KB 左右)和性能(纯 JS 实现,比原生慢)。如果你的项目必须支持旧浏览器,这个方案是兜底首选。

什么时候该让后端改

前面讲了这么多前端方案,但有些场景还是应该推动后端改,别硬扛。

如果 ID 是「整个系统的核心唯一标识」,出现在几乎所有接口里,那让后端序列化成字符串是干净得多的做法。前端做统一转换是个「补丁」,只要有人绕过你的工具函数,问题就会再次出现。一个团队的所有 API 调用都走封装,这本身就需要纪律。

另外,如果团队用的是 TypeScript,后端返回的 ID 类型在声明文件里写成了 number,那你前端拿到 BigInt 之后,所有类型标注都得改。这是个牵一发动全身的活,值不值得做要看项目规模。

比较务实的判断标准:如果超长 ID 只是少数几个接口的问题,用前端方案快速补上;如果是全站普遍现象,那还是从后端改字符串更值得。

落在最后

JSON.parse 的 source 参数其实是个挺小的改动——只是给 reviver 多塞了一个参数。但因为它出现在「解析」这个所有数据进入 JS 的必经之路上,能解决的问题却很硬。

对大多数前端项目来说,把 parseWithBigInt 这个工具函数往 utils 里一放,配上封装好的请求库,需要的地方调一下就完了。不用引入依赖,不用和后端扯皮,出错的可能性也小得多。

如果你维护的项目里也出现过「ID 到了前端就变样」这类事故,不妨先把这些代码收进去试试。踩过几次坑之后,自然就知道该怎么改了。

大整数精度丢失不用改后端了:JSON.parse 的 source 参数实战
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript 大整数精度丢失不用改后端了:JSON.parse 的 source 参数实战 https://www.taomawang.com/web/javascript/2797.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务