JS 小数精度引发的血案
前言: 在找工作面试的时候,相信你偶尔会遇到一道经典的面试题,即:
0.1 + 0.1 是否等于 0.3
都不用思考,你就能马上说出答案,肯定不等于啊,如果记忆里好点你还能记住运算结果,0.1 + 0.2 = 0.30000000000000004
。造成这种结果的原因是,小数在转成二进制的时候,采用的办法是小数乘积正取正,这个方法没有任何问题,问题是有的小数是无法整乘,即使用 小数乘积正取正 方法,把十进制的小数换算成二进制,结果会出现无限不循环数。而且 JS 采用双精度存值,只保留 64 位,这就导致了约数的出现,有约数那肯定就不准确了,由此出现小数精度的问题。
一、浮点数的精度运算
我看网上给出大约三种解决方案:
- 使用
toFixed,parseFoloat
原生 JS 方法,我们不能使用这个方法,因为本来计算就不准确,又使用 toFixed 给约数下,不准确加上更不准确 😩。看例子:
parseFloat(0.9); //0.9
parseFloat(9999999999999999) //10000000000000000
parseInt("9999999999999999"); //10000000000000000
parseFloat(9.999999999999999); //10
toFixed不会四舍五入:
var num = 1.835;
num.toFixed(2); //"1.83"
toFixed 取值不准确:
var num = 0.999999999999998898;
num.toFixed(10); //"1.0000000000"
-
将浮点数转为整数运算,再对结果做除法,例如
(0.1 * 10 + 0.2 * 10) / 10 === 0.3
,但是8800.03 * 100 === 880003.0000000001
转换结果又不对,所以小数运算还是有问题的。 -
比较推荐的是这三个库, bignumber.js,decimal.js,以及 big.js 来解决精确度的问题。三者的区别为:
-
big.js:极简主义;易于使用; 小数点后指定的精度;精度仅适用于除法;4 种舍入模式。适用于取精度简单的运算
-
bignumber.js:以 2-64 为基数; 配置选项;NaN; 无限; 小数点后指定的精度;精度仅适用于除法;随机数;基本前缀;9种舍入模式;模模式;模幂。多种精度任你选择,更加适用于金融类。
-
decimal.js:二进制,八进制和十六进制;配置选项;NaN; 无限; 非整数幂,exp,ln,log;三角函数 以有效数字指定的精度;始终应用精度;随机数;序列化和反序列化;基本前缀;9种舍入模式;模模式;二进制指数表示法。适合做程序员计算器。
摘自:big.js,bignumber.js 和 decimal.js 有什么区别?
按功能范围分 decimal.js > bignumber.js > big.js
。
比较好奇 big.js 怎么用 JS 实现计算的,看了源码,一堆x,t, b 等变量,结果源码没看懂。
二、浮点数的百分比表示
这就是我遇到的血案,后端传回来的毛利率为小数,前端自己处理成百分比的形式,但是因为某些小数在乘 100 的时候出现精度的问题,感觉无解似的。这时候我的解决思路就是把数字按数组来处理,并写了个函数,函数可以让输入数自动扩大一百倍,然后吐出来的数只需要手动加个百分号就行了。
源码如下:
const decimal2Percentage = (decimal) => {
// 判断是整数还是小数
const isInteger = String(decimal).split(".")[1];
if (isInteger) {
// 小数逻辑
// 获取小数的整数部分
const firstNumber = String(decimal).split(".")[0];
// 获取小数的小数部分
decimal = String(decimal).split(".")[1];
// 小数位数少于两位补零0.1 ==> 10
decimal = decimal.length < 2 ? decimal.padEnd(2, 0) : decimal;
const percentage = decimal.split("");
// 小数点后移两位,达到乘100的效果
decimal.length > 2 && percentage.splice(2, 0, ".");
if (firstNumber > 0) {
// 小数的整数大于零需要保留
return `${firstNumber}${percentage.join("")}`;
} else {
// 裁掉多余的零
let numberIndex = percentage.findIndex(number => number !== "0");
percentage.splice(0, numberIndex);
percentage[0] === "." && percentage.unshift("0");
return `${percentage.join("")}`;
};
} else {
// 整数逻辑,直接添加两个零,零除外
const newDecimal = String(decimal) !== "0" ? String(decimal).split(".").concat([ 0, 0 ]) : [ "0" ];
return newDecimal.join("");
};
}
console.log(decimal2Percentage(0.0001)); //0.01
console.log(decimal2Percentage(0.1)); //10
console.log(decimal2Percentage(0)); //0
console.log(decimal2Percentage(90)); //9000
console.log(decimal2Percentage(12)); //1200
console.log(decimal2Percentage(0.102023231)); //10.2023231
三、大数精度问题
[Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]
表示 JS 数的表示范围,当超出这个范围怎么办,聪明的你肯定想到了,没错使用字符串来表示, antd 的 inputNumber 输入框就是先输入数字,数字不能表示时切换字符串来表示。这个属于位数精度的问题。
https://www.npmjs.com/package/fraction.js
四、你没注意到的 Math.round 方法
还有另外一个与 JavaScript 计算相关的问题,即 Math.round(x),它虽然不会产生精度问题,但是它有一点小陷阱容易忽略。下面是它的舍入的策略:
如果小数部分大于 0.5,则舍入到下一个绝对值更大的整数。
如果小数部分小于 0.5,则舍入到下一个绝对值更小的整数。
如果小数部分等于 0.5,则舍入到下一个正无穷方向上的整数。
所以,对 Math.round(-1.5),其结果为 -1,这可能不是我们想要的结果,一定要注意这一点。