一、前言

Javascript中,常见的与浮点数相关的操作(运算)有:+-*/toFixedparseFloatMath.roundMath.floortoString… 这些操作(运算)的结果,有些情况下和数学上定义的结果是不同的,如果不加注意,会影响产品功能的正常运行,俗称有bug

  • 以下几个表达式的计算结果是什么样子的?
    1. 0.1 + 0.2 = ?
    2. 0.1 + 0.2 === 0.3 ?
    3. 0.3 - 0.1 = ?
    4. 0.145 * 100 = ?
    5. 0.15.toFixed(1) = ?

答案:

  1. 0.1 + 0.2 = ? 0.30000000000000004
  2. 0.1 + 0.2 === 0.3 ? false
  3. 0.3 - 0.1 = ? 0.19999999999999998
  4. 0.145 * 100 = ? 14.499999999999998
  5. 0.15.toFixed(1) = ? 0.1

二、why

2.1 JS精度为何会丢失

机器本来如此,无法精确表达所有数学结果,只能近似表达。
不展开,上链接: 0.1在内存中的样子
不同编程语言中会有近似处理,尽量让结果准确。

2.2 toFixed不是四舍五入

是4舍6入,舍弃位为5时,结果不稳定。

思考:在明确知道以上坑的情况下,如何避免写bug?

三、实战

3.1 工作背景

  1. 难以避免计算结果出现小数的情况,比如价格计算、油耗计算等
  2. 因为各种原因,输入数据是小数

3.2 实际场景

加油中:原单价5.22元/L(a),现价5.11元/L(b),加100块钱(total),能加多少L油(v)?实际支付多少(actual),优惠多少(discount)?

挑战:服务商会进行金额校验,校验不通过会导致下单失败。

  1. 有中间步骤,每一步都(可能)有精度丢失

    1. v = total / a = 100 / 5.22 19.16L (除法,toFixed
    2. actual = v * b 97.91 (乘法,toFixed,基于上一步保留两位小数的结果)
    3. discount = total - actual = 2.09 (减法,toFixed
  2. 公式化简,去除中间步骤,v不参与实际价格的计算

    1. v = total / a = 100 / 5.22 19.16L (除法,toFixed
    2. actual = total / a * b 97.89 (除法,乘法,toFixed
    3. discount = total - actual 2.11 (减法,toFixed
  3. 尽量避免小数运算,化为整数单位后再计算,转为整数时需要注意处理乘法带来的精度问题,计算结果需要再按增大的倍数缩小回来

    1. // v 的计算只有一步,放大100倍计算后再缩小100,结果不变
    2. aCent = Math.round(a * 100) = 522(没问题)
    3. bCent = Math.round(b * 100) = 511(没问题)
    4. actual = total / aCent * bCent 97.89 toFixed)这一步还是有坑(69.145.toFixed(2) = 69.14
    5. discount = total - actual 2.11元(减法,toFixed
  4. 所有计算,化为整数,且尽量都转化为最小单位值,如(分,毫升)

    1. // v 的计算只有一步,放大100倍计算后再缩小100,结果不变,而且也不需要转换为毫升
    2. aCent = Math.round(a * 100) = 522(没问题)
    3. bCent = Math.round(b * 100) = 511(没问题)
    4. totalCent = Math.round(toal * 100) = 10000(没问题)
    5. actualCent = Math.round(totalCent / aCent * bCent) 9789(没问题)
    6. discountCent = totalCent - actualCent = 211元(没问题)
    7. actual = actualCent / 100 = 97.89(没问题)?不放心?可以继续做一次toFixed,然后再parseFloat回来
    8. discount = discountCent / 100 = 2.11(没问题)

3.3 toFixed结果如何变得符合数学预期?

单纯的考虑,如何让toFixed变得符合预期?

  1. // return num.toFixed(keep)
  2. function customeToFixed(num: number, keep: number): number {}

思路:

  • 舍弃位为5时,特殊处理
  • 既然Number.toFixed是四舍六入,那就在舍入位上加1让其成为6
  • 又考虑到小数加法有精度丢失问题,舍入位加1后还是会出现舍入位为5的情况,所以应该加2(0.1435+0.0001=0.14359999999999998,但是,实际上这时候toFixed(3)结果是正确的,但是加2更稳妥)
  1. /**
  2. * 四舍五入(支持保留n位小数,n>=0)
  3. * @param {any} x 原数字
  4. * 如果n不是合法数字或者无法转换为合法数字,round结果返回NaN
  5. * @param {any} n 保留几位小数,默认0
  6. * 如果n不是合法数字或者无法转换为合法数字,round结果返回NaN
  7. * 如果n小于0,round结果返回NaN
  8. * 如果n的值包含小数部分,round处理时只关注n的整数部分值
  9. * @return {number} 返回一个保留n位小数的数字,异常情况下可能是NaN
  10. */
  11. function round(x, n = 0) {
  12. return parseFloat(roundStr(x, n, false));
  13. }
  14. /**
  15. * 四舍五入,并返回格式化的字符串
  16. * 支持保留n位小数,n>=0,如 round(1.325, 2)=1.33
  17. * 支持格式化字符串时取出末尾的0,如round(1.109, 2, true)=1.1
  18. * @param {any} x 原数字
  19. * 如果n不是合法数字或者无法转换为合法数字,roundStr结果返回''
  20. * @param {any} n 保留几位小数,默认0
  21. * 如果n不是合法数字或者无法转换为合法数字,roundStr结果返回''
  22. * 如果n小于0,roundStr结果返回''
  23. * 如果n的值包含小数部分,roundStr处理时只关注n的整数部分值
  24. * @param {boolean} removeTrailingZero 是否移除字符串末尾的无效数字0
  25. * @return {string} 返回四舍五入后的字符串,异常情况下返回空字符串''
  26. */
  27. function roundStr(x, n = 2, removeTrailingZero = false) {
  28. let xNum = Number(x); // x转换为数字
  29. const nNum = Math.floor(Number(n)); // n转换为数字,且只保留整数部分
  30. // 异常情况,返回''
  31. if (isNaN(xNum) || isNaN(nNum) || nNum < 0) return '';
  32. // 仅保留整数的情况
  33. if (nNum === 0) return Math.round(xNum);
  34. // 保留n位小数的情况
  35. const xStr = xNum.toString();
  36. const rexExp = new RegExp(`\\.\\d{${nNum}}5`);
  37. // 1. 大部分情况下,四舍五入使用Number.toFixed即可
  38. // 2. 然而,Number.toFixed方法在某些情况下对第n+1位是5的四舍五入存在问题,如1.325保留
  39. // 2位小数时结果为1.32(期望为1.33),对此种情况下,有两种处理方式:
  40. // 2.1 先扩大10^n倍,舍掉小数部分取整数部分,然后加1,最后缩小10^n倍。但此种情况下,
  41. // 不能处理过大的数字,也不能处理保留小数位数过多的情况,会可能导致数字超过Infinity
  42. // 2.2 Number.toFixed是四舍6入,对于第n+1位是5的情况,增加2*10^(-n-1),保证满足
  43. // 第n+1位>6。增加2*10^(-n-1)而不是增加1*10^(-n-1),是因为后者不能保证第n+1位>=6,
  44. // 例如1.325+0.001=1.32599999...第n+1位仍然为5
  45. // 此处,采用2.2方式,解决Number.toFixed的问题,又能避免2.1方式中数字超过Infinity的问题
  46. if (rexExp.test(xStr)) { // 情况2,处理方式2.1:如果小数部分第n+1位是5,增加2*10^(-n-1)
  47. xNum += 2 * (10 ** (-nNum - 1));
  48. }
  49. const str = xNum.toFixed(nNum);
  50. if (!removeTrailingZero) return str;
  51. // 去除末尾的0
  52. if (/^\d+\.0*$/.test(str)) { // 小数部分全是0
  53. return str.replace(/^(\d+)(\.0*)$/, (_m, s1) => s1);
  54. }
  55. return str.replace(/^(\d+\.\d*[1-9]{1})(0*)$/, (_m, s1) => s1);
  56. }

四、总结

4.1 经验和提醒

  1. 根据数学逻辑进行化简,避免中间步骤
  2. 将运算数处理为整数进行运算,最后将结果按比例还原为小数
  3. 根据实际场景,将运算数转为关心的最小精度单位后再进行计算
  4. 四舍五入时,整数使用Math.round,小数使用优化后的toFixed

4.2 缺点/注意事项/思考

以上都只能是经验和提醒,不是圣经,实操时要根据实际场景需求来调整

  1. 【注意】要不要中间步骤,需要根据实际情况来看。如果和合作方约定的协议需要有中间步骤,或者实际情况下不得不有中间步骤,则需要保留中间步骤
  2. 【缺点】化为整数过程中,能够处理的最大最小数字有限,会有可能产生溢出
  3. 暂无
  4. 暂无
  5. 【思考】其他未知的坑,浮点数的parseFloat、toString一定安全吗?