写在前面
笔者最近一直在做电商业务的开发,因为涉及到组合支付的情况(平台虚拟币支付 + 微信/支付宝在线支付),所以需要计算出不同支付方式所要支付的金额。这个地方,测试上周五下班时就提出了一个金额的bug,也引起了我写这篇文章的念头。
bug 截图如下:
订单总金额为 ¥300.01,其中使用额度支付 ¥300,还剩余 ¥0.01 需要进行在线支付。但是在「立即支付」按钮处计算出来的支付金额却是 ¥0.00,实际上应该是 ¥0.01。
经过对于代码逻辑的排查,发现了问题的所在。
const a = 300.01 - 300
console.log(a) // 0.009999999999990905
上面的代码结果,出乎意料,也正是 bug 产生的原因。因为实际支付的金额是个循环小数,我只是截取了小数的前两位,所以得到的结果就是 0.00,而不是期望的 0.01。
到了这里,bug 算是快解决完了,但是我们静下心来,深入地探索一下这个问题,找找藏在背后的神秘宝藏🏴☠️。
旧题新看
上午9点,新界某大楼天台,微风不燥
梁朝伟:你们警察真是奇怪,老是喜欢约在天台见面。
刘德华转头:或许这就是警察吧👮♀️。
梁朝伟看向刘德华:我怎么不喜欢这地方。帮我吧,我们一起搞倒他。
刘德华拔枪:对不起,我是警察👮♀️。
梁朝伟大叫:我也是!!
刘德华:谁知道??我们都不过他的,他可是 0.3。
编了一个冷笑话,结合流传的满广泛的一道题理解一下:0.1 + 0.2 === 0.3 为什么是 false?
这道题的原因和前言里面讲的 bug 是一样的:计算机世界里面的浮点数问题。对于什么是浮点数,请参考这篇我翻译的文章: 浮点数是怎么回事?。既然已经遇到了这个问题,那么我们如何解决它呢?接下来我们就仔细梳理一下解决浮点数计算问题的方法。
使用分替换元作为单位
最源头的解决办法就是不使用元做金额单位,改用分作为金额单位,这样从根本上不存在小数的问题了。当前端需要使用到元的时候,例如在线支付,只需要先用分做单位进行金额计算,然后除以 100 转化为元即可。但这是理想中的情况,现实往往不是理想世界。经常会有其他因素影响,从而采用元作为金额单位。例如这次产品说,财务在看导出订单表的时候,使用分容易产生误解。亦或者是后端说使用分,后端凭空多了一些转化逻辑,太麻烦。最后,还是采用了元作为金额单位。
使用 toFixed()转化
既然已经采用了元为单位,那么我们就需要考虑其他方法避免浮点数计算的问题。首先想到的可能是 Number.prototype.toFixed(),这个方法的作用就是将数字转化为指定小数位数的字符串。
(76.211).toFixed(2) // '76.21'
(76).toFixed(2) // '76.00'
(76.8450001).toFixed(2) // '76.85'
(76.845).toFixed(2) // '76.84'
参考上述代码运行结果,toFixed() 方法是存在问题的,第四行代码的结果就跟我们期望的不同。因为它是采取的银行家算法。
银行家算法
四舍六入五考虑,五后非空就进一,五后为空看奇偶,五前为偶应舍去,五前为奇要进一
所以不能直接使用 toFixed(),想用的话需要自己进行二次封装才行。
使用取整方法 Math.floor()、Math.ceil()、Math.round()
JS 内置数学模块 Math,有着上面的静态方法。它们的作用是返回给定数字的一个相领整数,注意哦,返回值是整数。
-
Math.floor()
返回小于或等于一个给定数字的最大整数,也就是向下取整。
Math.floor( 45.95); // 45 Math.floor( 45.05); // 45 Math.floor( 4 ); // 4 Math.floor(-45.05); // -46 Math.floor(-45.95); // -46
-
Math.ceil()
返回大于或等于一个给定数字的最小整数,也就是向上取整。
Math.ceil(.95) // 1 Math.ceil(4) // 4 Math.ceil(7.004); // 8 Math.ceil(-7.004); // -7
-
Math.round()
函数返回一个数字四舍五入后最接近的整数。
如果参数的小数部分大于0.5,则舍入到相邻的绝对值更大的整数。如果参数的小数部分小于0.5,则舍入到相邻的绝对值更小的整数。如果参数的小数部分恰好等于0.5,则舍入到相邻的在正无穷方向上的整数。注意,与很多其他语言中的 round() 函数不同,Math.round() 并不总是舍入到远离0的方向(尤其是在负数的小数部分恰好等于0.5的情况下)。
Math.round(20.49) // 20 Math.round(20.5) // 21 Math.round(-20.5) // -20 Math.round(-20.51) // -21
现在我们已经知道这3个函数的区别了,并且知道它们返回的都是整数,那么如何满足我们的保留小数的需求呢?答案就是:先放大,再缩小。
例如变量 a=0.00999999999,我们想要保留a两位小数,得到0.01的结果。
const a = 0.00999999999
let b = (Math.round(a*100)) / 100
console.log(b)
// 0.01
假设我们想要保留n位小数,那么放大的倍数就是 10^n。
额外的 parseInt() 和 parseFloat()
-
parseInt()
parseInt(string, radix) 解析一个字符串并返回指定基数的十进制整数,
radix
是2-36之间的整数,表示被解析字符串的基数。-
string
要被解析的值。如果参数不是一个字符串,则将其转换为字符串(使用
ToString
抽象操作)。字符串开头的空白符将会被忽略。 -
radix(可选)
从
2
到36
,表示字符串的基数。例如指定 16 表示被解析值是十六进制数。请注意,10不是默认值!
-
-
parseFloat()
**parseFloat(string)**解析一个参数(必要时先转换为字符串)并返回一个浮点数。
-
string
需要被解析成为浮点数的值。
-