先说答案
- chrome浏览器运行JavaScript时用的是V8,其使用64位的双精度浮点数表示整数,大于
Number.MAX_SAFE_INTEGER:9007199254740991的数会失去精度。 - axios会默认调用JSON.parse方法解析接口返回的json数据,而JSON.parse也是在V8上实现的,也存在失去精度问题
- 修改axios的
request config的transitional.forcedJSONParsing属性取消自动执行JSON.parse操作,手动解析JSON字符串。具体跳转[👉👉👉]
情景复现
今天遇到到神奇问题,浏览器和httpdebugger返回的值抓接口返回的不一样
数据实际是:
20250117154158614
浏览器的解析20250117154158616
httpdebugger解析:20250117154158614
处理:服务端转化数据为string
问题就是,数字 20250117154158614 被浏览器解析成了 20250117154158616
经验证,node也存在该问题,也能理解,毕竟node是基于v8的
问题原因分析
基础知识:
- 浏览器使用的双精度浮点数表示的整数,遵循的是IEEE 754标准
- float 64的构成为:1位符号位 + 11位偏移位 + 52 位尾数位
- IEEE 754标准 会有舍入操作
计算步骤
20250117154158614 二进制表示为
1000111111100010101111111001111101000100011100000010110 -- 长度为55
转换为科学计数法为
1.000111111100010101111111001111101000100011100000010110 * 2^54
计算一下偏移位:
54 + 1023 = 1077 = 10000110101(B)
由于是整数,则符号位为0,则20250117154158614的浮点数表示为
0 10000110101 000111111100010101111111001111101000100011100000010110 -- 长度为66
由于float 64 只有64位,截断最后两位
0 10000110101 0001111111000101011111110011111010001000111000000101 -- 长度为64
由于是正数,遵循IEE754的舍入原则,向正无穷舍入,最后一位 +1,就变成了
0 10000110101 0001111111000101011111110011111010001000111000000110 -- 长度位64
接下来将得到的浮点数再转换回整数为
1.0001111111000101011111110011111010001000111000000110 * 2^54 (这里不用解释为啥是2的54次方了吧)
等于
1000111111100010101111111001111101000100011100000011000 -- 长度为55
等于
20250117154158616
触发时机
先看个好玩的:
再看个攒劲的:
然后再再看一下axios的源码的一部分
看到这,估计就能才想到到为啥应用中拿到的值失去精度了--->开始怀疑JSON.parse()
💡但是为啥查看请求的返回结果时, Response和Preview的Tab展示的结果不一样呢
再看一下 Chrome DevTools的,“查看Response的Preview”的 JSONView 的源码:
再看下 "查看Response原始数据" 的ResourceSourceFrame 和 SourceFrame的源码
调用栈有点长,尽量截图吧
插个题外话:欸嘿,有点意思啊,第一张图的意思是如果是流式渲染的话,它也能实时更新结果?试一把
还真是的!!!!致敬这群大神
PS:这玩意儿我也不知道咋调试,一路chatGPT一路查过来的,希望没有定位错(定位错了就当没看见😛😛😛)
See See V8
都已经翻了chrome源码了,不去翻翻v8的源码说得过去吗?
template <typename Char>
Handle<Object> JsonParser<Char>::ParseJsonNumber() {
double double_number;
int smi_number;
if (ParseJsonNumberAsDoubleOrSmi(&double_number, &smi_number)) {
return factory()->NewHeapNumber(double_number);
}
return handle(Smi::FromInt(smi_number), isolate_);
}
template <typename Char>
bool JsonParser<Char>::ParseJsonNumberAsDoubleOrSmi(double* result_double,
int* result_smi) {
// ...
*result_double =
StringToDouble(chars,
NO_CONVERSION_FLAG, // Hex, octal or trailing junk.
std::numeric_limits<double>::quiet_NaN());
// ...
}
后面就不翻了翻不动了,最后翻到了github.com/v8/v8/blob/…的方法
其实这里已经能看出来了,最终存储数据的是double类型,即64位双精度浮点数,就会因为位数不够导致最开始说的计算的失去精度。
收~!
💡总之 初步判定!!!
JSON.parse的问题,也是V8的问题
其他离谱的问题
> BigInt(20250117154158614)
< 20250117154158616n
> BigInt(20250117154158614) >= 20250117154158615
< true
> BigInt(20250117154158614) >= BigInt(20250117154158615)
< true
嗯。。。搞是真的搞,其实也好理解,传递给bigInt 20250117154158614时,JavaScript会失去精度成了20250117154158616,而传递字符串就不会有这个问题。
至于为啥会这样,是真的不想翻了,网上也都说了V8存整数用的都是64位双精度浮点数存的。
前端解决方案
axios请求得到返回结果的时候,会根据request config中transitional.forcedJSONParsing属性判断是否执行JSON.parse,在请求时的config中设置transitional属性,然后手动解析json字符串即
当然这样会影响到所有的数字字段,把它转成了字符串。
如果想降低影响,可以设置replace函数的回调,例如只将大于Number.MAX_SAFE_INTEGER:9007199254740991的数改成字符串
const threshold = BigInt(Number.MAX_SAFE_INTEGER);
const data = res.data.replaceAll(/"(\w+)":\s*(-?\d+(.\d+)?)/g, (match, key, value) => {
value = value.split('.')[0]
if (value) {
const bigIntValue = BigInt(value);
if (bigIntValue > threshold) {
// 将数值转换为字符串类型
return `"${key}": "${value}"`;
}
}
// 返回原始匹配项
return match;
})
两种方法在速度上的比较,以返回一万条{`id${1-10000}`: 20250117154158614}数据为例
| 不使用回调-耗时(ms) | 使用回调-耗时(ms) | |
|---|---|---|
| 1 | 1.31494140625 | 9.7060546875 |
| 2 | 1.656982421875 | 16.783203125 |
| 3 | 1.3740234375 | 8.81689453125 |
| 4 | 1.330078125 | 9.63916015625 |
| 5 | 1.302001953125 | 14.06201171875 |
| 6 | 1.38720703125 | 14.30810546875 |
| 7 | 1.69580078125 | 8.489013671875 |
| 8 | 2.81298828125 | 11.198974609375 |
| 9 | 1.595947265625 | 13.48095703125 |
| 10 | 1.632080078125 | 8.4072265625 |
| average | 1.7445088421875 | 11.49825015625 |
使用回调的方案耗时会存在波动,可能是因为一直要申请内存空间吧。至于哪个方案,看大佬们选择了。
The End~~~★,°*:.☆( ̄▽ ̄)/$:.°★ 。 撒花
抽风仅一次,狗命要紧