当接口返回Bigint:前端精度丢失排查与解决之旅
某天下午,阳光正好,群消息突然闪烁。点开一看,后端同学甩来两张截图:
A 图是某个列表接口的返回,数据结构大致如下:
json
{
...,
data: [
{
id: 1234567890123456789, // 后端通过算法生成的19位自增id
...
},
...
]
}
B 图则是前端拿着其中某个 id 去请求详情时接口报错的截图,请求参数长这样:
txt
接口地址/xxx?id=1234567890123456800 // 注意:这里的 id 和 原id 后几位不一致
嗯?什么情况?!
一般来说,前端不会对 id 这类字段做特殊处理,查询时都是直接透传给后端,怎么会发生变化?
于是我开始排查:
- 打开开发者工具的 Network 面板,通过 Preview 查看接口返回,发现 id 显示为
1234567890123456800
------ 咦,没毛病啊,B 图的入参就是这个值!可为什么和后端截图里的返回值不一致? - 接着我点开 Response 查看原始返回数据,发现 id 其实是
1234567890123456789
。 - 直觉告诉我:有问题!平常的业务场景很少涉及这么大的数字。查了一下发现,JavaScript 的安全整数范围其实是有限制的:
js
> Number.MAX_SAFE_INTEGER
< 9007199254740991 // 16位
一旦数字超过 MAX_SAFE_INTEGER
,JSON.parse
在序列化时就会出现精度丢失。而开发者工具的 Preview 面板正是通过 JSON.parse
来处理 JSON 数据的。
- 结论很明显:后端返回的 id 已经超出了 JavaScript 的安全整数范围。
(此处省略前后端友好交流环节...🤝)
接下来介绍:前端如何处理接口中的 Bigint
(PS:最稳妥的方式当然是后端在序列化时就把这类数字转为字符串。但总有各种原因需要前端接手,比如历史项目、临时方案等...你懂的。)
我们的方案并不复杂,但也踩了坑,一并分享给大家:
方案背景
- 项目基于 axios 封装了统一的 http 模块,为了控制影响范围,最好在其基础上做适配。
- 对于超出安全范围的 long 类型数字,通常有两种处理方式:
- 转为
BigInt
- 转为字符串
- 转为
综合考虑后,我们选择了使用 json-bigint
这个库,它能灵活支持以上两种转换方式。
鉴于我们项目中目前只有 id 字段会出现大数,最终选择了「转为字符串」方案。
如果你考虑使用
BigInt
,请务必了解其在各浏览器中的兼容性,并注意BigInt
与普通Number
在运算和类型判断上的差异。有关 BigInt 的详细说明可参考:MDN - BigInt
具体实现
我们在 axios 的 transformResponse
阶段介入处理:
为何不在response拦截器做处理? axois拦截器阶段,json数据已经被序列化,此时的大数字已经精度丢失。
js
import JSONbig from 'json-bigint'
axios.create({
...,
transformResponse: [
function(data, headers) {
if (typeof data !== 'string') return data
if (!data.trim()) return data
try {
const JSONbigParser = JSONbig({
storeAsString: true, // 将大数转为字符串
useNativeBigInt: false,
alwaysParseAsBig: false,
strict: false
})
const parsed = JSONbigParser.parse(data)
// 注意:这里有坑!下文详解
return parsed
} catch(err) {
try {
return JSON.parse(data)
} catch (callbackError) {
return data
}
}
}
]
})
调试之后,确认接口返回中的大数字段已被正确转为字符串。
是不是觉得......有点太顺利了?
But...
随后我们发现,在 axios 的响应拦截器 interceptors.response
中,处理 resp.data
时会报错:
js
axiosInstance.interceptors.response.use(
(resp) => {
resp.data.toString() // 报错:toString is not a function
// ...
},
(error) => { ... }
)
什么?resp.data
明明有值,却没有 toString
方法?
坑点分析
反复对比转换前后的 resp.data
,我们发现:经过 json-bigint
解析后的对象,竟然没有原型(prototype)!
翻看其源码发现:
js
// 摘自:https://github.com/sidorares/json-bigint/blob/master/lib/parse.js
object = Object.create(null) // 使用这种方式创建对象,会丢失原型链
原来,作者为了预防「原型污染」,在 1.0.0 版本中将对象创建方式从 {}
改为了 Object.create(null)
,导致转换后的对象不再继承 Object.prototype
。
解决方案
有两种方式可以应对:
-
版本降级 :将
json-bigint
降级至 0.4.0 版本(该版本仍使用{}
创建对象)。 -
保持当前版本,手动恢复原型:我们编写了一个工具函数,递归地为对象恢复原型对象:
(以下为恢复原型的工具函数,可根据需要引用)
js
/**
* 原型恢复工具函数
* 用于修复json-bigint解析后对象缺失原型的问题
*/
/**
* 递归恢复对象原型链
* json-bigint使用Object.create(null)创建纯对象,导致缺失Object.prototype方法
* @param obj 需要恢复原型的对象
* @returns 恢复原型后的对象
*/
export function restorePrototype(obj: any): any {
// 基本类型和null直接返回
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 如果已经有原型,说明不需要恢复
if (Object.getPrototypeOf(obj) !== null) {
// 但仍需要递归处理子对象
if (Array.isArray(obj)) {
return obj.map(item => restorePrototype(item));
} else {
const restored: any = {};
for (const key in obj) {
if (obj.hasOwnProperty && obj.hasOwnProperty(key)) {
restored[key] = restorePrototype(obj[key]);
} else if (Object.prototype.hasOwnProperty.call(obj, key)) {
// 对于没有hasOwnProperty方法的对象,使用Object.prototype.hasOwnProperty
restored[key] = restorePrototype(obj[key]);
}
}
return restored;
}
}
// 处理数组
if (Array.isArray(obj)) {
// 创建新数组并恢复每个元素的原型
return obj.map(item => restorePrototype(item));
}
// 处理普通对象:创建新对象并恢复原型
const restored: any = {};
// 复制所有属性(包括不可枚举的)
const keys = Object.getOwnPropertyNames(obj);
for (const key of keys) {
const descriptor = Object.getOwnPropertyDescriptor(obj, key);
if (descriptor) {
// 递归恢复属性值的原型
const restoredValue = restorePrototype(descriptor.value);
Object.defineProperty(restored, key, {
...descriptor,
value: restoredValue
});
}
}
return restored;
}
/**
* 检查对象是否缺失原型
* @param obj 要检查的对象
* @returns 是否缺失原型
*/
export function isMissingPrototype(obj: any): boolean {
return obj !== null &&
typeof obj === 'object' &&
Object.getPrototypeOf(obj) === null;
}
/**
* 安全地调用对象方法,如果方法不存在则使用默认实现
* @param obj 目标对象
* @param methodName 方法名
* @param defaultImpl 默认实现
* @param args 方法参数
* @returns 方法调用结果
*/
export function safeMethodCall<T>(
obj: any,
methodName: string,
defaultImpl: (...args: any[]) => T,
...args: any[]
): T {
if (obj && typeof obj[methodName] === 'function') {
return obj[methodName](...args);
}
return defaultImpl.call(obj, ...args);
}
/**
* 深度恢复对象原型(递归处理所有嵌套对象)
* @param obj 要处理的对象
* @returns 处理后的对象
*/
export function deepRestorePrototype(obj: any): any {
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 处理数组
if (Array.isArray(obj)) {
return obj.map(item => deepRestorePrototype(item));
}
// 处理普通对象
let result = obj;
// 如果缺失原型,恢复它
if (isMissingPrototype(obj)) {
result = restorePrototype(obj);
}
// 递归处理所有属性
for (const key in result) {
if (Object.prototype.hasOwnProperty.call(result, key)) {
result[key] = deepRestorePrototype(result[key]);
}
}
return result;
}
小结一下 :
处理 BigInt 精度问题并不难,关键是要注意 json-bigint
在 v1.0.0 后的行为变化,以及如何在尽量少侵入业务的情况下平稳落地解决方案。
如果你也遇到了类似问题,希望这篇分享能帮到你。如果有更好的处理方式,欢迎交流讨论!