最近在工作中,在算一个处方费用医保结算的时候 遇到一个诡异的问题,会出现一分钱的差异,设计金额的计算 往往是医院和银行最关注的。经过一上午的排查,最后 发现竟然是完全信赖的 js 原生 toFixed 方法的问题。
test(){
console.log(1.05.toFixed(1)); // 1.1 对
console.log(1.005.toFixed(2)); // 1.00 错
console.log(1.0005.toFixed(3)); // 1.000 错
console.log(1.00005.toFixed(4)); // 1.0001 对
console.log(1.000005.toFixed(5)); // 1.00001 对
console.log(1.0000005.toFixed(6)); // 1.000001 对
console.log(1.00000005.toFixed(7)); // 1.0000000 错
console.log(1.000000005.toFixed(8)); // 1.00000000 错
}
看到上面的测试结果 我整个脑瓜子都是嗡嗡的
js封装解决问题
/**
* 保留小数点几位数, 自动补零, 四舍五入
* @param num: 数值
* @param digit: 小数点后位数
* @returns string
* Object.is()是es6引入的、用于判断两个或者多个数据是否全等的方法。很重要的一个特点是Object.is(NaN,NaN)的结果是true 这里是判断这个值是否为数字 如果不为数字则 方法parseFloat 方法就不能转换 这里就会 显示为true
* Number.EPSILON 可以用来设置“能够接受的误差范围”。比如,误差范围设为 2 的-50 次方(即Number.EPSILON * Math.pow(2, 2)),即如果两个浮点数的差小于这个值,我们就认为这两个浮点数相等。 这里是设置最小误差值
* Math.pow 原始方法 这里是求 10的多少次方
*/
function myFixed(num, digit) {
if(Object.is(parseFloat(num), NaN)) {
return console.log(`传入的值:${num}不是一个数字`);
}
num = parseFloat(num);
return (Math.round((num + Number.EPSILON) * Math.pow(10, digit)) / Math.pow(10, digit)).toFixed(digit);
}
test(){
console.log(myFixed(1.05,1)); // 1.1 对
console.log(myFixed(1.005,2)); // 1.01 对
console.log(myFixed(1.0005,3)); // 1.001 对
console.log(myFixed(1.00005,4)); // 1.0001 对
console.log(myFixed(1.000005,5)); // 1.00001 对
console.log(myFixed(1.0000005,6)); // 1.000001 对
console.log(myFixed(1.00000005,7)); // 1.0000001 对
console.log(myFixed(1.000000005,8)); // 1.00000001 对
}
eg:
var f = 3.15
undefined
f.toFixed(1)
'3.1'
myFixed(f,1)
'3.2'
好了问题解决了 ,现在我们追一下 tofixed 的具体问题。
我面向百度编程,一搜索果真发现各种结果,看来确实是个经典的问题了。
以前也了解过 它符合的是银行家的算法(四舍六入五成双),啥意思呢就是: 四舍六入五考虑,五后非零就进一,五后为零看奇偶,五前为偶应舍去,五前为奇要进一 (当舍去位的数值 ≤4 时舍去,当它 ≥6 时加上,可当它 =5 时,则根据 5 后面的数字来定;当 5 后有非零数字时,舍 5 入 1;当 5 后无有效数字时,需要再分两种情况:5 前为偶数,舍 5 不进;5 前为奇数,舍 5 入 1)
更具上面的规则,我进行了一番测试,结果又发现了问题:
const a = 3.15
const b = 3.25
console.log(a.toFixed(1)); // 3.1
console.log(b.toFixed(1)); // 3.3
怎么回事?变量a 5前为奇数 没有进1;变量b 前为偶数 也没有舍弃反而进了 1 ;
猜测 1: js 精度问题引起的
我们都知道
JavaScript 中所有数字包括整数和小数都只有一种类型 — Number。它的实现遵循 IEEE 754 标准,使用 64 位固定长度来表示,也就是标准的 double 双精度浮点数(相关的还有float 32位单精度)。为什么呢,因为这样节省存储空间。
也就是说 17652.19 + 7673.78 = 25325.969999999998。其实最简单的例子是 0.1+0.2 = 0.30000000000000004
0.1的二进制表示的是一个无限循环小数,该版本的 JS 采用的是浮点数标准需要对这种无限循环的二进制进行截取,从而导致了精度丢失,造成了0.1不再是0.1,截取之后0.1变成了 0.100…001,0.2变成了0.200…002。所以两者相加的数大于0.3。
将0.1转换成为二进制加上0.2的二进制会是53位,但是二进制的最大位数是52位取近似值。
上述的 3.25 其实类似于3.2500000002 存在尾数 所以按照tofixed 的算法 就应该是 3.3,但是3.15呢 难道是类似于 3.14999999999998 ? 这里我又引入一个 decimal.JS 来解决js 精度的问题。
想必大家在用js 处理 数字的 加减乘除的时候,或许都有遇到过 精度不够的问题
还有那些经典的面试题 02+0.1 == 0.3
至于原因,那就是 js 计算底层用的 是 ,精度上有限制
那么,Decimal.js 就是帮助我们解决 js中的精度失准的问题
npm install --save decimal.js // 安装
import Decimal from "decimal.js" // 具体文件中引入
let a = 1
let b = 6
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new
let res = new Decimal(a).add(new Decimal(b))
let res = Decimal(a).add(Decimal(b))
减
let a = "4"
let b = "8"
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new
let res = new Decimal(a).sub(new Decimal(b))
let res = Decimal(a).sub(Decimal(b))
乘
let a = 1
let b = 6
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new
let res = new Decimal(a).mul(new Decimal(b))
let res = Decimal(a).mul(Decimal(b))
除
let a = 1
let b = 6
// a 与 b 可以是 任何类型,Decimal 内部会自己处理兼容
// 下面两种都可以 可以带 new 也不可以不带 new
let res = new Decimal(a).div(new Decimal(b))
let res = Decimal(a).div(Decimal(b))
注意:
上面的结果是 一个 Decimal 对象,你可以转换成 Number 或则 String
let res = Decimal(a).div(Decimal(b)).toNumber() // 结果转换成 Number
let res = Decimal(a).div(Decimal(b)).toString() // 结果转换成 String
关于保存几位小数相关
//查看有几位小数 (注意不计算 小数点 最后 末尾 的 0)
y = new Decimal(987000.000)
y.sd() // '3' 有效位数
y.sd(true) // '6' 总共位数
// 保留 多少个位数 (小数位 会补0)
x = 45.6
x.toPrecision(5) // '45.600'
// 保留 多少位有效位数(小数位 不会补0,是计算的有效位数)
x = new Decimal(9876.5)
x.toSignificantDigits(6) // '9876.5' 不会补0 只是针对有效位数
// 保留几位小数 , 跟 js 中的 number 一样
toFixed
x = 3.456
// 向下取整
x.toFixed(2, Decimal.ROUND_DOWN) // '3.45' (舍入模式 向上0 向下1 四舍五入 4,7)
// 向上取整
Decimal.ROUND_UP
//四舍五入
ROUND_HALF_UP
计算
let a = 1
let b = 6
//加
let res = new Decimal(a).add(new Decimal(b)) //得到的值是一个Decimal对象 需要转换
let res1 = new Decimal(a).add(new Decimal(b)).toNumber() //结果转换成number
let res2 = new Decimal(a).add(new Decimal(b)).toString() //结果转换成string
//下面同上操作
//减
let res = new Decimal(a).sub(new Decimal(b))
//乘
let res = new Decimal(a).mul(new Decimal(b))
//除
let res = new Decimal(a).div(new Decimal(b))
固定取两位小数,其他抹去
/**
* 取2位小数(可自定义)
*
* @param num1 参数1
* @param num2 参数2
* @param status 1(+) 2(-) 3(*)
* @param num 小数后 num-1位
* @returns
*/
export const multiply = (num1, num2, status,num=3) => {
let sum = ''
if (status === 1) {
sum = new Decimal(parseFloat(num1)).add(new Decimal(parseFloat(num2))).toFixed(3).toString()
return +sum.substring(0, sum.indexOf(".") + 3)
} else if (status === 3) {
sum = new Decimal(parseFloat(num1)).mul(new Decimal(parseFloat(num2))).toFixed(3).toString()
return +sum.substring(0, sum.indexOf(".") + 3)
}
}
但是
created() {
// const a = 2.998;
// const b = 8.037;
// var g = parseFloat(a + b);
// var h = g.toFixed(2);
// // 加法
// let c = new Decimal(a).add(new Decimal(b)).toNumber();
// let i = c.toFixed(2);
// // 减法
// let d = new Decimal(a).sub(new Decimal(b));
// // 乘法
// let e = new Decimal(a).mul(new Decimal(b));
// // 除法
// let f = new Decimal(a).div(new Decimal(b));
// console.log("---->>>", c, d, e, f, g, h, i);
// console.log(
// "---->>>",
// new Decimal(c),
// new Decimal(d),
// new Decimal(e),
// new Decimal(f)
// );
var z = 3.25;
var x = z.toFixed(3);
var xx = parseFloat(x).toFixed(1);
var o = new Decimal(z);
let y = new Decimal(z).toNumber();
var q = y.toFixed(1);
console.log("---->>>>sss", o, y, q, x, xx);
}
// ---->>>>sss Decimal {s: 1, e: 0, d: Array(2), constructor: ƒ} 3.25 3.3 3.250 3.3
还是不对, 这就可能不单单是精度的问题了 又要抓头发了 。。。
再经过一番探索,总算是有点收获了,下面就得来看看 ECMAScript 规范对该方法的定义了,有时候回归规范才是最靠谱的方式。
除
上图是关于整个 toFixed 方法的定义,不过是翻译后的版本,会有出入但差别不大,也可以点击上面的链接查看原文,我们主要关注图中红框部分,通过公式来计算舍去位数值。
下面我们举个例子。测试一下:
eg:
console.log(1.0000005.toFixed(6)); // 1.000001 正确
console.log(1.00000005.toFixed(7)); // 1.0000000 错误
首先,根据红框的条件,x<10^21,1.0000005 与 1.00000005 都是小于 10^21 的,所以我们直接可以使用公式 n / 10^ - x 来玩耍。
我们先用 x=1.0000005 代入公式来看看情况:
// 假设n1
var n1 = 1000000;
var x = 1.0000005;
var f = 6;
console.log((n1 / Math.pow(10, f) - x)); // -5.00000000069889e-7
// 假设n2
var n2 = 1000001;
var x = 1.0000005;
var f = 6;
console.log((n2 / Math.pow(10, f) - x)); // 4.999999998478444e-7
由结果可知,当 n1=1000001 时,得到的结果取最接近 0 的值,故:
console.log(1.0000005.toFixed(6)); // 1.000001 正确
再来试试当 x=1.00000005 代入公式:
// 假设n1
var n1 = 10000000;
var x = 1.00000005;
var f = 7;
console.log((n1 / Math.pow(10,f) - x)); // -4.9999999918171056e-8
// 假设n2
var n2 = 10000001;
var x = 1.00000005;
var f = 7;
console.log((n2 / Math.pow(10,f) - x)); // 5.000000014021566e-8
由结果可知,当 n2=10000001 时,得到的结果取最接近 0 的值,故:
console.log(1.00000005.toFixed(7)); // 1.0000000 错误
总的来说,上面例子就是教你如何通过规范定义的公式计算出结果而已,如果你看得懂规范,那么直接去代入也是没有问题的。