记录--前端金额运算精度丢失问题及解决方案

这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助

前言

前端开发中难免会遇到价格和金额计算的需求,这类需求所要计算的数值大多数情况下是要求精确到小数点后的多少位。但是因为JS语言本身的缺陷,在处理浮点数的运算时会出现一些奇怪的问题,导致计算不精确。

本文尝试从现象入手,分析造成这一问题原因,并总结和整合一些通用的解决方案,以供大家参考。

现象回顾

下面的是JS进行数值运算过程中常见的问题,这个问题有个专业的名称叫精度丢失

在 JavaScript 中整数和浮点数都属于 Number 数据类型,所有的数字都是以 64 位浮点数形式存储,整数也是如此。所以我们在打印 1.00 这样的浮点数的结果是 1 而非 1.00 。在一些特殊的数值表示中,例如金额,这样看上去有点别扭,但是至少值是正确了。然而要命的是,当浮点数做数学运算的时候,你经常会发现一些问题,举几个例子:

// 加法
0.1 + 0.2 = 0.30000000000000004
0.7 + 0.1 = 0.7999999999999999
0.2 + 0.4 = 0.6000000000000001
2.22 + 0.1 = 2.3200000000000003

// 减法
1.5 - 1.2 = 0.30000000000000004
0.3 - 0.2 = 0.09999999999999998

// 乘法
19.9 * 100 = 1989.9999999999998
19.9 * 10 * 10 = 1990
1306377.64 * 100 = 130637763.99999999
1306377.64 * 10 * 10 = 130637763.99999999
0.7 * 180 = 125.99999999999999
9.7 * 100 = 969.9999999999999
39.7 * 100 = 3970.0000000000005

// 除法
0.3 / 0.1 = 2.9999999999999996
0.69 / 10 = 0.06899999999999999

问题分析

JavaScript 里的数字是采用 IEEE 754 标准的 64 位双精度浮点数。

该规范定义了浮点数的格式,对于 64 位的浮点数在内存中的表示,最高的 1 位是符号位,接着的 11 位是指数,剩下的 52 位为有效数字,具体对应如下:

  • 第 0 位:符号位, s 表示 ,0 表示正数,1 表示负数;
  • 第 1 位到第 11 位:储存指数部分, e 表示 ;
  • 第 12 位到第 63 位:储存小数部分(即有效数字),f 表示,

符号位决定了一个数的正负,指数部分决定了数值的大小,小数部分决定了数值的精度。

IEEE 754 规定,有效数字第一位默认总是 1,不保存在 64 位浮点数之中。也就是说,有效数字总是 1.xx…xx 的形式,其中 xx..xx 的部分保存在 64 位浮点数之中,最长可能为 52 位。

因此,JavaScript 提供的有效数字最长为 53 个二进制位(64 位浮点的后 52 位 + 有效数字第一位的 1)。

那么,JavaScript 在计算 0.1 + 0.2 时,到底发生了什么呢?

首先,十进制的 0.10.2 都会被转换成二进制,但由于浮点数用二进制表达时是无穷的,就成了下面的样子。

0.1 -> 0.0001100110011001...(无限)
0.2 -> 0.0011001100110011...(无限)
IEEE 754 标准的 64 位双精度浮点数的小数部分最多支持 53 位二进制位,所以两者相加之后得到的二进制就是下面的样子。
0.0100110011001100110011001100110011001100110011001100

最终,因浮点数小数位的限制而截断的二进制数字,再转换为十进制,就成了 0.30000000000000004,所以在进行算术计算时就产生了误差。

解决方案

通常情况下对计算精度要求高的业务场景都应该交给后端去计算和存储,因为后端有成熟的方案和工具来解决这种计算问题。

这里是对网上常见的几个解决方案的汇总和整理,但是方案一和方案二都存在一定的局限性,我会在对应的方案里进行说明。通常我们前端做这类运算通常只用于表现层,所以这两个方案基本是够用的。

方案一

在应对确定精度浮点数运算时,可以把金额转换成整数进行运算,最后再将结果转换成真实金额。

// 定义金额(保留两位小数)
const amount1 = 0.10;
const amount2 = 0.20;

// 转整数运算,并还原成真实金额
const result = ((amount1 * 100) + (amount2 * 100)) / 100;

// 结果
console.log(result); // 0.3

// 直接运算
console.log(amount1 + amount2); // 0.30000000000000004

需要注意的是上面的例子只能处理最多两位小数的运算场景,如果小数位不确定这个方法是行不通的。

方案二

JavaScript 内置的 toFixed() 方法可以将数字转换成保留指定小数位的字符串。这个方法适用于简单的金额计算。但需要注意舍入误差,因为转换后是字符串,失去了浮点数的特性,最后的结果坑你存在微小的误差。

// 定义金额
const amount1 = 0.1;
const amount2 = 0.2;

// 加法运算
const result = (amount1 + amount2).toFixed(2); // 保留两位小数

// 注意这里运算的结果应该是:0.30000000000000004
console.log(result); // 输出 "0.30"

toFixed 它是一个四舍六入五成双的诡异的方法(也叫银行家算法)。"四舍六入五成双"含义:对于位数很多的近似数,当有效位数确定后,其后面多余的数字应该舍去,只保留有效数字最末一位,这种修约(舍入)规则是“四舍六入五成双”,也即“4舍6入5凑偶”这里“四”是指≤4 时舍去,"六"是指≥6时进上,"五"指的是根据5后面的数字来定,当5后有数时,舍5入1;当5后无有效数字时,需要分两种情况来讲:5前为奇数,舍5入1;5前为偶数,舍5不进(0是偶数)。

第三方库

现代前端发展至今,已经有很多成熟的类库来帮助我们解决此类问题,这类类库通常有很好的通用性和兼容性。

下面我将推荐几个人气较高的数字计算类库。

Math.js

Math.js 是专门为 JavaScript 和 Node.js 提供的一个广泛的数学库。它具有灵活的表达式解析器,支持符号计算,配有大量内置函数和常量,并提供集成解决方案来处理不同的数据类型像数字,大数字 (超出安全数的数字),复数,分数,单位和矩阵,功能强大,易于使用。

官网:mathjs.org/

GitHub:GitHub - josdejong/mathjs: An extensive math library for JavaScript and Node.js

decimal.js

为 JavaScript 提供十进制类型的任意精度数值。

官网:decimal.js API

GitHub:GitHub - MikeMcl/decimal.js: An arbitrary-precision Decimal type for JavaScript

big.js

Big.js 是一个用于处理任意精度的大数运算的 JavaScript 库。它解决了 JavaScript 中处理大数运算时精度丢失的问题,提供了更高精度的计算能力。

Big.js 库的特点包括:

  • 任意精度:Big.js 允许您处理任意精度的数字,而不受 JavaScript 内置数字类型的限制。
  • 高精度计算:Big.js 提供了精确的加法、减法、乘法、除法和取余等运算,以及比较和舍入等功能。
  • 可配置的精度和舍入规则:您可以自定义 Big.js 运算的精度和舍入规则,以满足特定的需求。
  • 支持链式操作:您可以使用链式调用来执行多个运算,使代码更简洁易读。
  • 适用于浏览器和 Node.js:Big.js 可以在浏览器和 Node.js 环境中使用,兼容性良好。

Big.js 库非常适用于需要高精度计算的场景,如金融、密码学、科学计算和大数据处理等。它允许开发人员在 JavaScript 中进行准确的数字计算,避免了精度损失带来的问题。

官网:big.js API

GitHub:GitHub - MikeMcl/big.js: A small, fast JavaScript library for arbitrary-precision decimal arithmetic.

总结

本文对 Javascript 中浮点数运算出现的精度丢失问题进行了还原,分析了问题产生的原因在于二进制本身。同时给出了三个网络上比较成熟的解决方案,其中第一和第二方案基本可以满足大部分开发场景,如果不能满足就使用类库。

本文转载于:

https://juejin.cn/post/7325627704782307337

如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

 

热门相关:我朋友的婶婶   逃离疯人院   俺不是贫困户   百色起义1989   末日重始