去年年末碰到一个挺典型的线上问题。用户点了「查看订单详情」,页面弹出来一个「订单不存在」。查后端日志,请求收到了,数据库里也有这条记录,返回的 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.js、decimal.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 到了前端就变样」这类事故,不妨先把这些代码收进去试试。踩过几次坑之后,自然就知道该怎么改了。

