某天下午,阳光正好,群消息突然闪烁。点开一看,后端同学甩来两张截图:
A 图是某个列表接口的返回,数据结构大致如下:
{ ..., data: [ { id: 1234567890123456789, // 后端通过算法生成的19位自增id ... }, ... ]}
B 图则是前端拿着其中某个 id 去请求详情时接口报错的截图,请求参数长这样:
接口地址/xxx?id=1234567890123456800 // 注意:这里的 id 和 原id 后几位不一致
嗯?什么情况?!
一般来说,前端不会对 id 这类字段做特殊处理,查询时都是直接透传给后端,怎么会发生变化?
于是我开始排查:
- 打开开发者工具的 Network 面板,通过 Preview 查看接口返回,发现 id 显示为
1234567890123456800
—— 咦,没毛病啊,B 图的入参就是这个值!可为什么和后端截图里的返回值不一致? - 接着我点开 Response 查看原始返回数据,发现 id 其实是
1234567890123456789
。 - 直觉告诉我:有问题!平常的业务场景很少涉及这么大的数字。查了一下发现,JavaScript 的安全整数范围其实是有限制的:
> 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数据已经被序列化,此时的大数字已经精度丢失。
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
时会报错:
axiosInstance.interceptors.response.use( (resp) => { resp.data.toString() // 报错:toString is not a function // ... }, (error) => { ... })
什么?resp.data
明明有值,却没有 toString
方法?
坑点分析#
反复对比转换前后的 resp.data
,我们发现:经过 json-bigint
解析后的对象,竟然没有原型(prototype)!
翻看其源码发现:
// 摘自:https://github.com/sidorares/json-bigint/blob/master/lib/parse.jsobject = Object.create(null) // 使用这种方式创建对象,会丢失原型链
原来,作者为了预防「原型污染」,在 1.0.0 版本中将对象创建方式从 {}
改为了 Object.create(null)
,导致转换后的对象不再继承 Object.prototype
。
解决方案#
有两种方式可以应对:
-
版本降级:将
json-bigint
降级至 0.4.0 版本(该版本仍使用{}
创建对象)。
👉 相关 GitHub Issue -
保持当前版本,手动恢复原型:我们编写了一个工具函数,递归地为对象恢复原型对象:
(以下为恢复原型的工具函数,可根据需要引用)
/** * 原型恢复工具函数 * 用于修复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;}
小结一下:
处理上述问题并不难,关键是要注意 json-bigint
在 v1.0.0 后的行为变化,以及如何在尽量少侵入业务的情况下平稳落地解决方案。
如果你也遇到了类似问题,希望这篇分享能帮到你。如果有更好的处理方式,欢迎交流讨论!