资讯动态

前端数字格式化实战:Intl.NumberFormat 完全指南

发布时间:2026/10/20 13:22:30 来源:尧图企业网站定制
1. 数字格式化这件事为什么要在前端反复做1.1 从一版报表需求说起很多前端开发迟早会遇到这样一个需求页面上要展示商品价格、订单金额、库存数量、手续费比例数据从后端接口拿过来往往是1234567.891这种原生态数字。如果直接渲染到页面上不仅丑而且用户根本看不明白——没有千分位分隔不知道这是十二万还是百万小数位数不一致金额对不上语言环境不同数字书写习惯也不一样。我第一次认真对待这个问题是在某个模拟项目做运营报表的时候。接口返回一堆订单量和成交额产品经理要求“金额保留两位小数每三位一个逗号百分比要带百分号负数用括号表示”。当时的第一反应就是正则加一堆字符串拼接写出来也能跑但代码越写越心虚浮点数精度、四舍五入规则、千分位分段、不同地区习惯……每个点都有人在踩坑。后来我在团队代码里看到了Intl.NumberFormat了解完才发现这其实是 JavaScript 原生提供的一套数字格式化 API早就在浏览器里躺着只是很多项目里没被真正用好。它解决的不只是“加逗号”这么一件事而是把数字的多语言、多风格、多单位输出统一管理起来。这篇文章我就把自己实际项目里的使用思路、配置参数、踩坑记录、以及和其他方案对比的结果整理一遍。1.2 你到底在格式化什么样的数字聊 API 之前先划清边界。前端里的数字格式化至少要覆盖这几类场景金额类货币符号、千分位、固定小数位比如¥ 12,345.67。数量类大数字千分位比如1,234,567或者带缩写123.4万。比例类百分比、千分比比如12.34%。工程类科学计数法、有效位数比如1.235E6。序号类两位数字补零之类不过那个通常用padStart就够了不算 Intl 的主场。这些需求以前大家最常写的是手抄公式num.toFixed(2)再正则插入逗号或者用正则/\B(?(\d{3})(?!\d))/g去分割整数部分。单个需求没问题但一旦遇到“保留两位小数但是要四舍五入”“负数要不要带符号”“百万以上显示成 xxx万”“阿拉伯数字和中文数字混排”这些叠加条件手写方案就变得脆弱。Intl.NumberFormat最聪明的设计在于它把“规则”和“执行”分开。你告诉它用什么地区习惯、什么显示风格它内部帮你处理所有规则细节。这篇我会用一个完整项目里最常碰到的配置清单来拆它的用法也会把手写正则方案和它的优缺点摆在桌面上对比方便后续接手的人直接参考。2. Intl.NumberFormat 的设计意图为什么它值得当首选2.1 构造参数拆解两个参数四种能力Intl.NumberFormat是个构造函数基本用法是const nf new Intl.NumberFormat(locales, options);第一个参数locales控制语言区域比如zh-CN表示中文中国、en-US表示美式英语、de-DE表示德语德国。第二个参数options是整形配置对象里面常见的组合如下const nf new Intl.NumberFormat(zh-CN, { style: currency, // 货币、百分数、普通数字 currency: CNY, // 货币代码 minimumFractionDigits: 2, // 最少小数位数 maximumFractionDigits: 2, // 最多小数位数 useGrouping: true, // 是否分组千分位 notation: standard, // 标准计数还是压缩计数 signDisplay: auto // 正负号显示策略 });我在项目里最常挂嘴边的就是四个维度style决定整体风格、currency决定金额符号、fractionDigits决定小数精度、notation决定计数写法。为什么把“区域”和“风格”拆成两层因为数字不是全人类统一的。同一个1234567.8在印度可能被分成12,34,567.8在德语地区小数点和小数点逗号刚好反过来。如果自己写正则你根本不可能为全世界每个地区维护一份分组规则。而Intl内部维护了一套国际化数据它知道每个区域怎么写数字。你的代码不需要知道规则只用告诉它“我要给谁看”这就是它最大的价值。2.2 几个典型配置组的输出效果对照我们先把最常用的一组配置贴出来我建了一个对照表方便实际开发时直接查。目标场景localesoptions输入数字输出结果人民币金额zh-CN{style:currency, currency:CNY}1234567.8¥1,234,567.80美元金额en-US{style:currency, currency:USD}1234567.8$1,234,567.80欧元金额de-DE{style:currency, currency:EUR}1234567.81.234.567,80 €百分比zh-CN{style:percent, maximumFractionDigits:1}0.123412.3%千分位数字en-US{}9876543.219,876,543.21压缩显示zh-CN{notation:compact, maximumFractionDigits:1}1234567123.5万科学计数en-US{notation:scientific}12345671.234567E6这张表我到现在还会贴在团队文档里。每次产品提格式化需求先对着表找一行基本十分钟能确定配置不用再临时试正则。有一点要特别提醒style: percent的时候输入的值是小数表示法0.1234 输出12.3%不是直接把 12.34 变成12.34%。如果你后端直接返回12.34这个数值你得先除以 100 再交给 formatter否则输出会变成1,234%。这个错位我自己在图表组件里踩过一次改了半天才发现问题出在“数值单位理解不一致”。2.3 为什么 format 方法可以被放心复用Intl.NumberFormat实例还有一个很实用的特性它是无状态的。同一份格式化规则不管 format 多少次结果都不会因为上次调用而改变。这意味着你可以在模块顶层创建一个全局实例反复使用不需要每次循环里都 new 一个。const priceFormatter new Intl.NumberFormat(zh-CN, { style: currency, currency: CNY, minimumFractionDigits: 2, maximumFractionDigits: 2 }); // 在循环里复用同一个实例 const totals [1234.5, 5678.9, 9876.1]; const result totals.map(item priceFormatter.format(item)); // 输出[¥1,234.50, ¥5,678.90, ¥9,876.10]这种复用习惯在小页面里看不出差异一旦表格要渲染几百行、图表 tooltip 每秒触发几十次实例复用的收益就非常明显。浏览器内部对Intl的格式化器也有优化缓存同一个实例的 format 方法执行速度明显比反复构建新实例快。这个点后面我专门测过一组数据放到性能小节再说。3. 实操配置明细从订单页到报表中心3.1 订单金额与货币符号处理电商后台的订单列表是最常见的场景。金额显示需要货币符号、千分位、两位小数同时还要注意“不同币种对应不同符号”。这里我一般会封装一个通用的 createMoneyFormatter 函数function createMoneyFormatter(locale, currencyCode, fractionDigits 2) { return new Intl.NumberFormat(locale, { style: currency, currency: currencyCode, minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits }); } const cnyFormatter createMoneyFormatter(zh-CN, CNY); const usdFormatter createMoneyFormatter(en-US, USD); console.log(cnyFormatter.format(123456.789)); // ¥123,456.79 console.log(usdFormatter.format(123456.789)); // $123,456.79注意这里minimumFractionDigits和maximumFractionDigits我是故意设成相同的值这样金额永远保持两位小数不会金额一会儿是¥123.00一会儿是¥123。很多表格里的数字对不齐就是因为没固定小数位。不同地区对货币符号的位置有自己约定比如美元符号在金额前而部分欧洲国家的符号在金额后面。交给Intl.NumberFormat就不用自己写判断。有人会担心“我就是想强制人民币符号显示在数字前面怎么办”那你直接用zh-CN加CNY它自然会放在前面如果你必须在en-US情况下显示人民币输出的就不是CN¥而是CN¥1,234.56国际习惯如此不用强扭。再提醒一个做导出 Excel 时常遇到的坑Excel 单元格里如果直接塞Intl格式化后的字符串导出文件里看到的也是一串字符串不是可计算的数字。一般做法是导出时传原始数字同时另带 one 列展示格式化文本如果只允许一列就要在导出工具里另行处理显示格式。这个属于业务实现不是Intl本身的问题但架不住每次都有同学在联调时踩。3.2 百分比和千分比的正确姿势报表里现金流量比例、环比增长、库存周转率这类指标后端给的通常是小数。直接在页面里乘 100 再加%是常见做法但很粗糙。用Intl.NumberFormat可以顺带控制小数位和千分位const percentFormatter new Intl.NumberFormat(zh-CN, { style: percent, minimumFractionDigits: 1, maximumFractionDigits: 1, signDisplay: exceptZero // 正数也显示加号 }); console.log(percentFormatter.format(0.234)); // 23.4% console.log(percentFormatter.format(-0.1256)); // -12.6% console.log(percentFormatter.format(0.08)); // 8%signDisplay是一个很容易被忽略但很实用的配置。默认值是auto意思是负数显示负号、正数不显示加号。想强调“增长”场景可以设成exceptZero正数也会带上。我做同比分析组件的时候尤其喜欢这个配置一眼就能分辨增长率是正还是负。注意0本身不会带符号这符合通用阅读习惯。百分比还有一个隐藏需求要不要分组。如果百分比数值本身就很大比如12345.6%你希望展示成12,345.6%还是12345.6%useGrouping在这里起作用默认是true。这块业务上没标准答案产品说怎样就怎样但配置能力得知道。千分比{ style: unit, unit: percent }和百分比不一样这里不建议混淆。实际业务里很少展示千分比所以多数项目里直接 ‰就行了不值得为它单独维护一套方案。3.3 万元单位与 compact 压缩显示给管理后台做首页大屏时经常要求“成交量显示成 xx 万”。这时候notation: compact就很好用const compactFormatter new Intl.NumberFormat(zh-CN, { notation: compact, maximumFractionDigits: 1, compactDisplay: short }); console.log(compactFormatter.format(2340000)); // 234万 console.log(compactFormatter.format(2345678)); // 234.6万compactDisplay有两个候选short输出234万long会输出234.6 万之类的长写法。我一般只用short因为大屏空间宝贵。在实际项目里这种格式适合概览卡片和图表坐标轴但不能用在明细列表里——明细要求精确到个位压缩显示会丢精度。我见过某次列表里误用了 compact订单总金额显示成987.7万看起来没问题但导出明细时对不上账排查半天才定位到格式化层。经验教训是概览用压缩明细则必须标准。4. formatToParts 和更细的控制切面4.1 从字符串到结构化部件大多数情况下format()返回一个字符串就够用了。但有些场景需要我们把数字的整数部分、小数部分、货币符号、分组分隔符拆开自己拼装到不同 DOM 节点或图表文字里。比如大屏上想突出显示整数部分、弱化小数部分。这时就需要formatToParts()。它把格式化结果拆成一个数组每一项有type和value两个字段const nf new Intl.NumberFormat(zh-CN, { style: currency, currency: CNY }); console.log(nf.formatToParts(12345.67)); // 输出近似 // [ // { type: currency, value: ¥ }, // { type: integer, value: 12 }, // { type: group, value: , }, // { type: integer, value: 345 }, // { type: decimal, value: . }, // { type: fraction, value: 67 } // ]拿到这个结构后处理逻辑就变得非常直观。你可以只取integer部分拼在一起也可以把group分隔符替换成空格或窄空格甚至可以按 type 给不同 DOM 节点加不同样式。4.2 用 formatToParts 实现分段高亮某次做图表 tooltip 时产品要求金额数字里的整数部分加粗、小数部分灰色、货币符号红色。直接拿到字符串去做 split 非常难看因为你不知道分隔符是什么、货币符号在哪个位置。如果用formatToParts就变成了一个很简单的数组遍历function formatMoneyWithHighlight(amount) { const nf new Intl.NumberFormat(zh-CN, { style: currency, currency: CNY }); return nf.formatToParts(amount).map(part { if (part.type currency) { return span classcurrency${part.value}/span; } if (part.type fraction) { return span classfraction${part.value}/span; } return span classinteger${part.value}/span; }).join(); }这个方案避免了任何字符串硬解析因为小数点的位置、负号位置、货币符号位置都已经被Intl规定好了。工具提示框里直接插这段 HTML样式也可以按类统一控制。要注意的是formatToParts性能略低于format但只用于 tooltip每秒触发频率不高实测没有问题。4.3 数字在中文环境下的单位别名用formatToParts还能帮你自动识别单位信息。比如notation: compact模式下formatToParts()会返回一个type: compact的片段里面是万、亿这类单位文本。这让“整数 单位”样式拆分变得容易。const compactFormatter new Intl.NumberFormat(zh-CN, { notation: compact, maximumFractionDigits: 1 }); const parts compactFormatter.formatToParts(1234567); console.log(parts); // [{ type: integer, value: 123 }, { type: decimal, value: . }, { type: fraction, value: 5 }, { type: compact, value: 万 }]这个结构对接大屏动画特别方便你可以把数字部分做播报动画单位部分直接挂到文本末尾。如果用format()拿到整串字符串123.5万你还需要自己正则去拆麻烦得多。5. 那些“原生以外”的旧方案究竟输在哪里5.1 手写正则加 toFixed短平快但藏雷很多老项目里数字格式化就一句话function formatNumber(num) { return num.toFixed(2).replace(/\B(?(\d{3})(?!\d))/g, ,); }这行代码在大部分正数场景下确实能用比如1234567.8会变成1,234,567.80。但问题藏在边界里没有负数、零、小数少于两位时的统一显示策略。没有处理超大数或科学计数法表示的数字。没有兼容不同地区小数分隔符差异。toFixed的结果是字符串再 replace 时基于的已经是四舍五入后的值这里没问题但toFixed本身在某些精度极端情况下会有浮点误差比如(1.005).toFixed(2)在一些环境里返回1.00而不是1.01。我还见过一段先乘 100 再除回来自做百分比计算的代码浮点误差直接把结果弄成14.999999%前端同学不由自主地写了个兼容写法。这些本质上都是因为把“展示规则”和“数值运算”混在了一起。Intl.NumberFormat的思路是数值保持原样展示规则由内部统一处理能避开很多浮点坑。5.2 第三方工具库功能强但项目体积吃重社区里也有像numeral.js、accounting.js、numbro这类专门处理数字格式化的库。早期在某些场景下非常好用支持链式调用、自定义模式。但现在的 web 项目普遍对包体积敏感尤其移动端 H5一个库存几千行、只为加个千分位性价比确实低。拿numeral来说就算只按需引入核心调用打包体积也会比Intl多出 20KB 左右而且你还得额外处理它的语言包加载。这不是说不能用只是它解决的问题如今原生 API 都已经覆盖没必要再为了格式化数字而引一个库。有一种情况我会建议继续用第三方库团队历史代码已经大量依赖某个库并且很多地方跨模块引用了它的格式化函数。这时候为了阅读一致性和改动成本维持现状是可以接受的。但如果从零开始一个新项目我的明确建议是直接上Intl.NumberFormat删掉一个依赖就是给项目减一次风险。5.3 多端统一和 Node 端注意事项前端定方案经常会忽略 Node 端。如果你的项目同时跑着服务端渲染SSR模板里也要格式化金额那Intl在 Node 上完全可用而且从 Node 14 开始完整支持 ICU 字符集。这就意味着同一个Intl.NumberFormat配置在前端、后端都能用代码可以抽到公共包里。我遇到过一种情况前端页面用Intl格式化后端导出的 Excel 文件里数字写成1234567原样两边对不上。后来统一在服务端也封装了一个同样的 formatter写入导出文件之前先用同一套规则做展示数据就一致了。所以这里想特别提醒数字格式化规则最好抽象成一个公共模块前后端共用而不是各自单独写一份正则。否则今天前端改了小数位后端没改明天报表就千奇百怪。6. 兼容性排查与降级方案别把方案锁死在现代浏览器6.1 支持范围和特性检测不少同学担心Intl.NumberFormat的兼容性这里把实际情况说清楚。主流的 Chrome、Edge、Firefox、Safari 从很早的版本就支持基础用法现在生产环境中几乎不用考虑兼容问题。真正需要注意的场景是部分旧版移动 WebView、低版本安卓系统自带的浏览器、以及个别自定义内核的桌面壳。建议在入口做一次轻量特性检测const hasIntlNumberFormat typeof Intl ! undefined typeof Intl.NumberFormat function typeof Intl.NumberFormat.prototype.formatToParts function;如果连formatToParts都需要检测说明要用到分段控制如果只是format()也可以只检测format。对于不支持的环境常见的做法是打一个补齐旧机制的Intlpolyfill。不过要注意NumberFormat的完整 polyfill 体积不小它会用 JavaScript 模拟 locale 数据包体积有些大。真要接的话建议只在检测失败时动态加载if (!hasIntlNumberFormat) { // 动态加载 polyfill await import(intl-numberformat-polyfill); }配合动态 import 做按需加载既不影响现代浏览器性能也能给老设备留退路。6.2 轻量降级方案兜底一个 toFixed如果你不想引入任何 polyfill 依赖也可以写一个极轻的降级函数只覆盖format能力。反正老环境里一般也是“能加个千分位就行”不是非要支持全套语言差异。function safeFormatNumber(num, locale zh-CN) { if (hasIntlNumberFormat) { return new Intl.NumberFormat(locale).format(num); } // 简易降级千分位 固定两位小数 const fixed Number(num).toFixed(2); const parts fixed.split(.); parts[0] parts[0].replace(/\B(?(\d{3})(?!\d))/g, ,); return parts.join(.); }这个兜底方案能抓到绝大多数显示需求。但注意它不会正确处理货币符号和单位所以如果业务强依赖特定货币显示那还是老老实实接 polyfill或者在降级分支里补符号配置。6.3 单元测试把格式规则钉死在用例里格式化函数最怕“默默被改坏”。我现在的习惯是给常见配置都写一组快照式断言至少覆盖以下典型值const cases [ [1234567.8, ¥1,234,567.80], [0, ¥0.00], [-1234.56, -¥1,234.56], [100, ¥100.00] ];为什么要把0单独拿出来测因为0和负数的显示在Intl里都容易有意外比如某些 region 下负数会用括号显示而非负号产品可能压根不知道自己的 locale 会显示成(¥1,234.56)。单元测试能逼着你确认规则。我在某国际业务项目里就遇到过用en-US格式化负数金额输出是-$1,234.56但当地财务表达习惯是($1,234.56)。解决办法很简单配置里加currencySign: accounting输出就会变成($1,234.56)。这个配置藏在角落文档如果不逐项过一遍根本发现不了。7. 常见问题速查与避坑指南7.1 六类高频问题对照表这几年下来我整理了一份问题对照表基本覆盖了群里被问过最多的情况问题现象产生原因解决方式百分比输出多了好多位小数忘了设maximumFractionDigitsoptions 里显式配置小数位25%输出成立25或者漏百分号把 25 当 0.25 传入确认输入值是小数表示如 0.25负数金额显示为-¥1,234.56而非括号默认currencySign是标准样式设置currencySign: accounting数字一大就显示成1.235E6默认notation的标准输出和手写需求不符显式设置notation: standard或compact表格里金额一会儿三位小数一会儿两位minimum和maximum不一致两者设成同一值修改 locale 后格式没变实例已创建locale 不会自动更新换 locale 时要重新创建实例这些都是真实踩过的不是从文档里抄出来的。特别是最后一条我见过有人在多语言切换页面里只切了lang属性但 formatter 实例还是语言切换前的导致切了英文界面数字却仍然按中文格式显示。正确做法是把 formatter 封装成一个可重建的工厂函数切换语言时重新生成实例再触发视图更新。7.2 一个被忽略的性能细节实例复用前面提到过复用实例这里我给个具体对比。我在项目里循环渲染 2000 行表格数据分别测了两种写法的耗时// 低效循环里不断创建实例 const start performance.now(); for (let i 0; i 2000; i) { const nf new Intl.NumberFormat(zh-CN, { style: currency, currency: CNY }); nf.format(data[i]); } console.log(performance.now() - start); // 高效复用同一个实例 const nf new Intl.NumberFormat(zh-CN, { style: currency, currency: CNY }); const start2 performance.now(); for (let i 0; i 2000; i) { nf.format(data[i]); } console.log(performance.now() - start2);在我测的浏览器版本里第二种写法明显有优势差值大约在 30% 到 60% 之间。虽然 2000 行也就差个十几毫秒但页面里同时有表格、图表 tooltip、导出预览时这个差距会被放大。所以我的代码习惯是页面级或组件级创建 formatter 实例能放模块顶层就放模块顶层。7.3 local 参数到底要不要传全很多人写new Intl.NumberFormat()不带参数这在浏览器里会默认使用运行环境的当前语言比如中文系统就显示中文规则英文系统就显示英文规则。如果你本来就希望数字跟随系统语言那没问题。但业务报表通常要固定风格比如中文站点无论用户在什么环境金额始终显示成¥1,234.56此时就必须显式传zh-CN否则同一个页面在不同浏览器里显示效果不一致。另一个细节是locale参数的写法。zh-CN和zh都能用但效果可能有细微差别。真要严格管控就统一写zh-CN、en-US这种完整格式别写zh不然小数分隔符等细节可能落在非预期规则上。8. 实际项目中我最终采用的封装结构8.1 一个可落地的 formatter 工厂基于上面的实操我在多个项目里沉淀下来的封装结构长这样// format/index.js const DEFAULT_OPTIONS { style: decimal, useGrouping: true, minimumFractionDigits: 0, maximumFractionDigits: 3 }; function createFormatter(locales, options {}) { return new Intl.NumberFormat(locales, { ...DEFAULT_OPTIONS, ...options }); } // 按用途暴露特定实例 export const currencyFormatter (currency CNY, locales zh-CN) createFormatter(locales, { style: currency, currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }); export const percentFormatter (locales zh-CN, fractionDigits 2) createFormatter(locales, { style: percent, minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits }); export const compactFormatter (locales zh-CN, fractionDigits 1) createFormatter(locales, { notation: compact, maximumFractionDigits: fractionDigits });调用侧会非常清晰import { currencyFormatter, compactFormatter } from /utils/format; const cnyMoney currencyFormatter(CNY, zh-CN); const compactNum compactFormatter(zh-CN, 1); cnyMoney.format(1234567.8); // ¥1,234,567.80 compactNum.format(1234567); // 123.5万封装的价值在于统一配置入口。后续如果要全局调整小数位、切换货币符号风格只需改动工厂函数不用满项目找toFixed和正则。8.2 React 和 Vue 里怎么接入布局在组件里用我通常直接在模块顶层创建实例避免每次 render 都创建// React 示例 const priceFormatter new Intl.NumberFormat(zh-CN, { style: currency, currency: CNY, minimumFractionDigits: 2, maximumFractionDigits: 2 }); function PriceCell({ value }) { return span{priceFormatter.format(value)}/span; }如果组件依赖主题或语言切换那就把 formatter 实例放在一个useMemo或计算属性里依赖项设为locale和currency。这样语言切换时只重建这个实例而不是整个组件重算逻辑。Vue 3 里同理若locale来自全局状态可以用computed返回一个 formatterimport { computed } from vue; const priceFormatter computed( () new Intl.NumberFormat(locale.value, { style: currency, currency: CNY }) );这里算的是一个重新生成 formatter 的策略本身开销不大但比在模板里每次调用方法内 new 要克制得多。8.3 表格排序和数值比较的警告最后必须强调一点Intl.NumberFormat处理的是“展示”绝不能用它的输出值去参与排序、比较或运算。有些表格组件用字符串排序格式化后的¥1,234.56在字典序里就会排在¥999.99前面因为1小于9但数值上 1234 明明更大。所以排序字段一定要用原始数值格式化只在最终渲染层做。这其实也是整篇博文最想强调的核心格式化永远是最后一公里。数据在传输、运算、比较、入库时都要保持数值形态只在展示前转换成对应格式。很多人一上来就想着把格式化后的字符串回填给某个字段后面越写越别扭。分清“存储值”和“显示值”很多坑自然就绕开了。按照我个人几年的实操感受Intl.NumberFormat是把那些“看起来很小、不做又不行”的数字杂活收拢成一套标准流程的最佳工具。现在新项目要是没有特殊理由我不会再手写金额格式化的正则了。如果你刚接触它建议先照着上面的表格把常用配置跑一遍再做一版自己的工厂函数项目里该有的能力基本就齐了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑