遇到的问题:

在前端开发中,我们经常会遇到一些“动态计算后保存”的需求,比如订单、预算、发票等场景。今天就来分享一个真实案例:

背景

我们的业务场景是这样的:

  • 有一个 Vue 表单 fieldFrom,里面包含三个字段:

    • contractMoney 合同金额

    • fieldYloeuu 已开票金额

    • fieldFsjhkx 未开票金额

我们希望:未开票金额 =  合同金额 -  已开票金额   并且在用户修改任意一个字段时,剩余金额自动更新。

于是,我们写了一个 watch

watch: {
  fieldFrom: {
    handler(newValue) {
      this.fieldFrom.fieldFsjhkx = this.fieldFrom.contractMoney - this.fieldFrom.fieldYloeuu
    },
    deep: true
  }
}

看起来没问题,结果在保存到数据库时出现了问题:


经过排查发现:

  • 后端表字段是 DECIMAL(15, 2),最多允许 15 位数字,小数点后 2 位。

  • 我们的 watch 计算后没有做小数处理,导致 123.456789 这种浮点数直接保存,触发报错。

  • 正则、前端校验都没有问题,根本原因是 JS 浮点精度 + watch 没有处理小数

问题分析

JS 中浮点数本身存在精度问题:

0.1 + 0.2 // 结果是 0.30000000000000004
 

所以在 watch 或计算属性里直接减法,可能得到很多位小数。即使我们在表单里显示 2 位小数,后台接收到的仍然是原始浮点数。

解决方案

方法一:在 watch 里做小数处理

可以使用 toFixed(2) 或自定义函数,确保保存时保留两位小数:
watch: {
  fieldFrom: {
    handler(newValue) {
      const diff = this.fieldFrom.contractMoney - this.fieldFrom.fieldYloeuu
      this.fieldFrom.fieldFsjhkx = Number(diff.toFixed(2))
    },
    deep: true
  }
}

方法二:统一用工具函数处理浮点精度,之前封装了一个类似的小工具类也能满足这个需求。(这个小的工具类之前封装好的)

/**
 * 金额工具类(解决浮点数精度 + 千分位展示)
 */
export const MoneyUtils = {
  /**
   * 千分位格式化
   * @param {number|string} value - 原始金额
   * @param {number} precision - 小数位,默认 2
   * @returns {string}
   */
  format(value, precision = 2) {
    if (value == null || value === '' || isNaN(value)) return '0.00'
    const num = Number(value).toFixed(precision)
    return num.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
  },

  /**
   * 保留指定小数位
   * @param {number|string} value
   * @param {number} precision
   * @returns {string}
   */
  toFixed(value, precision = 2) {
    if (value == null || value === '' || isNaN(value)) return '0.00'
    return Number(value).toFixed(precision)
  },

  /**
   * 加法(解决浮点精度问题)
   */
  add(a, b) {
    return this._decimalOp(a, b, 'add')
  },

  /**
   * 减法
   */
  sub(a, b) {
    return this._decimalOp(a, b, 'sub')
  },

  /**
   * 乘法
   */
  mul(a, b) {
    return this._decimalOp(a, b, 'mul')
  },

  /**
   * 除法
   */
  div(a, b) {
    if (Number(b) === 0) return NaN
    return this._decimalOp(a, b, 'div')
  },

  /**
   * 内部通用小数运算
   */
  _decimalOp(a, b, type) {
    const x = Number(a) || 0
    const y = Number(b) || 0

    const xStr = x.toString()
    const yStr = y.toString()
    const xDecimal = (xStr.split('.')[1] || '').length
    const yDecimal = (yStr.split('.')[1] || '').length
    const base = Math.pow(10, Math.max(xDecimal, yDecimal))

    switch (type) {
      case 'add':
        return (Math.round(x * base) + Math.round(y * base)) / base
      case 'sub':
        return (Math.round(x * base) - Math.round(y * base)) / base
      case 'mul':
        return (x * base) * (y * base) / (base * base)
      case 'div':
        return (x * base) / (y * base)
      default:
        return NaN
    }
  }
}

总结

  1. Vue watch 做计算时一定要注意浮点精度问题。

  2. toFixed + Number() 是最简单的前端处理方式。

  3. 后端也建议做二次截断,保证数据库安全。

  4. 遇到“最多支持15位数字”的错误,不要先怪正则或校验,先检查浮点数小数位。

更多推荐