【业务方案】关于金额保留小数的问题

2,912 阅读5分钟

写在前面


  笔者最近一直在做电商业务的开发,因为涉及到组合支付的情况(平台虚拟币支付 + 微信/支付宝在线支付),所以需要计算出不同支付方式所要支付的金额。这个地方,测试上周五下班时就提出了一个金额的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,有着上面的静态方法。它们的作用是返回给定数字的一个相领整数,注意哦,返回值是整数。

  1. 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
    
  2. Math.ceil()

    返回大于或等于一个给定数字的最小整数,也就是向上取整。

    Math.ceil(.95)
    // 1
    Math.ceil(4)
    // 4
    Math.ceil(7.004);
    // 8
    Math.ceil(-7.004);
    // -7
    
  3. 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()


  1. parseInt()

    parseInt(string, radix) 解析一个字符串并返回指定基数的十进制整数, radix 是2-36之间的整数,表示被解析字符串的基数。

    • string

      要被解析的值。如果参数不是一个字符串,则将其转换为字符串(使用 ToString 抽象操作)。字符串开头的空白符将会被忽略。

    • radix(可选)

      236,表示字符串的基数。例如指定 16 表示被解析值是十六进制数。请注意,10不是默认值!

  2. parseFloat()

    **parseFloat(string)**解析一个参数(必要时先转换为字符串)并返回一个浮点数。

    • string

      需要被解析成为浮点数的值。