js 数字计算的精度问题
〇、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
2.4 使用 decimal.js 库
git 地址:https://github.com/MikeMcl/decimal.js/
如果当前项目中涉及到的计算比较多,还是推荐使用类似这样的 js 库,统一用法避免改起来麻烦。
// js 文件引用,引用详情:https://www.bootcdn.cn/decimal.js/
<script src='https://cdn.bootcdn.net/ajax/libs/decimal.js/10.4.3/decimal.js'></script>
// 前端项目安装
npm install --save decimal.js
// 前端文件中进行引入
import Decimal from 'decimal.js';
本文只简单介绍个别常用的运算,decimal.js 还是非常强大的,包含计算的方方面面。详情可参考官方 API:http://mikemcl.github.io/decimal.js/#
一个简单的加法示例:(两种写法)
const num1 = 0.1;
const num2 = 0.2;
const res = new Decimal(num1).add(new Decimal(num2)) // 写法一
// const res = Decimal.add(num1,num2); // 写法二
console.log(res.toFixed(20));
console.log((0.1+0.2).toFixed(20));
console.log("res.toFixed(1):",res.toFixed(1)); // toFixed:转【格式化字符串】
console.log("res.toNumber():",res.toNumber()); // toNumber:转【数字】
console.log("res.toString():",res.toString()); // toString:转【字符串】
方法 | 标识 | 语法 |
加 | add | new Decimal(num1).add(new Decimal(num2)) 或者 Decimal.add(num1,num2) |
减 | sub | new Decimal(num1).sub(new Decimal(num2)) 或者 Decimal.sub(num1,num2) |
乘 | mul | new Decimal(num1).mul(new Decimal(num2)) 或者 Decimal.mul(num1,num2) |
除 | div | new Decimal(num1).div(new Decimal(num2)) 或者 Decimal.div(num1,num2) |
取余 | mod | new Decimal(num1).mod(new Decimal(num2)) 或者 Decimal.mod(num1,num2) |
比较 | comparedTo | const res = new Decimal(num1).comparedTo(num2); 若 num1 大于 num2,则 res = 1 若 num1 等于 num2,则 res = 0 若 num1 小于 num2,则 res = -1 |
比大小 |
大于:greaterThan
大于等于:greaterThanOrEqualTo 小于:lessThan 小于等于:lessThanOrEqualTo 等于:equals |
new Decimal(num1).greaterThan(num2)
new Decimal(num1).greaterThanOrEqualTo(num2) new Decimal(num1).lessThan(num2) new Decimal(num1).lessThanOrEqualTo(num2) new Decimal(num1).equals(num2) 等到一个 bool 类型,true/false
|
判断数值类型 | 整数:isInt 整数:isPos 负数:isNeg |
new Decimal(num1).isInt()
new Decimal(num1).isPos() new Decimal(num1).isNeg() 等到一个 bool 类型,true/false
|
保留 n 个有效位 |
toPrecision
|
num1.toPrecision(n)
结果为 string 类型
当有效位数不足时,小数点后补 0
当整数位过多时不适用,结果格式示例:'5e+1',影响后续计算
|
另外,关于 toFixed() 实际上是有十个配置项,但常用的实际上只有三个:
算法名称 | 属性值 | 数值 |
四舍五入 | Decimal.ROUND_HALF_UP | 4 |
进一法 | Decimal.ROUND_UP | 0 |
舍去法 | Decimal.ROUND_DOWN | 1 |
下边是一个关于四舍五入的测试:
const num1 = new Decimal(0.15);
const num2 = new Decimal(0.25);
const num3 = new Decimal(0.35);
console.log("num1:",num1.toFixed(1,Decimal.ROUND_HALF_UP));
console.log("num2:",num2.toFixed(1,Decimal.ROUND_HALF_UP));
console.log("num3:",num3.toFixed(1,Decimal.ROUND_HALF_UP));
const num11 = new Decimal(-0.15);
const num22 = new Decimal(-0.25);
const num33 = new Decimal(-0.35);
console.log("num11:",num11.toFixed(1,Decimal.ROUND_HALF_UP));
console.log("num22:",num22.toFixed(1,Decimal.ROUND_HALF_UP));
console.log("num33:",num33.toFixed(1,Decimal.ROUND_HALF_UP));
注意:数值后跟着 decimal.js 特有的方法时,需将数值转成 Decimal 类型后再调用。
本文来自博客园,作者:橙子家,欢迎微信扫码关注博主【橙子家czzj】,有任何疑问欢迎沟通,共同成长!