〇、js 的数值计算存在结果不精确的情况
最近接触财务相关系统,页面上会有一些简单的计算,就发现其实是非常简单的计算,但 js 计算出来的结果却不是预期值,可能带上一大串 0 或 9,导致计算结果错误,本文来简单汇总下,以及如何处理来避免这个问题。
先看看都会有哪些不精确的情况。
// 【加减乘除 四种基础运算】
// 精度问题其实也不是全都会有,比如以下测试:
let num1 = 0.1;
let num2 = 0.1;
let result = num1 + num2;
console.log("0.1+0.1 = " + result);
// 0.1+0.1 = 0.2
// 0.1+0.2 = 0.30000000000000004
// 1.1*1.2 = 1.32
// 1.1*1.1 = 1.2100000000000002
// 其实四种基础运算都会出现类似情况,这里就简单列两类
// 【数值比较】
console.log("'0.1+0.2 = 0.3' 比较结果:", 0.1 + 0.2==0.3);
// '0.1+0.2 = 0.3' 比较结果: false
一、原因简介
因为计算机内部的信息都是由二进制方式表示的,即 0 和 1 组成的各种编码,但由于某些浮点数没办法用二进制准确的表示出来,也就带来了一系列精度问题。当然这也不是 js 独有的问题。
推荐一个文章吧,有兴趣可以深究下:https://zhuanlan.zhihu.com/p/33333351,讲的应该是很详细,但有些复杂博主看了一遍也不是太了解。
下面把大概的流程梳理下。
- 将小数转成二进制数
小数部分乘 2 取整数部分,若小数不为 0 则继续乘 2,直至小数部分为 0。然后将取出的整数位正序排列。整数部分就除以 2。
0.1 * 2 = 0.2 // 取0
0.2 * 2 = 0.4 // 取0
0.4 * 2 = 0.8 // 取0
0.8 * 2 = 1.6 // 取1
0.6 * 2 = 1.2 // 取1
0.2 * 2 = 0.4 // 取0
......
0.1 的二进制表示是:0.000110011......0011.....(0011无限循环)
0.2 的二进制表示是:0.00110011......0011......(0011无限循环)
虽然是无限长度,但是还是得有一个标准的显示方式,那就是 IEEE 754 标准。
- IEEE 754 标准方法表示数值
IEEE 754 标准是 IEEE 二进位浮点数算术标准(IEEE Standard for Floating-Point Arithmetic)的标准编号。IEEE 754 标准规定了计算机程序设计环境中的二进制和十进制的浮点数自述的交换、算术格式以及方法。
详细的标识法,就跳过了,直接来看结果。
js 只有一种数字类型 number,而 number 使用的是 IEEE 754 双精度浮点格式。最高位是一位符号位(0正 1负),后面的 11 位是指数,剩下的 52 位为尾数(有效数字)。
// js 中 0.1 的二进制存储格式为:
// 符号位用逗号分隔,指数位用分号分隔
0.1:0,01111111011;1001100110011001100110011001100110011001100110011010
0.2:0,01111111100;1001100110011001100110011001100110011001100110011010
-
计算两个二进制数的和
// 二进制形式的结果
sum = 0.010011001100110011001100110011001100110011001100110100
// 最后再转成十进制
sum = 2^2 + 2^5 + 2^6 + ... + 2^52 = 0.30000000000000004440892098500626
// 近似后的结果:0.30000000000000004
详情可参考:https://zhuanlan.zhihu.com/p/33333351
二、解决方式
2.1 Math.Round() 函数,先乘后除 10 的 n 次方
根据需求的精度,先乘以 10 的 n 次方,通过 Math.Round() 函数取整 后,再除以10 的 n 次方。
function numToString(num){
let factor = Math.pow(10, 4); // 最多保留 4 位小数
// 通过乘以一个因子(例如 10 的 4 次方),然后四舍五入
// 最后,再除以相同的因子,可以实现对特定小数位数的精确控制
let roundedNum = Math.round(num * factor) / factor;
return roundedNum.toString();
}
关于 Math.Round() 四舍五入的规则,可以参考以下测试结果:
console.log("Math.round(4.2) ", Math.round(4.2) );
console.log("Math.round(4.5) ", Math.round(4.5) );
console.log("Math.round(4.7) ", Math.round(4.7) );
console.log("Math.round(-4.2) ", Math.round(-4.2) );
console.log("Math.round(-4.5) ", Math.round(-4.5) );
console.log("Math.round(-4.7) ", Math.round(-4.7) );
console.log("Math.round(1.5) ", Math.round(1.5) );
console.log("Math.round(2.5) ", Math.round(2.5) );
console.log("Math.round(3.5) ", Math.round(3.5) );
console.log("Math.round(-1.5) ", Math.round(-1.5) );
console.log("Math.round(-2.5) ", Math.round(-2.5) );
console.log("Math.round(-3.5) ", Math.round(-3.5) );
可以看出,正数的小数位为 5 时,进 1;负数小数位为 5 时,舍弃。
2.2 toFixed() 方法,直接取小数点后固定位数
此方法就是直接指定要保留的几位小数,若小数位较少,就会以 0 补全。
toFixed() 的四舍五入规则,并非严格的根据要保留小数位后边的小数来判断。
若作为判断的小数位为 5,且后边没有大于 0 的数,则舍入到最近的奇数;若 5 后边有非零的值,就直接进 1。
例如,4.55 保留 1 位小数,就是 4.5,但 4.5500001 的结果就是 4.6。
例如,-4.55 保留 1 位小数,就是 -4.5,但 -4.5500001 的结果就是 -4.6。
如下示例,供参考:
let num = 4.22;
console.log("num.toFixed(1):4.22) ", num.toFixed(1));
num = 4.55;
console.log("num.toFixed(1):4.55) ", num.toFixed(1));
num = 4.551;
console.log("num.toFixed(1):4.551) ", num.toFixed(1));
num = 4.65;
console.log("num.toFixed(1):4.65) ", num.toFixed(1));
num = 4.77;
console.log("num.toFixed(1):4.77) ", num.toFixed(1));
num = -4.22;
console.log("num.toFixed(1):-4.22) ", num.toFixed(1));
num = -4.55;
console.log("num.toFixed(1):-4.55) ", num.toFixed(1));
num = -4.551;
console.log("num.toFixed(1):-4.551) ", num.toFixed(1));
num = -4.65;
console.log("num.toFixed(1):-4.65) ", num.toFixed(1));
num = -4.77;
console.log("num.toFixed(1):-4.77) ", num.toFixed(1));
num = -4.77;
console.log("num.toFixed(1):-4.77) ", num.toFixed(4));
注意:toFixed() 的结果是字符串类型,若最终还需要 number 类型,就需要通过 Number() 函数进行转换。
2.3 通过正则表达式,多余位小数直接舍去
必须先将数字类型转换成字符串,再使用 match() 方法。
let num = 3.14959;
let numStr = num.toString(); // 必须为字符串
let fixedNumStr = numStr.match(/^-?\d+(\.\d{0,2})?/)[0]; // 两位小数的正则表达式
let fixedNum = parseFloat(fixedNumStr);
console.log(fixedNum); // 输出:3.14