后端问为什么前端数值精度会丢失?

前言

欢迎关注同名公众号《熊的猫》,文章会同步更新,也可快速加入前端交流群!

相信各位前端小伙伴在日常工作中不免会涉及到使用 JavaScript 处理 数值 相关的操作,例如 数值计算保留指定小数位接口返回数值过大 等等,这些操作都有可能导致原本正常的数值在 JavaScript 中确表现得异常(即 精度丢失 ),这也是被很多开发者诟病的一点(你该不会还没踩过坑吧! ),当然包括很多 后端开发者不止一次的被问到这个问题)。

本文主要包含 精度丢失场景、精度丢失原因、解决方案 等方面的内容,文中若有不正确的地方欢迎在评论区分享你的见解。

精度丢失场景

浮点数的计算

数值计算在前端的应用还不算少,但涉及 浮点数 参与计算时可能会出现精度丢失,如下:

加( + )

  • 正常计算:0.1 + 0.2 = 0.3
  • JavaScript 计算:0.1 + 0.2 = 0.30000000000000004

减( - )

  • 正常计算:1 - 0.9 = 0.1
  • JavaScript 计算:1 - 0.9 = 0.09999999999999998

乘( * )

  • 正常计算:0.0532 * 100 = 5.32
  • JavaScript 计算:0.0532 * 100 = 5.319999999999999

除( / )

  • 正常计算:0.3 / 6 = 0.05
  • JavaScript 计算:0.3 / 6 = 0.049999999999999996

超过最值

所谓 超过最值(最大、最小值 指的是超过了 Number.MIN_SAFE_INTEGER(- 9007199254740991),即 +(2^53 -- 1)Number.MAX_SAFE_INTEGER(+ 9007199254740991),即 -(2^53 -- 1) 范围的值,项目中最常见的就是如下几种情况:

  • 后端返回的数值超过最值

    • 例一,后端返回的列表数据,通常都会有相应的 ID 来标识唯一性,但后端字啊生成这个 ID 时是 Long 类型 ,那么该值很可能就会超过 JavaScript 中能表示的最大正整数,此时就导致精度丢失,即前端实际获取到的 ID 值和后端返回的将不一致
    • 例二,后端可能需要将一些值通过计算之后,把对应的结果值返回给前端,此时若该值超过了 最值,那么也会产生精度丢失
  • 前端进行数值计算时,计算结果超过最值

保留指定小数位

除了上述对涉及浮点数计算、超过最值的场景之外,我们通常还会对数值进行保留指定小数位的处理,而部分开发者可能会直接使用 Number.prototype.toFixed 来实现,但这个方法却并不能保证我们期望的效果,例如保留小数位时需要进行 四舍五入 时就会有问题,如下:

js 复制代码
console.log(1.595.toFixed(2)) // 1.59 ------> 期望为:1.60
console.log(1.585.toFixed(2)) // 1.58 ------> 期望为:1.59
console.log(1.575.toFixed(2)) // 1.57 ------> 期望为:1.58
console.log(1.565.toFixed(2)) // 1.56 ------> 期望为:1.57
console.log(1.555.toFixed(2)) // 1.55 ------> 期望为:1.56
console.log(1.545.toFixed(2)) // 1.54 ------> 期望为:1.55
console.log(1.535.toFixed(2)) // 1.53 ------> 期望为:1.54
console.log(1.525.toFixed(2)) // 1.52 ------> 期望为:1.53
console.log(1.515.toFixed(2)) // 1.51 ------> 期望为:1.52
console.log(1.505.toFixed(2)) // 1.50 ------> 期望为:1.51

精度丢失的原因

计算机内部实际上只能 存储/识别 二进制 ,因此 文档、图片、数字 等都会被转换为 二进制 ,而对于数字而言,虽然我们看到的是 十进制 的表示结果,但实际上会底层会进行 十进制二进制 的相互转换,而这个转换过程就有可能会出现 精度丢失 ,因为十进制转二进制后可能产生 无限循环 部分,而 实际存储空间是有限的

IEEE 754 标准

Javascript 中的数字存储使用了 IEEE 754 中规定的 双精度浮点数 数据类型,双精度浮点数使用 64 位(8 字节) 来存储一个 浮点数,可以表示二进位制的 53 位 有效数字,即 (0-52 位为 1) 111...111 = (53 位为 1,0-52 位为 0) 1000...000 - 1 ,也就是 2^53 - 1 ,而这也就是 JavaScript 中 Number.MAX_SAFE_INTEGER(+ 9007199254740991) 对应的值。

双精度浮点数的组成

双精度浮点数(double) 由如下几部分组成:

  • sign 符号位,0 为正,1 为负

    • 占 1bit,在 63 位
  • exponent 指数部分,表示 2 的几次方

    • 占 11bit,在 52-62 位
    • 指数 采用 偏移码表示法 ,即将 指数的真实值 e 加上一个 偏移量 ,然后得到 阶码 (即计算结果)并将其表示为 二进制数
    • 其中 偏移量 = (2^n-1) - 1n指数的位数(即 n = 11 ,因此偏移量为 Math.pow(2, 11-1) - 1 = 1023
    • 阶码 E = 指数真值 e + 偏移码 (2^n-1) - 1
  • mantissa 尾数部分,表示浮点数的精度

    • 占 52bit,在 0-51 位

    • 尾数采用 隐式 的方式表示,即在尾数的 最高位上 总是隐含着一个 1 ,并且隐藏在 小数 点的左边 (即 1 < 尾数 < 2 ),因此尾数的有效位数为 53 位,而不是 52

十进制浮点数的存储过程

有了上面的公式,接下来我们来演示一下一个十进制浮点数是如何以 双精度浮点数 的形式被存储到计算机中的,其大致分为如下两步:

  • 十进制转二进制
    • 分别对整数部分和小数部分的十进制转化为二进制
  • 求出 sign、exponent、mantissa 的值

下面我们通过 263.3 这个数值来演示。

十进制转二进制

分别将 263.3整数部分 263小数部分 0.3 转为对应的 二进制数 ,这里你可以使用便捷的 在线转换工具,也可选择手动计算:

  • 整数部分二进制

    • 一直 除以 2 直到余数为 0出现循环 ,然后 从下往上 将每次的余数进行组合即可
  • 小数部分二进制

    • 一直 乘以 2 直到乘积为 1出现循环 ,然后 从上往下 将每次的乘积的 整数位 进行组合即可

    最终得到的结果就是 263.3(10) 对应的 二进制100000111.010011001...

求出 sign、exponent、mantissa 的值

  • sign
    • 其中 sign 为符号位,且 263.3(10) 为正数,因此 sign = 0
  • exponent
    • 根据公式 (-1)^S x (1. M) x 2^(E-1023) 可知,其中的 尾数 要符合 1. M 的形式,因此 100000111.010011001... 中小数点需要往左移动 8位 变成 1.00000111 010011001 ...
    • 其中的 8 就是 指数真值 ,但在实际存储时是存 阶码的二进制 ,根据公式 阶码 = 指数真值(8) + 偏移量(1023) ,即 阶码 = 1031 ,所以 exponent 值就为 1031 的二进制:10000000111
  • mantissa
    • 根据上一步的 1.00000111 010011001 ... 很容易知道尾数 mantissa = 00000111 010011001 ...

最终存储形式

Number.prototype.toFixed 的舍入

关于这个方法的舍入方式,目前最多的说法就是 银行家算法 ,的确在大多情况下确实能够符合 银行家算法 的规则,但是部分情况就并不符合其规则,因此严格意义上来讲 Number.prototype.toFixed 并不算是使用了 银行家算法 ,如果你要问为什么,请看 ECMAScript® 2024 Language Specification (tc39.es),在下文都会提及。

银行家算法

所谓银行家算法用一句话概括为:

四舍六入五考虑,五后 有数 就进一五后 无数 看 奇偶五前 为偶当 舍去五后 为奇要 进一

  • 四舍 指保留位后的 数值 < 5舍去4 只是个代表值
  • 六入 指保留位后的 数值 > 5进一6 只是个代表值
  • 若保留位后的 数值 = 5 ,看 5 后 是否有数
    • 5 后 无数 ,则看 5 前 的数值的 奇偶 来判断
      • 5 前 的数值为 偶数 ,则 舍去
      • 5 前 的数值为 奇数 ,则 进一
    • 5 后 有数 ,则 进一

用例子来验证一下:

js 复制代码
// 四舍
(1.1341).toFixed(2) = '1.13'

// 六入
(1.1361).toFixed(2) = '1.14'

// 五后 有数 ,进一
(1.1351).toFixed(2) = '1.14'

// 五后 无数,看奇偶,五前为 3 奇数,进一 
(1.1350).toFixed(2) = '1.14'

// 五后 无数,看奇偶,五前为 0 偶数,舍去
(1.1250).toFixed(2) = '1.13'

看起来没有问题是吧!

js 复制代码
// 五后 有数,应进一
(1.1051).toFixed(2) = 1.11 (正确 √)
(1.105).toPrecision(17) = '1.1050000000000000' // 精度

// 五后 无数,看奇偶,五前为 0 偶数,应舍去
(1.105).toFixed(2) = 1.10 (正确 √)

// 五后 无数,看奇偶,五前为 2 偶数,应舍去
(1.125).toFixed(2) = 1.13 (不正确 ×)
1.125.toPrecision(17) = '1.1250000000000000' // 精度

// 五后 无数,看奇偶,五前为 4 偶数,应舍去
(1.145).toFixed(2) = 1.15 (不正确 ×)
1.145.toPrecision(17) = '1.1450000000000000' // 精度

// 五后 无数,看奇偶,五前为 6 偶数,应舍去
(1.165).toFixed(2) = 1.17 (不正确 ×)
1.165.toPrecision(17) = '1.1650000000000000' // 精度

// 五后 无数,看奇偶,五前为 8 偶数,应舍去
(1.185).toFixed(2) = 1.19 (不正确 ×)
1.185.toPrecision(17) = '1.1850000000000001' // 精度

ECMAScript 定义的 toFixed 标准

一眼望上去是不是觉得看不懂,那么这里就来尝试解释一下这个标准的内容吧(掺杂个人理解)!

  1. x = 目标数字 ,如:(1.145).toFixed(2)x = 1.1245

  2. f = 参数 ,如:(1.145).toFixed(2)f = 2

  3. f = undefined ,即 未传参 ,则将 f = 0

  4. f = Infinite ,即传入了 无穷值 ,则抛出 RangeError 异常

  5. f < 0 或 f > 100 ,即传入了不在 0 - 100 之间的值,则抛出 RangeError 异常

  6. x = Infinite ,即想要对 非准确值 保留位操作,则返回其 字符串形式

    • 例如,Infinity.toFixed(2) = 'Infinity'NaN.toFixed(2) = 'NaN'
  7. x = 计算机所能表示的数学值 ℝ(x)

    • 数字BigInt x数学值 的转换表示为 x 的数学值 ,或 ℝ(x)
  8. 返回值符号 s = '' ,即为符号定义 初始值

  9. x < 0 ,则将 s = '-' ,并将 x = -x

  10. x ≥ 10^21 ,则 返回值 m = x 对应的科学计数法 表示的 字符串

  11. x < 10^21,则

    a. 让 n = 一个整数 ,其中 n / 10^f - x 尽可能接近于 0 ,如果有两个这样的 n ,选择 较大的 n

    b. 若 n = 整数 0 ,则 m = "0" ,否则,m = 由 n十进制 表示形式的数字组成的 字符串值(按顺序,不带前导零)

    c. 若 指数 f ≠ 0 ,则 k = m.length

    • k ≤ f ,则
      • z = 由代码单元 0x0030(DIGIT ZERO)f+1-k 次出现组成的 字符串
      • m = z + m
      • k = f + 1
    • a = m 的第一个 k-f 码单元
    • b = m 的其它 f 个编码单元
    • m = a + "." + b
  12. 返回 s + m 组成的字符串

看不懂?那就挑懂的地方看

不多说了,还是用 (1.125).toFixed(2) = 1.13 举个栗子吧!

  • 根据上述规范初始 x = 1.125,f = 2,s = ''
  • 根据规范 7 可知 x = 1.125.toPrecision(53) = 1.125
  • 根据规范 11.a 提供的公式:n / 10^f - x ≈ 0 代入计算:n ≈ 112.5
    • 此时最接近 n整数两个 值为 110112 ,按标准取最大的 113
    • 在按 11.c 的规范得到 m = 1.13
  • 最终返回 s + m= 1.13

还不会,再来个 (-1.105).toFixed(2) = -1.10 的栗子吧!

  • 根据上述规范初始 x = 1.105,f = 2,s = '-'
  • 根据规范 7 可知 x = (-1.105).toPrecision(53) = 1.10499...
  • 根据规范 11.a 提供的公式:n / 10^f - x ≈ 0 代入计算:n ≈ 110.4...
    • 此时最接近 n整数 只有 一个 值为 110 (因为只有小数点后为 5 时,向上 / 向下 取整才会有两种情况)
    • 在按 11.c 的规范得到 m = 1.10
  • 最终返回 s + m= -1.13

如何解决前端数值的精度问题?

虽然知道了 精度丢失 的原因,也知道了 toFixed 舍入 的逻辑,但是实际上在进行计算时,我们还是希望按照实际看到的数值来进行计算或舍入,而不是底层转换过的值。

使用第三方库

需要的自行查阅:

思路扩展

浮点数计算

浮点数在 JavaScript 中经底层转换后可能会有精度丢失,但是 安全范围内的整数 却不会丢失,那么我们就可以先将 浮点数 转成 整数 进行计算后,再将计算结果成为浮点数。

0.1 + 0.2 = 0.30000000000000004 举个例子,如下:

  • 原式:0.1 + 0.2 = x
  • 扩大 10 倍:0.1 * 10 + 0.2 * 10 = 10 * x
  • 变式:10 * x = 3
  • 结果:x = 0.3

超过最值

前面提到的 后端返回前端计算 产生的超过 安全范围的值 ,我们可以使用 BigInt 来处理,这是新增的原始值类型,它提供了一种方法来表示 大于 2^53 - 1 的整数

保留指定小数位

既然 Number.prototype.toFixed() 的舍入方法并不是我们需要的,那么我们可以直接将其重写成符合的即可,例如:

js 复制代码
Number.prototype.toFixed=function (d) { 
    var s=this+""; 
    if(!d)d=0; 
    if(s.indexOf(".")==-1)s+="."; 
    s+=new Array(d+1).join("0"); 
    if(new RegExp("^(-|\\+)?(\\d+(\\.\\d{0,"+(d+1)+"})?)\\d*$").test(s)){
        var s="0"+RegExp.$2,pm=RegExp.$1,a=RegExp.$3.length,b=true;
        if(a==d+2){
            a=s.match(/\d/g); 
            if(parseInt(a[a.length-1])>4){
                for(var i=a.length-2;i>=0;i--){
                    a[i]=parseInt(a[i])+1;
                    if(a[i]==10){
                        a[i]=0;
                        b=i!=1;
                    }else break;
                }
            }
            s=a.join("").replace(new RegExp("(\\d+)(\\d{"+d+"})\\d$"),"$1.$2");
 
        }if(b)s=s.substr(1); 
        return (pm+s).replace(/\.$/,"");
    }return this+"";
 
}

最后

欢迎关注同名公众号《熊的猫》,文章会同步更新,也可快速加入前端交流群!

以上就是本文的全部内容了,由于涉及到部分内容 计网 相关内容,所以可能理解起来会比较吃力,但是跨过这道坎也就没那么难理解了。

希望本文对你有所帮助!!!

相关推荐
dr李四维10 分钟前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
ifanatic11 分钟前
[面试]-golang基础面试题总结
面试·职场和发展·golang
I_Am_Me_24 分钟前
【JavaEE进阶】 JavaScript
开发语言·javascript·ecmascript
雯0609~31 分钟前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ34 分钟前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z40 分钟前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
程序猿进阶1 小时前
堆外内存泄露排查经历
java·jvm·后端·面试·性能优化·oom·内存泄露
前端百草阁1 小时前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜1 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4041 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html