ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JavaScript金额格式化:千分位、两位小数与浮点精度避坑

JavaScript金额格式化:千分位、两位小数与浮点精度避坑 上周改一个对账模块接口返回的金额是 1234567.8912 这种裸数字产品要求页面上必须显示成 1,234,567.89导出 CSV 的时候又必须还原成不带逗号的纯数字。说起来就是 JS 里把金额格式化成千分位加逗号、保留两位小数这点事我前后折腾了快两个小时才把它做干净。坑不在主逻辑上全在细节里浮点数的二进制表示、toFixed 的舍入方向、千分位正则在有小数位时会乱插逗号、负数四舍五入往哪边走、空值和零值要显示成不一样的东西。这些点随便踩中一个第二天测试同学就会拿着截图来找你。这篇东西我打算把这件事从头到尾讲透。什么是金额格式化、它在什么场景下会出问题、几条主流实现路线各自适合谁、每一步为什么这么写以及线上真跑起来之后才暴露出来的那些问题。前端新手可以把它当成一份可以直接抄的实现笔记写过几年的人可以重点看舍入规则和性能那两节那里的坑我基本都踩过一遍。全文给的都是能直接粘进项目里的代码参数含义和实测输出也一并列出来。1. 拆需求这笔账到底要格式化成什么样1.1 业务现场里金额的四种形态很多格式化函数写崩根子不在代码在于没想清楚同一个金额在系统里其实有四种完全不同的形态。把这件事分清楚函数的设计边界自然就出来了。第一种是接口原始值。它可能是 number1234567.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 四条主流实现路线的横向对比把需求拆完之后实现路线其实就那么四条。我把它们的差异整理成一张表这张表是我在几个项目里反复权衡之后定下来的判断你可以直接拿去做选型参考。路线代表写法舍入表现代码量性能量级最适用的场景正则替换 toFixedn.toFixed(2).replace(...)受二进制影响1.005 得 1.00最少一行快展示型页面金额来源可信字符串手动进位拆整数位和小数位逐位处理按十进制语义1.005 得 1.01中等六十行左右较快对账、财务、导出Intl.NumberFormatnew 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)得到1e21。这种字符串再交给千分位正则处理结果会非常离谱。金额场景虽然很少遇到这么大的数但接口字段一旦被错误赋值比如时间戳塞进了金额字段就真会出现。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.0040.00舍入后是零就必须丢掉负号不能出现-0.0000.00不要走假值判断分支if (!num)会把 0 当成空值null/undefined--和 0.00 严格区分1,234.561,234.56归一化时清掉货币符号、千分位逗号和空格NaN/Infinity--用Number.isFinite判断别用isNaN全局函数1e211,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 继续往前推推到最前面还溢出就补一个 1function 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 参数设计与配置项说明工具函数要考虑的不是今天能不能用而是半年后别人接手能不能看懂、能不能改。所以我给最终版本定义了一套明确的配置项每个参数的含义都写进注释里参数类型默认值说明decimalsnumber2保留的小数位数0 表示不留小数thousandsSepstring,千分位分隔符导出场景可传空串关闭分组decimalSepstring.小数点符号欧洲部分场景用,prefixstring前缀比如货币符号¥suffixstring后缀比如单位元fallbackstring--非法值、空值的兜底显示roundModestringhalf-uphalf-up四舍五入truncate直接截断thousandsSep可以传空串这一点是专门为导出场景留的口子。同一个函数页面上传,导出时传就不用在两个地方维护两套逻辑也不会出现导出忘了去逗号这种低级事故。这种把差异点做成参数的做法比复制一份函数改两行要可靠得多。3.5 实测三套实现的输出对照下面这张表是我在控制台里一条一条跑出来的结果对照重点看1.005和1234567.895这两行的差异那正是三种方案的分水岭。输入值轻量版toFixed字符串进位版Intl 版1234567.8911,234,567.891,234,567.891,234,567.891.0051.001.011.002.6752.672.682.671234567.8951,234,567.901,234,567.90视实现细节可能为1,234,567.89-0.004-0.000.00-0.00-1234.5-1,234.50-1,234.50-1,234.5000.000.000.00null------1,234.56NaN1,234.56NaN1e211e211,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改用字符串进位版超大数变成1e21toFixed对 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 classNamemoney{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这八个输入。这八个值基本覆盖了我这些年遇到的所有边界情况把它们的期望输出在测试里固化下来比在文档里写一堆说明可靠得多。
返回列表