上周改一个对账模块,接口返回的金额是 1234567.8912 这种裸数字,产品要求页面上必须显示成 1,234,567.89,导出 CSV 的时候又必须还原成不带逗号的纯数字。说起来就是 JS 里把金额格式化成千分位加逗号、保留两位小数这点事,我前后折腾了快两个小时才把它做干净。坑不在主逻辑上,全在细节里:浮点数的二进制表示、toFixed 的舍入方向、千分位正则在有小数位时会乱插逗号、负数四舍五入往哪边走、空值和零值要显示成不一样的东西。这些点随便踩中一个,第二天测试同学就会拿着截图来找你。
这篇东西我打算把这件事从头到尾讲透。什么是金额格式化、它在什么场景下会出问题、几条主流实现路线各自适合谁、每一步为什么这么写,以及线上真跑起来之后才暴露出来的那些问题。前端新手可以把它当成一份可以直接抄的实现笔记,写过几年的人可以重点看舍入规则和性能那两节,那里的坑我基本都踩过一遍。全文给的都是能直接粘进项目里的代码,参数含义和实测输出也一并列出来。
1. 拆需求:这笔账到底要格式化成什么样
1.1 业务现场里金额的四种形态
很多格式化函数写崩,根子不在代码,在于没想清楚同一个金额在系统里其实有四种完全不同的形态。把这件事分清楚,函数的设计边界自然就出来了。
第一种是接口原始值。它可能是 number(1234567.8912),可能是字符串("1234567.89"),也可能是脏数据——带货币符号、带单位、带空格,比如"¥1,234,567.89 元"。这类值进函数的第一件事必须是归一化,而不是直接Number()一把梭,因为Number("¥1,234.56")的结果是NaN,而NaN一旦流进渲染层,页面上就会出现一个刺眼的 NaN 或者空白。
第二种是页面展示值。它需要千分位分隔符、固定的小数位数、负数带减号、零值显示0.00,空值显示--(具体用什么符号看设计规范,但必须有区分)。这里有个很容易被忽略的点:0.00和--在业务上完全不是一回事。0.00代表"确实是零",--代表"数据缺失或者不适用"。如果两者都显示成0.00,客服第二天就会收到"我明明没有这笔账为什么显示零"的工单。
第三种是用户输入值。输入框里边打字边加逗号,光标不能乱跳,退格键要能正常删除,粘贴一大串数字也要能自动整理。这块是最容易被低估的,后面第 5 章会单独讲。
第四种是导出值和计算值。这类场景下千分位逗号是纯粹的灾难。CSV 里写1,234.56,如果没有给字段加引号,Excel 打开时会把它拆成两列;Number("1,234.56")的结果是NaN;把这些字符串相加会得到字符串拼接而不是数值求和。所以在整个链路里,格式化必须是一个"只在最后一公里调用"的纯展示函数,绝不能让它污染数据层。
注意:格式化函数只负责"把数字变成给人看的字符串",任何参与运算的环节都要用原始数字或者分单位的整数。这条边界一旦糊掉,后面所有问题都是连锁反应。
1.2 四条主流实现路线的横向对比
把需求拆完之后,实现路线其实就那么四条。我把它们的差异整理成一张表,这张表是我在几个项目里反复权衡之后定下来的判断,你可以直接拿去做选型参考。
| 路线 | 代表写法 | 舍入表现 | 代码量 | 性能量级 | 最适用的场景 |
|---|---|---|---|---|---|
| 正则替换 + toFixed | n.toFixed(2).replace(...) | 受二进制影响,1.005 得 1.00 | 最少,一行 | 快 | 展示型页面,金额来源可信 |
| 字符串手动进位 | 拆整数位和小数位逐位处理 | 按十进制语义,1.005 得 1.01 | 中等,六十行左右 | 较快 | 对账、财务、导出 |
| Intl.NumberFormat | new Intl.NumberFormat(...) | 同样受二进制影响,1.005 得 1.00 | 少,两三行 | 单次偏慢,缓存后可用 | 多币种、国际化 |
| 第三方数值库 | numeral、accounting、decimal.js | 看具体库实现,decimal 系列最稳 | 要引依赖 | 看库 | 已有依赖,或者精度要求极端 |
这四条的差别,说白了就是在"少写代码"和"算得对"之间选一个位置。正则加 toFixed 那一条,代码短到可以背下来,但它在1.005这类数上给出的答案和财务同事手算的答案不一致;字符串手动进位那一条,代码长一点,但它的行为和"人拿笔在纸上算"完全一致。Intl 那一条的优势是能自动适配不同地区的写法,比如德语区会用1.234.567,89这种反过来点的格式,但它的舍入仍然是基于数值的二进制真实值,救不了舍入的坑。
1.3 我的选型结论
综合下来,我给项目定的是"双实现"策略。页面展示走字符串进位版本,因为它能保证1.005这类边界值和业务方的预期一致;只有在做国际化和多币种的时候,才切到 Intl 版本,并且是带实例缓存的版本。第三方库只在项目里本来就有依赖时才考虑,为了一个格式化函数引一个几十 KB 的包,性价比不高。
还有一个理由值得单独说:字符串版本不依赖任何运行时特性,我把这段函数抄到小程序、Node 脚本、甚至老项目的兼容代码里都能跑,行为完全一致。这种确定性在跨端项目里比省下的那几十行代码值钱得多。
2. 挖坑:浮点数、舍入和千分位背后的三个真陷阱
2.1 为什么金额不能用浮点数直接算
先看几个每天都会发生的现象。在浏览器控制台里敲0.1 + 0.2,会得到0.30000000000000004;敲1.005 * 100,会得到100.49999999999999;敲0.29 * 100,会得到28.999999999999996。这不是 JS 的 bug,是 IEEE 754 双精度浮点数的固有特性:计算机用二进制分数存储小数,而0.1、1.005这些十进制小数在二进制下是无限循环的,只能存成一个非常接近的近似值。
用一个生活类比来解释:这就像用只能装到毫米刻度的尺子去量一根 1.005 毫米粗的线,你只能量到 1.004 或者 1.006,具体偏向哪边取决于尺子的刻度怎么分。计算机存储小数时,"刻度"是 2 的负 n 次方,所以十进制小数落进去之后,有的会略大一点,有的会略小一点。1.005属于后者,它的真实值是1.0049999999999998934...,比 1.005 略小。
这个"略小"直接决定了后面所有舍入行为的走向。因为你看到的1.005只是一个打印出来的最短字符串,程序内部实际参与计算的那个数比它小一点点,所以任何"看数值大小取整"的算法,都会把它当成小于 1.005 来处理,于是保留两位小数时向下取到1.00。
对金额场景来说,这个问题的严重程度取决于你在哪一层犯错。如果只是页面展示差一分钱,业务方忍忍也就过去了;但如果这个值参与了退款金额计算、对账差额比对、发票金额生成,那差一分钱就是明细对不平,会牵出成串的排查工单。所以凡是涉及钱的系统,业界普遍做法是把金额存成"分"为单位的整数,用整数做加减乘除,只在最后展示的时候才转回十进制小数。这个思路后面第 5 章还会再展开。
2.2 toFixed 和 Intl 都救不了 1.005
很多人第一反应是用toFixed解决,觉得既然它叫"固定小数位",那它总该按四舍五入来吧。实测结果是这样的:
(1.005).toFixed(2) // "1.00" (2.675).toFixed(2) // "2.67" (1.335).toFixed(2) // "1.33" (1.345).toFixed(2) // "1.35" <- 这个又对了看最后两行,1.335向下、1.345向上,这种"同样是 x.x5 结尾却结果不一致"的现象,正是二进制近似在背后作祟。toFixed的实现是先取这个数的二进制真实值,然后按照"取最接近的、如果一样近就取较大的那个"这条规则来决定结果。因为它拿到的1.005实际上是1.0049999...,它当然应该舍到1.00。
网上一度流传一个补丁写法:
Math.round((num + Number.EPSILON) * 100) / 100;这个补丁在1.005上确实能给出1.01,因为Number.EPSILON是2.220446049250313e-16,加上去刚好把1.0049999...推进到了1.0050000000000001。但它的本质是"加一个极小的常数把误差顶过去",这个常数在数值大小约等于 1 的时候管用,一旦数值到了百万级(比如1234567.895),双精度浮点数在这个量级上的最小刻度已经远大于Number.EPSILON,这个补丁就完全失效了。所以我不建议在正式项目里用它,它属于"看起来能跑,但没人能说清什么时候会崩"的写法。
那 Intl 呢?
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1.005); // "1.00"结果一样。因为Intl.NumberFormat拿到的也是那个二进制真实值,它默认的roundingMode是halfExpand,规则本身没问题,问题在输入。这说明换工具解决不了问题,要么改变数据的来源(用分单位整数),要么改变处理数据的方式(用字符串语义做十进制舍入)。
注意:
toFixed还有一个隐蔽限制,当数值绝对值大于等于 1e21 时,它会返回科学计数法字符串,比如(1e21).toFixed(2)得到"1e+21"。这种字符串再交给千分位正则处理,结果会非常离谱。金额场景虽然很少遇到这么大的数,但接口字段一旦被错误赋值(比如时间戳塞进了金额字段),就真会出现。
2.3 千分位正则为什么会在小数点后面乱插逗号
最流行的千分位写法是这个:
'1234567'.replace(/\B(?=(\d{3})+$)/g, ','); // "1,234,567"它靠两个条件定位插入点:\B表示这里不是单词边界,(\d{3})+$表示从这个位置往右,剩余的数字个数正好是 3 的整数倍直到结尾。对纯整数来说这个逻辑完美。
问题出在它被套在带小数的字符串上,而且小数位超过两位的时候:
'1234567.8912'.replace(/\B(?=(\d{3})+$)/g, ','); // "1,234,567.8,912"小数点后面也被插了一个逗号。原因很直白:它在小数点后第三位找到了一个"右边还剩 3 位数字到结尾"的位置,条件成立,于是插了进去。而当小数位正好是两位时,小数点后的两位不满足"3 位整数倍",所以侥幸不会插错——这就是为什么很多人写完测了一下1234.56觉得没问题,直到某天接口多返了四位小数才炸掉。
正确的做法是先把整数部分和小数部分拆开,只对整数部分做替换:
function groupInteger(intStr) { return intStr.replace(/\B(?=(\d{3})+$)/g, ','); } groupInteger('1234567'); // "1,234,567"这里的$锚定的是被单独传进来的这个纯整数字符串的结尾,小数点根本不在处理范围内,天然免疫。这个拆分动作看起来多了一步,实际上它同时解决了负数的问题:'-1234567'里的减号不是数字,\B在减号和 1 之间不成立,所以不会出现-,123,456这种笑话。
2.4 负数、零值、空值与超大数的边界清单
边界值这件事,我在两个项目里都吃过亏,所以现在写格式化函数一定会先列一张清单再动手。下面这张表是我现在实际在用的版本。
| 输入 | 期望输出 | 处理要点 |
|---|---|---|
-1234.5 | -1,234.50 | 符号单独提取,只对绝对值做分组和补位 |
-0.004 | 0.00 | 舍入后是零就必须丢掉负号,不能出现-0.00 |
0 | 0.00 | 不要走"假值判断"分支,if (!num)会把 0 当成空值 |
null/undefined | -- | 和 0.00 严格区分 |
'¥1,234.56' | 1,234.56 | 归一化时清掉货币符号、千分位逗号和空格 |
NaN/Infinity | -- | 用Number.isFinite判断,别用isNaN全局函数 |
1e21 | 1,000,000,000,000,000,000,000.00 | 要先展开科学计数法再处理 |
超过MAX_SAFE_INTEGER | 走字符串或 BigInt 分支 | 9007199254740991是安全整数上限 |
这里面最容易翻车的是第三行和第一行。第三行的问题在于,很多人写归一化时会顺手写if (!value) return fallback;,这一句会把0也拦下来变成--。第一行的问题在于,-0.001舍入到两位小数后是0.00,但如果符号是在舍入前就拼上去的,输出就会变成-0.00,这个字符串在页面上看起来像程序出了 bug,在测试同学那里一定是个提缺陷的理由。所以符号必须在舍入完成后、并且判断结果不为零的情况下才拼回去。
3. 动手写:从最短实现到可复用工具函数
3.1 二十行版本:正则加 toFixed 的快速通路
如果你只是要一个能立刻用的版本,金额来源也可信(比如后端已经保证只返回两位小数),那这个版本够用,我把它放在项目里当"轻量工具":
/** * 轻量版金额格式化 * @param {number|string} value * @param {number} decimals 小数位,默认 2 * @param {string} fallback 非法值的兜底显示 */ function formatMoneyLite(value, decimals = 2, fallback = '--') { const num = Number(value); if (!Number.isFinite(num)) return fallback; const fixed = num.toFixed(decimals); const dot = fixed.indexOf('.'); const intPart = dot === -1 ? fixed : fixed.slice(0, dot); const decPart = dot === -1 ? '' : fixed.slice(dot + 1); const grouped = intPart.replace(/\B(?=(\d{3})+$)/g, ','); return decPart ? `${grouped}.${decPart}` : grouped; }拆开看就四步。第一步归一化,Number(value)同时处理数字和字符串,Number.isFinite一次性把NaN、Infinity、-Infinity全部拦掉,比isNaN更严谨——isNaN("abc")会先做隐式转换再判断,容易误伤。第二步用toFixed定小数位,这一步就是前面说的舍入问题的来源,属于"我知道它有坑但场景可控"的取舍。第三步按小数点位置切分,这个切分是为了让千分位正则只作用在整数部分。第四步拼回去。
这个函数的性能很好,因为它只做字符串操作加一次toFixed,没有正则回溯,没有对象创建。在需要渲染上千行数据的表格里,这个版本的差别是能感觉出来的。它的代价就是前面说的1.005问题,以及1e21以上会拿到科学计数法字符串。如果你的金额全部来自后端且已经被约束在两位小数,那这两个坑基本碰不到。
3.2 稳健版本:全字符串运算,把浮点误差挡在门外
真正要对付财务口径的舍入,思路得换一下:不要拿数字去算,拿字符串去算。核心观察是——(1.005).toString()返回的是"1.005",也就是那个"人本来想写的十进制数",而不是"1.0049999999999999"。因为 JS 在把数字转成字符串时,会输出能唯一还原该数字的最短字符串。这个特性正好可以被我们利用:既然打印出来是1.005,那就按1.005去处理,业务方的预期就满足了。
完整实现如下,我按职责拆成了几个小函数,每个都单独可测:
/** * 金额格式化(字符串进位版) * 思路:把数字当成十进制字符串处理,逐位进位,规避二进制浮点误差 */ function formatMoney(value, opts = {}) { const { decimals = 2, thousandsSep = ',', decimalSep = '.', prefix = '', suffix = '', fallback = '--', roundMode = 'half-up' // half-up 四舍五入 | truncate 直接截断 } = opts; const normalized = normalizeMoney(value); if (normalized === null) return fallback; const { sign, digits } = normalized; // digits 是展开后的十进制字符串 const dot = digits.indexOf('.'); let intPart = dot === -1 ? digits : digits.slice(0, dot); let decPart = dot === -1 ? '' : digits.slice(dot + 1); if (roundMode === 'half-up') { const rounded = roundHalfUp(intPart, decPart, decimals); intPart = rounded.intPart; decPart = rounded.decPart; } else { decPart = decPart.slice(0, decimals).padEnd(decimals, '0'); } const grouped = intPart.replace(/\B(?=(\d{3})+$)/g, thousandsSep); const decText = decimals > 0 ? decimalSep + decPart : ''; const isAllZero = !/[1-9]/.test(intPart + decPart); const signText = sign === '-' && !isAllZero ? '-' : ''; return `${signText}${prefix}${grouped}${decText}${suffix}`; }接下来是三个辅助函数。第一个负责归一化,把各种脏输入整理成"符号 + 展开后的十进制数字字符串":
function normalizeMoney(value) { if (value === null || value === undefined || value === '') return null; let num; if (typeof value === 'string') { // 清掉千分位逗号、货币符号、空格、全角空格 const cleaned = value.replace(/[,\s\u00a5\uffe5$\u3000]/g, ''); if (cleaned === '' || cleaned === '-' || cleaned === '.') return null; num = Number(cleaned); } else { num = value; } if (typeof num !== 'number' || !Number.isFinite(num)) return null; const sign = num < 0 ? '-' : ''; return { sign, digits: toPlainString(Math.abs(num).toString()) }; }第二个负责把科学计数法展开成普通十进制字符串。这一步是很多实现直接跳过的地方,跳过就意味着1e21这类输入会走进分组正则然后输出一堆乱七八糟的东西:
function toPlainString(numStr) { if (!/[eE]/.test(numStr)) return numStr; const [mantissa, expPart] = numStr.split(/[eE]/); const exp = parseInt(expPart, 10); const [intRaw, fracRaw = ''] = mantissa.split('.'); const digits = intRaw + fracRaw; const pointPos = intRaw.length + exp; if (pointPos <= 0) { return '0.' + '0'.repeat(-pointPos) + digits; } if (pointPos >= digits.length) { return digits + '0'.repeat(pointPos - digits.length); } return digits.slice(0, pointPos) + '.' + digits.slice(pointPos); }第三个是核心的十进制四舍五入。它的逻辑和人在纸上做竖式进位一模一样:看保留位后面的那一位,小于 5 直接截断,大于等于 5 就从最低位开始往前加一,遇到 9 就变 0 继续往前推,推到最前面还溢出就补一个 1:
function roundHalfUp(intPart, decPart, decimals) { if (decPart.length <= decimals) { return { intPart, decPart: decPart.padEnd(decimals, '0') }; } const keep = decPart.slice(0, decimals); const nextDigit = decPart.charCodeAt(decimals) - 48; if (nextDigit < 5) { return { intPart, decPart: keep }; } // 从最低位开始逐位加一 const digits = (intPart + keep).split(''); let i = digits.length - 1; while (i >= 0) { const d = digits[i].charCodeAt(0) - 48 + 1; if (d < 10) { digits[i] = String(d); break; } digits[i] = '0'; i -= 1; } if (i < 0) digits.unshift('1'); // 99.995 这类进位溢出 const joined = digits.join(''); const cut = joined.length - decimals; const newInt = joined.slice(0, cut).replace(/^0+(?=\d)/, '') || '0'; const newDec = joined.slice(cut); return { intPart: newInt, decPart: newDec }; }注意roundHalfUp里的一个隐含取舍:它只看保留位的下一位,不关心再往后还有没有数字。这是标准的"四舍五入",和"银行家舍入"(遇到 5 看前一位奇偶)不一样。对账场景里,业务方拿计算器按出来的就是四舍五入,所以这里用 half-up 是对的。如果哪天接口方明确了要用银行家舍入,那就在这个函数里改判断条件,改动面很小。
提醒:这个实现里
digits[i].charCodeAt(0) - 48这种写法比parseInt快不少,因为在循环里跑几十次的时候,parseInt的字符串解析开销会累积出来。可读性上差一点,但加一行注释就够了。
3.3 现代版本:Intl.NumberFormat 与实例缓存
如果项目要做多币种,或者需要跟着用户的语言环境自动切换写法,Intl.NumberFormat是绕不开的。基础用法很短:
new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: true }).format(1234567.891); // "1,234,567.89"再进一步,用style: 'currency'可以直接带上货币符号:
new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(1234567.891); // "¥1,234,567.89"好处是这些格式规则不是硬编码的,而是跟随运行时提供的区域数据。换成'de-DE'就自动变成1.234.567,89,换成'en-IN'会按印度的分组习惯输出12,34,567.89。这一点自己写正则很难覆盖全,因为印度的分组规则是"前三位一组,之后每两位一组",不是简单每三位。
但Intl.NumberFormat有个必须知道的性能特性:构造实例比调用format贵得多。一个实例构造出来之后是可以复用的,所以正确做法是按配置缓存实例,而不是在循环里每次都new一个。在长列表里,如果不缓存,光是构造开销就能让滚动明显掉帧。
const formatterCache = new Map(); function getMoneyFormatter(decimals = 2, locale = 'zh-CN', useGrouping = true) { const key = `${locale}|${decimals}|${useGrouping}`; let formatter = formatterCache.get(key); if (!formatter) { formatter = new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, useGrouping }); formatterCache.set(key, formatter); } return formatter; } function formatMoneyIntl(value, decimals = 2, locale = 'zh-CN') { const num = Number(value); if (!Number.isFinite(num)) return '--'; return getMoneyFormatter(decimals, locale).format(num); }缓存键里带上locale和decimals是必要的,因为这两个参数一变,格式化结果就完全不同。用Map而不是普通对象做缓存,是为了避免污染Object.prototype上的键名,也算是个小习惯。
3.4 参数设计与配置项说明
工具函数要考虑的不是"今天能不能用",而是"半年后别人接手能不能看懂、能不能改"。所以我给最终版本定义了一套明确的配置项,每个参数的含义都写进注释里:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
decimals | number | 2 | 保留的小数位数,0 表示不留小数 |
thousandsSep | string | ',' | 千分位分隔符,导出场景可传空串关闭分组 |
decimalSep | string | '.' | 小数点符号,欧洲部分场景用',' |
prefix | string | '' | 前缀,比如货币符号'¥' |
suffix | string | '' | 后缀,比如单位'元' |
fallback | string | '--' | 非法值、空值的兜底显示 |
roundMode | string | 'half-up' | half-up四舍五入,truncate直接截断 |
thousandsSep可以传空串这一点,是专门为导出场景留的口子。同一个函数,页面上传',',导出时传'',就不用在两个地方维护两套逻辑,也不会出现"导出忘了去逗号"这种低级事故。这种把差异点做成参数的做法,比复制一份函数改两行要可靠得多。
3.5 实测:三套实现的输出对照
下面这张表是我在控制台里一条一条跑出来的结果对照,重点看1.005和1234567.895这两行的差异,那正是三种方案的分水岭。
| 输入值 | 轻量版(toFixed) | 字符串进位版 | Intl 版 |
|---|---|---|---|
1234567.891 | 1,234,567.89 | 1,234,567.89 | 1,234,567.89 |
1.005 | 1.00 | 1.01 | 1.00 |
2.675 | 2.67 | 2.68 | 2.67 |
1234567.895 | 1,234,567.90 | 1,234,567.90 | 视实现细节可能为1,234,567.89 |
-0.004 | -0.00 | 0.00 | -0.00 |
-1234.5 | -1,234.50 | -1,234.50 | -1,234.50 |
0 | 0.00 | 0.00 | 0.00 |
null | -- | -- | -- |
'¥1,234.56' | NaN | 1,234.56 | NaN |
1e21 | "1e+21" | 1,000,000,000,000,000,000,000.00 | 长串数字 |
-0.004那一行值得多说一句:轻量版和 Intl 版都会输出-0.00,因为它们的符号是在格式化之前就交给数字处理了。-0.00在页面上看起来就像程序算错了,测试同学基本都会提一个缺陷。字符串版在最后拼符号之前做了全零判断,所以能正确输出0.00。
'¥1,234.56'那一行也是实际发生过的。后端换了供应商之后,金额字段开始带货币符号,前端页面瞬间出现一片NaN。字符串版因为做了归一化清洗,直接把符号和逗号都清掉,反而没事。这也说明归一化这层看着多余,其实是性价比最高的一层防护。
4. 排查:我踩过的坑与常见问题速查
4.1 问题速查表
线上出问题的时候,能快速定位比什么都重要。下面这张表是我按"现象 → 原因 → 处理"整理的,遇到类似症状可以直接对号入座。
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
页面出现NaN | 输入是带符号或逗号的字符串,直接Number()得到NaN | 归一化时先清洗非数字字符 |
出现-0.00 | 符号在舍入前就拼接了 | 舍入完成后判断结果是否全零再拼符号 |
| 小数位后面多出逗号 | 千分位正则作用在了包含小数点的整串上 | 先按小数点拆成整数位和小数位 |
1.005格式化后是1.00 | 二进制近似导致实际值略小于 1.005 | 改用字符串进位版 |
超大数变成1e+21 | toFixed对 1e21 以上返回科学计数法 | 先展开科学计数法再分组 |
| 表格滚动卡顿 | 每次渲染都new Intl.NumberFormat | 按配置缓存实例 |
| 导出文件列错位 | CSV 里的金额带了千分位逗号 | 导出时把分隔符传成空串 |
| 金额参与计算后结果异常 | 拿格式化后的字符串去做了运算 | 数据层保留原始数字,只展示层格式化 |
零值显示成-- | 归一化时用了if (!value)这类假值判断 | 显式判断null、undefined、空串 |
这张表里,我最想强调的是倒数第二行。格式化函数被复用去做计算,是很常见的"顺手"行为,尤其在一些老代码里,formatMoney被当成"数字处理工具"在用。改造的时候一定要把这类调用点全部找出来,否则就是把展示逻辑的坑引到了业务逻辑里。
4.2 六个真实踩坑记录
第一个坑是if (!value)拦掉了零。这个坑我在两个项目里都遇到过。当时的场景是还款计划表,某一期的应还金额是 0,结果页面上显示了--,客服打电话来问"这一期到底要不要还"。修法很简单,把假值判断改成显式判断:
// 错误写法:0 会被当成空值 if (!value) return fallback; // 正确写法 if (value === null || value === undefined || value === '') return fallback;第二个坑是千分位正则切到了小数位。前面已经讲过原理,这里补充一个排查技巧:如果你怀疑是正则问题,直接把输入构造成小数位超过两位的测试用例跑一遍,比如1234.5678,如果输出里小数点后面出现了逗号,那就是这个原因。
第三个坑是导出 CSV 时列错位。我们有个报表导出功能,页面上显示得好好的1,234.56,导出的 CSV 用 Excel 打开时被拆成了两列,运营同事直接把文件甩群里问怎么回事。原因就是 CSV 的字段分隔符是逗号,而金额里也带了逗号,且没有给字段加引号。两个修法:一是导出时把thousandsSep传成空串,金额输出纯数字;二是给所有字段加双引号包裹。我选了前者,因为报表本来就是给程序继续处理的,加引号反而会干扰下游的解析逻辑。
第四个坑是长列表卡顿。有个订单列表页,每行有四个金额字段,一屏渲染五十行就是两百次格式化。最初的实现里每次都在函数内部new Intl.NumberFormat,滚动的时候明显看到掉帧。改成缓存实例之后,掉帧消失。判断是不是这个原因,可以在格式化函数里加一行计数,看一秒内被调了多少次。
第五个坑是负数舍入方向不符合业务预期。这里要说清楚:Math.round对负数是"向正无穷方向取整",Math.round(-1.5)的结果是-1,而不是-2。财务口径通常希望绝对值四舍五入,也就是-1.5应该变成-2。所以在实现里必须先取绝对值处理,最后再把符号加回去。我的字符串版本天然就是按这个逻辑写的,因为符号在一开始就被剥离出来了。
第六个坑是和后端约定不一致。有一次对账差额显示0.01,前端后端各查了半天,最后发现后端返回的金额精度是四位小数,前端在展示时做了四舍五入,而后端在算差额时用的是截断。两边的舍入策略不一样,就会出现"明明对平了却显示差一分"的情况。这件事之后我们定了一条规矩:金额的舍入策略必须前后端书面约定,并且在接口文档里写清楚,不能靠口口相传。
4.3 性能与调用场景建议
关于性能,我不想给一堆看起来很精确的毫秒数,因为那些数字在不同机器和不同 JS 引擎上差异很大,写出来反而误导人。我只说相对量级,这是我在实际项目里反复观察得出的结论:正则替换版是最快的,字符串进位版大概是它的三到五倍耗时(多的是字符串拆分和逐位循环),缓存过的 Intl 版本大概是八到十五倍,而没有缓存的 Intl 版本能到百倍以上。
这些数字听起来吓人,但要放到实际场景里看。单次调用两三微秒和单次二十微秒,在一个渲染周期里差别是可以忽略的;只有在一次渲染超过一百次调用、并且伴随滚动或者动画的时候,差别才体现出来。所以我的建议是:
- 普通详情页、表单回显,用哪个版本都行,选可读性最好的。
- 长列表、虚拟滚动、大数据量表格,优先正则版或者字符串版,并且一定要缓存。
- 对账、财务、导出这类"错一分就是事故"的场景,老老实实用字符串进位版。
- 如果金额会参与后续计算,压根不要在数据层格式化,把格式化推到渲染那一刻。
5. 延展:反向解析、输入框实时格式化与框架集成
5.1 把 1,234.56 还原成数字
有格式化就一定会有反向需求:用户编辑的时候拿到的可能是带逗号的字符串,提交给后端又必须是纯数字。这个解析函数比格式化简单,但有两个细节要注意。
function parseMoney(text) { if (text === null || text === undefined) return NaN; const cleaned = String(text).replace(/[^\d.-]/g, ''); if (cleaned === '' || cleaned === '-' || cleaned === '.') return NaN; if (cleaned.indexOf('-') > 0) return NaN; // 减号只能出现在开头 return Number(cleaned); } parseMoney('1,234.56'); // 1234.56 parseMoney('¥1,234.56'); // 1234.56 parseMoney('--'); // NaN parseMoney('-0.004'); // -0.004第一个细节是减号校验。如果不检查减号位置,"1-234"这种脏数据会被清洗成"1-234",然后Number("1-234")得到NaN,虽然结果对了但原因很隐蔽。加一行位置判断能让问题暴露得更早。
第二个细节是千位分隔符和小数点符号的歧义。"1.234,56"在欧洲写法里是 1234.56,但在中文和英文写法里会被理解成 1.23456 或者干脆解析失败。这个没有通用解,只能靠业务约定:如果系统确定只处理中文环境数据,那就按点号作小数处理;如果要做国际化,就得先探测格式再解析,或者干脆在传输层约定统一用无分隔符的字符串。
提醒:反向解析的结果是一个浮点数,如果这个值后面还要参与金额计算,最好立即转成分单位的整数再做运算。
parseMoney('0.29') * 100得到的可能是28.999999999999996,拿去Math.round虽然能修回来,但这种依赖巧合的写法不如直接处理整数来得踏实。
5.2 输入框边输边加逗号,以及光标跳动怎么治
输入框实时格式化是这类需求里最难缠的部分,难点不在格式化本身,而在光标位置。假设输入框里是1,234,光标在末尾,用户按退格删掉一个字符,value 变成1,23。如果这时候直接把它格式化回1,23(因为还没到三位),看起来没问题;但如果当前是1,234,567,用户想删掉中间那个逗号,格式化函数会立刻把逗号加回去,光标位置也会跳到末尾,用户体验就彻底崩了。
解法是记录"光标左边有多少个数字字符",格式化完成之后再按数字个数把光标放回去。数字的位置不受逗号增删影响,所以这个映射是稳定的:
function handleMoneyInput(event) { const input = event.target; const rawValue = input.value; const cursor = input.selectionStart; // 1. 记录光标前面有多少个数字 const digitsBeforeCursor = (rawValue.slice(0, cursor).match(/\d/g) || []).length; // 2. 清洗并格式化(注意这里的入参是字符串,末尾的小数点要保留) const cleaned = rawValue.replace(/,/g, ''); input.value = formatMoneyKeepingTrailingDot(cleaned); // 3. 按数字个数把光标放回去 let count = 0; let pos = 0; for (; pos < input.value.length; pos += 1) { if (/\d/.test(input.value[pos])) { count += 1; if (count === digitsBeforeCursor) { pos += 1; break; } } } input.setSelectionRange(pos, pos); }其中formatMoneyKeepingTrailingDot是个特例处理版本,它和普通格式化函数的区别在于:当用户刚敲下小数点、后面还没有数字时,不能把小数点吃掉,否则用户根本没法输入1.然后继续敲5。这类"输入中间态"的处理,是实时格式化和纯展示格式化的最大区别,也建议把这两个函数分开,不要试图用一个函数兼容两种场景,那样只会让两个场景都变得难维护。
另外还有一个细节:用input事件而不是keydown,因为keydown拿不到最终的 value,而且在处理粘贴、拖拽、输入法组合输入的时候会漏掉事件。用中文输入法打字时,还要处理compositionstart和compositionend事件,在组合输入期间暂停格式化,否则用户还没选完字就被格式化打断,输入体验会很糟。
5.3 在 Vue 和 React 里怎么复用
格式化函数本身是纯函数,集成到框架里就三层考虑:放哪里、怎么缓存、怎么避免重复计算。
Vue 3 里最顺手的是抽成一个 composable,把响应式和格式化分开:
// useMoneyFormat.js import { computed } from 'vue'; export function useMoneyFormat(source, options = {}) { return computed(() => formatMoney(source.value, options)); }组件里用的时候是const displayAmount = useMoneyFormat(amountRef),模板里直接{{ displayAmount }}。computed自带依赖追踪和缓存,amountRef不变就不会重复计算,这个特性在长列表里很关键。
React 里对应的做法是把它放在渲染层之外,或者用useMemo包一层:
function MoneyText({ value, decimals = 2, fallback = '--' }) { const text = useMemo( () => formatMoney(value, { decimals, fallback }), [value, decimals, fallback] ); return <span className="money">{text}</span>; }这里要提醒一个容易犯的错:不要在 React 组件里把格式化结果再塞回 state。格式化是纯计算,塞进 state 只会多一次渲染,还可能因为 state 更新时机问题导致显示和真实值不同步。另外options对象如果是每次渲染都新建的字面量,useMemo的依赖判断会失效,那种情况下要把decimals、fallback这类字段拆成独立的原始类型依赖,别把整个对象丢进依赖数组。
如果项目里有大量类似的数字展示需求(百分比、文件大小、时长),可以考虑把这一组格式化函数放到同一个模块里,统一导出。好处是格式规则集中管理,哪天产品说"所有金额一律保留两位小数",改一处就够了,不用去各个组件里搜toFixed(2)。
5.4 超大金额与高精度场景的兜底思路
最后说一个容易被忽略的场景:金额超过Number.MAX_SAFE_INTEGER,也就是9007199254740991。这个数大概是九千万亿,单个账户的余额不太可能到这个量级,但在统计汇总、年度总流水、大促累计 GMV 这类场景下,超过这个数字并不稀奇。一旦超过,Number类型就无法再精确表示每一个整数,加法可能直接丢精度。
这种场景下的稳妥做法是把金额统一表示成"分"为单位的整数,用BigInt做运算,只在展示时转成小数字符串。下面是一个只处理分单位整数字符串的格式函数:
function formatCents(centsText, decimals = 2, thousandsSep = ',') { let big = BigInt(String(centsText).replace(/[^\d-]/g, '') || '0'); const negative = big < 0n; if (negative) big = -big; const base = 10n ** BigInt(decimals); const intPart = (big / base).toString(); const decPart = (big % base).toString().padStart(decimals, '0'); const grouped = intPart.replace(/\B(?=(\d{3})+$)/g, thousandsSep); const isAllZero = !/[1-9]/.test(intPart + decPart); const signText = negative && !isAllZero ? '-' : ''; return decimals > 0 ? `${signText}${grouped}.${decPart}` : `${signText}${grouped}`; } formatCents('123456789012345678901'); // "1,234,567,890,123,456,789.01"这个函数和前面那套字符串实现的思路是一脉相承的:全部在十进制语义下做运算,不碰浮点数。区别只是运算工具从"手写逐位循环"换成了BigInt,因为BigInt本身就是精确整数运算,除法取整和取余都能直接给出正确结果,代码反而更短。
我在实际用的时候会把这个函数和前面的formatMoney放在同一个模块里,导出的接口名保持一致,调用方也不用关心底层用的是哪种方案。至于什么时候该切到BigInt版本,我的经验判断是:只要数据来源可能是"聚合后的总数"而不只是"单条记录",就该用BigInt。单条订单金额用浮点没问题,一年流水汇总就一定不要用。
最后再提一个实际工作中总结出来的习惯:格式化函数的单元测试用例,一定要包含1.005、2.675、-0.004、0、null、空串、带货币符号的字符串和1e21这八个输入。这八个值基本覆盖了我这些年遇到的所有边界情况,把它们的期望输出在测试里固化下来,比在文档里写一堆说明可靠得多。