九个月前埋下的子弹,穿过漫长的引线,终于在今天,击穿了现在的我:藏在 JS 代码里的浮点运算误差

65 阅读3分钟

当初写下剩余金额 = 未使用金额(282470.94)- 已使用金额累加之和(279470.93999999994)时轻描淡写的一行,在无数次累加、比对、结算后,终于在这个对账节点炸出刺眼的5.82(保留两位小数了实际为5.820766091346741e-8),精准击穿了所有基于 "直观数值" 构建的逻辑。

1755142543663_20EFB41D-05A4-455a-A9BF-C6C3FB0EE66D.png

  const a = 282470.94
  const b = 23614.97 + 110347.2 + 145508.77//279470.93999999994
  const result = a - b

  console.log(result) // 输出:5.820766091346741e-8
  console.log(result.toFixed(10)) // 输出:0.0000000582(更直观的小数形式)

“没坐” 正是我来公司第六天的晚上,当时项目正赶着 12 月 31 号上线。项目里封装的工具类我还没来得及翻源码,算金额结果保留小数时顺手就用了 toFixed (2) 应付着。四周前同事 “行哥” 看到我的 toFixed 后,好心提醒:“我们小数点的四舍五入有工具类哦。” 我当时想着‘看到优化点就得马上改’,没多琢磨把工具函数简简单单CV上去,卡点做完提了代码,下班美滋滋。

image.png

相信各位大佬们处理这类精度问题时,大多会用 Decimal.js 这类成熟的第三方库,或是自己轻量封装一套工具 —— 核心逻辑其实相通:把数字拆成整数和小数两部分,遇到小数就先放大成整数来运算,从根源上避开浮点数的存储陷阱。

//核心逻辑
const [start, end] = String(number).split('.')

我们项目的保留小数位数和数字运算的工具函数就是像这样封装的,然后就有了粗心大意的我只在计算的结果用了封装好的工具函数,就有了5.820766091346741e-8转字符串成了5和820766091346741e-8,最后结果5.82

 function rounding(number = 0, digit = 2, rule = 4) {
  if (isNaN(+number)) {
    return number
  }

  const isNegative = number < 0
  number = Math.abs(+number) // 处理负数,将其转为正数进行计算

  digit = +digit
  rule = +rule
  const [start, end] = String(number).split('.')

  if (end) {
    let newEnd = end.slice(0, digit)
    newEnd = +('0.' + newEnd)

    const point = end[digit] || 0
    if (point > rule) {
      const count = 1 / 10 ** digit
      newEnd = Decimal.add(newEnd, count)
    }

    number = Decimal.add(+start, +newEnd)
  }

  const result = new Decimal(number).toFixed(digit)

  return isNegative ? `-${result}` : result // 如果原始数字是负数,添加负号
}
const a = 282470.94
const b = 23614.97 + 110347.2 + 145508.77//279470.93999999994
const result = a - b
console.log(rounding(result)) // 输出:5.82

测试组的老姐今天早上突然发来一句:“你在开会吗?” “啊?” 我心里咯噔一下,疑惑地敲出 “没有啊”,刚按下回车,她的消息就追了过来:“你过来一下。” 那瞬间,一种不祥的预感顺着脊椎爬上来。刚走到她工位旁,她没多话,直接点开屏幕给我复现 —— 就是文章开头说的那笔金额,本该显示 0 的地方,赫然跳着个 5.82

image.png

看着那串扎眼的 5.82,我这才反应过来是自己当初图省事埋下的坑。一股愧疚感直往上涌 —— 这波操作也太拉垮了,既耽误了测试老姐的时间,又辜负了产品那边的信任。 没敢多耽搁,我攥着鼠标的手都带着点急,转头就把计算全换成了 Decimal.js。当然实际项目里是封装成工具函数用的,这里就不细说了。改完火速提了代码,心里才稍稍松了口气,只盼着这次能彻底把这精度隐患给堵上。

const a = new Decimal(282470.94)
const b = new Decimal(23614.97).plus(new Decimal(110347.2)).plus(new Decimal(148508.77))
const result = +a.minus(b)
console.log(result) // 输出:0

总结来看,修改旧代码或是优化他人写的代码时,最忌讳只盯着单个点猛改。就像这次踩的坑,看似只是换个工具函数的小事,实则得顺着逻辑链条从头摸到尾 —— 既要搞懂原有代码的设计意图,也要预判改动可能牵连的上下游,更得把边界场景挨个过一遍。 毕竟代码像一张网,每个节点都和其他地方牵着线。图省事只调一处,很可能漏了藏在暗处的关联,最后要么老问题没解决,要么新麻烦冒出来。所以啊,沉下心来全面梳理,把改动的前因后果盘清楚,才是稳妥的做法。

image.png