javascript的大整数失去精度问题 -- 从chrome v8源码看起

393 阅读6分钟

先说答案

  1. chrome浏览器运行JavaScript时用的是V8,其使用64位的双精度浮点数表示整数,大于Number.MAX_SAFE_INTEGER:9007199254740991的数会失去精度。
  2. axios会默认调用JSON.parse方法解析接口返回的json数据,而JSON.parse也是在V8上实现的,也存在失去精度问题
  3. 修改axios的request configtransitional.forcedJSONParsing属性取消自动执行JSON.parse操作,手动解析JSON字符串。具体跳转[👉👉👉]

情景复现

今天遇到到神奇问题,浏览器和httpdebugger返回的值抓接口返回的不一样

数据实际是:

20250117154158614

浏览器的解析20250117154158616

httpdebugger解析:20250117154158614

处理:服务端转化数据为string

问题就是,数字 20250117154158614 被浏览器解析成了 20250117154158616

经验证,node也存在该问题,也能理解,毕竟node是基于v8的

问题原因分析

基础知识:

  1. 浏览器使用的双精度浮点数表示的整数,遵循的是IEEE 754标准
  2. float 64的构成为:1位符号位 + 11位偏移位 + 52 位尾数位
  3. 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 configtransitional.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)
11.314941406259.7060546875
21.65698242187516.783203125
31.37402343758.81689453125
41.3300781259.63916015625
51.30200195312514.06201171875
61.3872070312514.30810546875
71.695800781258.489013671875
82.8129882812511.198974609375
91.59594726562513.48095703125
101.6320800781258.4072265625
average1.744508842187511.49825015625

使用回调的方案耗时会存在波动,可能是因为一直要申请内存空间吧。至于哪个方案,看大佬们选择了。

The End~~~★,°*:.☆( ̄▽ ̄)/$:.°★ 。 撒花

抽风仅一次,狗命要紧