前阵子在把项目从 Android/iOS 双端往鸿蒙端迁移时,团队里吵了三次架,核心原因全是“数字显示不一致”:同一个 1234567.8,iOS 上显示¥1,234,567.80,Android 上显示¥1,234,567.8,同一个 0.126,有的端是 12.6%,有的端是 13%。问题的根子不是 UI 没对齐,而是每个端各自维护了一套格式化逻辑,格式规则、舍入策略、空值兜底全不统一。
后来我们借着 React Native 鸿蒙适配的契机,把货币格式化(formatCurrency)和百分比格式化(formatPercent)两个工具函数彻底收敛到了 JS 层,做成了一套跨端共用的核心资产。这篇文章就把当时的完整设计思路、实现代码、踩坑过程和测试方案都放出来,给同样在搞 React Native 鸿蒙跨端、尤其是金融类应用的团队一个可以直接参考的样板。
1. 为什么金融应用的“数字显示”值得专门抽一层工具沉淀
很多团队一开始都觉得格式化不就是toFixed(2)加个逗号,不值得单独做一层。这个想法在普通工具类 App 里问题不大,但在金融类应用里,数字显示绝不是 UI 小事,它是数据正确性的最后一道防线。
1.1 金额和百分比出错不是 UI 瑕疵,是事故
金融应用里,金额和百分比直接关联到用户资产、收益、费率、还款计划等关键信息。一个¥1,234,567.80如果被格式化成¥1,234,567.8,用户可能觉得只是显示风格不一致;但如果是利率从3.45%被显示成3.5%,用户会认为平台擅自改了他的利率,这事直接上升到客诉和合规层面。
更隐蔽的是舍入规则不一致。同一笔0.005元的利息,A 端四舍五入成0.01,B 端直接截断成0.00,两边对账对不上,查半天才发现是格式化层的差异。这类问题在金融项目里就不是“显示 bug”,而是资金对账事故。所以把格式化逻辑收敛到一处,首要动机是保证跨端行为一致。
1.2 多端重复实现带来的连锁问题
在没有鸿蒙之前,不少金融团队的现状是:Android 用DecimalFormat,iOS 用NumberFormatter,Web 端用Intl.NumberFormat,三段代码各自维护,规则还经常不一样。
Android 的DecimalFormat("#,##0.00")和 iOS 的NumberFormatter在舍入行为上虽然都遵循四舍五入,但遇到浮点精度问题、负数格式(括号还是负号)、货币符号前后置这些细节,很容易各搞各的。再加上不同产品经理在不同时期提出不同需求,比如 A 端要求-$100.00,B 端要求($100.00),两边代码越来越分叉。
鸿蒙端加入之后,这个问题被放大了三倍:鸿蒙原生用的是 ArkTS,格式化能力储备和生态成熟度都不如 Android/iOS,再写第四套TextFormatter逻辑,维护成本直接失控。我们当时算过一笔账:四个端各自维护格式化代码,每次格式规则调整的排期成本是 4 天,而统一到 JS 层后是 1 天。
1.3 React Native + 鸿蒙架构下,JS 层是收敛的天然位置
选择把格式化工具收敛到 JS 层,不是因为“React Native 本来就是这么写的”,而是因为这个位置有天然优势:不管最终运行在 iOS、Android 还是鸿蒙上,RN 的 JS 引擎层都能执行同一份代码。只要团队约定“所有金额和百分比必须走 formatCurrency / formatPercent 这两个函数”,三端行为一致性就由代码结构保证了,而不是靠 Code Review 时人工提醒。
同时要注意,React Native 在鸿蒙上通常跑在 ArkTS 运行时提供的 JS 引擎里(比如借助鸿蒙的兼容层方案),虽然底层引擎可能不同,但如果我们的格式化工具不依赖任何原生能力、不依赖Intl的不稳定行为,单纯用纯 JS/TypeScript 实现,就几乎不受宿主引擎差异影响。这一点在后面第 4 章会详细展开。
2. formatCurrency:把钱显示对,从理解需求边界开始
写 formatCurrency 之前,我建议先别急着写代码,而是把业务需求完整列一遍。货币格式化的完整要素远不止“四舍五入加逗号”这么简单。
2.1 先列清需求再写代码:货币格式化的完整要素
一个健壮的货币格式化函数,至少要回答这几个问题:
| 需求维度 | 典型选项 | 说明 |
|---|---|---|
| 货币符号 | ¥、$、€、₩ | 不同市场的默认符号不同 |
| 符号位置 | prefix 前置、suffix 后置 | 如$100.00vs100.00€ |
| 小数位数 | 0、2、3、动态 | 加密货币可能到 6 位 |
| 千分位 | 开启、关闭 | 部分场景(如图表轴)会关闭 |
| 负数展示 | -100.00、-¥100.00、(100.00) | 会计惯例常用括号 |
| 舍入方式 | half-up、floor、ceil | 金融场景强制要求 |
| 空值和异常兜底 | --、0.00、空串 | 接口异常时必须可读 |
列完这张表你会发现,toFixed(2)只是其中一小块。金融应用中真正要命的不是“加不加逗号”,而是负数格式和舍入方式,这两个维度直接关系到用户看到的盈亏数字是否正确。
2.2 第一版实现:基于浮点数的常规方案
第一版我们为了快速上线,写了一个基于 number 的常规实现。核心思路是:输入金额数字,定义好选项,然后做舍入、取绝对值、拆整数和小数、加千分位、拼接符号。
type RoundType = 'half-up' | 'floor' | 'ceil'; interface FormatCurrencyOptions { symbol?: string; // 货币符号,默认 '¥' decimalPlaces?: number; // 小数位,默认 2 thousandSeparator?: boolean; // 千分位,默认 true symbolPosition?: 'prefix' | 'suffix'; // 符号位置 negativeFormat?: 'sign' | 'parenthesis'; // 负数格式 roundType?: RoundType; // 舍入方式 emptyPlaceholder?: string; // 空值占位 } export function formatCurrency( amount: number | string | null | undefined, options: FormatCurrencyOptions = {} ): string { const { symbol = '¥', decimalPlaces = 2, thousandSeparator = true, symbolPosition = 'prefix', negativeFormat = 'sign', roundType = 'half-up', emptyPlaceholder = '--', } = options; if (amount === null || amount === undefined || amount === '') { return emptyPlaceholder; } const num = typeof amount === 'string' ? parseFloat(amount) : amount; if (isNaN(num) || !isFinite(num)) { return emptyPlaceholder; } const factor = Math.pow(10, decimalPlaces); let rounded: number; switch (roundType) { case 'floor': rounded = Math.floor(num * factor) / factor; break; case 'ceil': rounded = Math.ceil(num * factor) / factor; break; default: rounded = Math.round(Math.abs(num) * factor) / factor * Math.sign(num); break; } const negative = rounded < 0; const absValue = Math.abs(rounded); const fixedStr = absValue.toFixed(decimalPlaces); const [intPart, decimalPart] = fixedStr.split('.'); const formattedInt = thousandSeparator ? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : intPart; const decimalStr = decimalPart ? `.${decimalPart}` : ''; if (negativeFormat === 'parenthesis' && negative) { return `(${symbol}${formattedInt}${decimalStr})`; } const sign = negative ? '-' : ''; if (symbolPosition === 'suffix') { return `${sign}${formattedInt}${decimalStr}${symbol}`; } return `${sign}${symbol}${formattedInt}${decimalStr}`; }这版代码在绝大多数场景下都能用,但有一个金融场景绕不开的隐患:浮点误差。拿0.1 * 100来说,在 JS 里结果是10.000000000000002,如果恰好遇到这种中间值,舍入结果就可能偏移。这类问题不常出现,但金融应用不允许“偶发错误”。
2.3 精度进阶:整数分方案与浮点陷阱
金融行业有个约定俗成的做法:涉及金额的内部传输尽量用“分”为单位,用整数表示,从根上避开浮点误差。我们最终在项目里使用的也是这个方案:上游接口直接返回分(cents),格式化函数接收整数分作为主输入,内部全部用整数运算。
/** * 基于整数分格式化货币,避免浮点误差 * 例如:formatCurrencyFromCents(12345678) => "¥123,456.78" */ export function formatCurrencyFromCents( cents: number | string | null | undefined, options: FormatCurrencyOptions = {} ): string { const { symbol = '¥', decimalPlaces = 2, thousandSeparator = true, symbolPosition = 'prefix', negativeFormat = 'sign', roundType = 'half-up', emptyPlaceholder = '--', } = options; if (cents === null || cents === undefined || cents === '') { return emptyPlaceholder; } let centsNum = typeof cents === 'string' ? parseInt(cents, 10) : cents; if (isNaN(centsNum) || !isFinite(centsNum)) { return emptyPlaceholder; } const negative = centsNum < 0; const absCents = Math.abs(centsNum); let intPart: string; let decimalPart: string; if (decimalPlaces === 0) { const rounded = roundCents(absCents, 0, roundType); intPart = String(rounded); decimalPart = ''; } else { const scale = Math.pow(10, decimalPlaces - 2); let scaled: number; if (decimalPlaces < 2) { // 例如只需要 1 位小数,先把分转成角,再舍入 scaled = Math.floor(absCents / 10); if (roundType === 'half-up') { scaled = Math.round(absCents / 10); } else if (roundType === 'ceil') { scaled = Math.ceil(absCents / 10); } } else { // 常见场景:整数分直接拆成元和分 // 更多位小数时,用 scale 放大处理 scaled = roundCents(absCents, decimalPlaces, roundType); } intPart = String(Math.floor(scaled / Math.pow(10, decimalPlaces))); const rawDecimal = String(scaled % Math.pow(10, decimalPlaces)).padStart(decimalPlaces, '0'); decimalPart = rawDecimal; } const formattedInt = thousandSeparator ? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : intPart; const decimalStr = decimalPart ? `.${decimalPart}` : ''; const body = symbolPosition === 'suffix' ? `${formattedInt}${decimalStr}${symbol}` : `${symbol}${formattedInt}${decimalStr}`; if (negativeFormat === 'parenthesis' && negative) { return `(${body})`; } return `${negative ? '-' : ''}${body}`; } function roundCents(absCents: number, decimalPlaces: number, roundType: RoundType): number { const scale = Math.pow(10, decimalPlaces - 2); const target = absCents / scale; switch (roundType) { case 'floor': return Math.floor(target); case 'ceil': return Math.ceil(target); default: return Math.round(target); } }核心区别在于:整个格式化过程不出现浮点数乘除,12345678分直接拆成123456元和78分,千分位也是打在整数字符串上,完全不依赖toFixed。这样就彻底规避了0.1 + 0.2这类经典问题。
但实际业务中并不是所有上游都返回分,有些接口返回的是“元”且是浮点数。这种场景我们不直接在格式化层修浮点,而是先提供一个yuanToCents转换函数,在数据进入业务层时就完成转换:
/** 元转分:在数据入口统一转换,后续所有金额展示都走整数分 */ export function yuanToCents(yuan: number | string): number { const num = typeof yuan === 'string' ? parseFloat(yuan) : yuan; if (isNaN(num) || !isFinite(num)) { return 0; } return Math.round((num + Number.EPSILON) * 100); }这里用Number.EPSILON也是为了抵消部分浮点噪声。建议团队定一条硬性规范:数据层拿到金额后立刻转成整数分,业务层和展示层只认分。
3. formatPercent:比货币格式化更隐蔽的“单位刺客”
百分比格式化比货币格式化更容易踩坑,因为它的输入语义经常不统一。同样是利率0.126,有的接口直接传0.126让你自己乘 100,有的接口直接传12.6表示已经是百分比数值。如果不做统一约定,格式化函数输出就乱套。
3.1 百分比输入到底是 0.1234 还是 12.34,必须显式约定
我见过很多团队在这个问题上互相甩锅:业务组说“后端返回的就是 0.1234 嘛”,后端说“我们文档写的是百分比数值 12.34”,最后前端在展示层临时除以 100 或者乘以 100,一个端一个样子。
formatPercent 的第一个设计决策就是:输入统一按“原始比例”处理,也就是 0.1234 表示 12.34%。这样和国际化惯例一致,后端存的是什么单位不会影响前端展示。
但为了避免以后被“传 12.34 的接口”坑到,我提供了valueToPercent的扩展参数:如果你的上游字段稳定地传入已经放大 100 倍的值,可以显式声明inputScaled: true,让函数内部不再乘 100。这个参数一旦设置,代码里就必须写清楚,不能猜。
3.2 百分比格式化实现
interface FormatPercentOptions { decimalPlaces?: number; // 小数位,默认 2 thousandSeparator?: boolean; // 千分位,默认 true withSign?: boolean; // 是否显示正号,如 +3.45% allowNegative?: boolean; // 是否允许负数 inputScaled?: boolean; // 输入是否已是百分比数值(如 12.6 表示 12.6%) trimTrailingZeros?: boolean; // 是否去掉小数末尾的 0 emptyPlaceholder?: string; } export function formatPercent( value: number | string | null | undefined, options: FormatPercentOptions = {} ): string { const { decimalPlaces = 2, thousandSeparator = true, withSign = false, allowNegative = true, inputScaled = false, trimTrailingZeros = false, emptyPlaceholder = '--', } = options; if (value === null || value === undefined || value === '') { return emptyPlaceholder; } const num = typeof value === 'string' ? parseFloat(value) : value; if (isNaN(num) || !isFinite(num)) { return emptyPlaceholder; } const raw = inputScaled ? num : num * 100; const negative = raw < 0; const absRaw = Math.abs(raw); const factor = Math.pow(10, decimalPlaces); let rounded = Math.round((absRaw + Number.EPSILON) * factor) / factor; let [intPart, decimalPart] = rounded.toFixed(decimalPlaces).split('.'); decimalPart = decimalPart || ''; if (trimTrailingZeros && decimalPlaces > 0) { decimalPart = decimalPart.replace(/0+$/, ''); } const formattedInt = thousandSeparator ? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : intPart; const decimalStr = decimalPart ? `.${decimalPart}` : ''; let sign = ''; if (negative) { if (!allowNegative) { return emptyPlaceholder; } sign = '-'; } else if (withSign) { sign = '+'; } return `${sign}${formattedInt}${decimalStr}%`; }使用示例:
formatPercent(0.126) // "12.6%" formatPercent(0.126, { decimalPlaces: 2 }) // "12.60%" formatPercent(0.126, { decimalPlaces: 2, trimTrailingZeros: true }) // "12.6%" formatPercent(1234.5, { withSign: true }) // "+1,234.50%"(涨跌幅场景常用) formatPercent(-0.005, { decimalPlaces: 2, allowNegative: false }) // "--" formatPercent(12.34, { inputScaled: true }) // "12.34%"3.3 与货币格式化共享底层逻辑的设计
百分比格式化和货币格式化看起来是两套函数,但它们的核心逻辑高度重叠:千分位格式化、整数部分拆分、符号处理、小数位补齐。如果各写各的,以后调整千分位规则或符号规则时就要改两处。
我建议抽一个内部公共函数formatNumberParts,专门处理“绝对值转千分位整数部分 + 小数部分 + 符号前缀”的通用逻辑,formatCurrency 和 formatPercent 都调用它。这样既减少了重复代码,也能保证两套输出的风格一致。
function formatAbsNumberParts( absValue: number, decimalPlaces: number, thousandSeparator: boolean, trimTrailingZeros: boolean ): string { const factor = Math.pow(10, decimalPlaces); const rounded = Math.round((absValue + Number.EPSILON) * factor) / factor; const [intPart, decimalPart] = rounded.toFixed(decimalPlaces).split('.'); const formattedInt = thousandSeparator ? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : intPart; let decimalStr = decimalPart || ''; if (trimTrailingZeros && decimalPlaces > 0) { decimalStr = decimalStr.replace(/0+$/, ''); } return decimalStr ? `${formattedInt}.${decimalStr}` : formattedInt; }这里的思路是:把“数字的展示玩法”(千分位、小数位、去零)和“语义玩法”(货币符号、百分比符号、正负号括号)分离。后续如果要统一改千分位分隔符为空格或者改成印度数字分组,只需要动一个函数。
4. 鸿蒙、iOS、Android 三端复用时的兼容性整治
工具函数写好了,真正让它成为“跨端核心资产”的关键一步是处理三端运行环境的差异。React Native 项目在鸿蒙端跑起来后,我们遇到了一堆在模拟器上根本暴露不出来、只有真机上才出现的兼容性问题。
4.1 Intl.NumberFormat 在不同 Runtime 上的表现差异
刚写第一版时,很多同事习惯性用Intl.NumberFormat来做千分位。在 iOS 的 JavaScriptCore 里表现正常,在 Android 的 Hermes 里基础用法也正常,但到了鸿蒙的 JS 运行时,某些版本对Intl.NumberFormat的支持不完整,特别是currency风格的货币符号映射,有的设备显示成CN¥,有的显示成¥,甚至有的直接返回不带符号的数字。
这个问题非常容易在联调阶段被漏掉,因为鸿蒙模拟器和开发者常用的高端真机可能表现正常,但用户的大众机型上就会翻车。我们的解决方案很简单:放弃依赖 Intl.NumberFormat,千分位和货币符号全部手动处理。这也意味着工具函数完全不去探测宿主环境支持什么,只依赖最基础的 JS 语法,稳定性大幅提升。
4.2 舍入规则与浮点误差的跨端一致性
另一类问题是舍入行为不一致。toFixed在不同 JS 引擎上对某些边界值的行为历史上就存在争议,比如(1.005).toFixed(2)在不同引擎上可能得到1.00或1.01。虽然现代引擎大多修复了,但鸿蒙端的新运行时是否完全对齐,我们没有十足的把握。
所以最终的 formatCurrencyFromCents 干脆绕开了toFixed,用整数运算直接算小数位;formatPercent 虽然还用toFixed,但统一乘了Number.EPSILON做补偿,并且在三端真机上跑了一轮断言用例。这里给的结论是:能绕开就绕开,绕不开就加补偿,但绝不能假设所有端行为一致。
4.3 字体渲染与 UX 细节:从“能用”到“好看”
格式化函数输出的字符串最终要渲染到界面上,不同端对货币符号的字体渲染也有差别。鸿蒙默认字体对¥的渲染和苹方对¥的渲染在宽度、基线位置上不完全一致。如果金额展示在表格或者卡片里,可能因为符号宽度差异导致数字不能对齐。
此外,负数括号格式(¥1,000.00)在金融场景常用,但在窄屏设备上括号容易和相邻元素重叠。我们后来加了两个 UI 层面的约定:金额类文本允许文本省略时不显示货币符号,只显示1,000.00;涨跌幅场景用颜色表示正负,同时保留+/-符号,方便色弱用户识别。这些细节不是格式化函数本身的问题,但如果没有统一的格式化层,这些 UX 约定根本无从谈起。
4.4 用测试矩阵守住三端一致性
跨端一致性的底线不是靠代码自证,而是靠测试矩阵。我们在 CI 里跑同一套 Jest 用例,覆盖 formatCurrency 和 formatPercent 的几十种输入输出断言;在真机测试阶段,专门做了一张三端对照表格,同一批输入在三端的截图输出必须逐字一致。
| 输入 | iOS | Android | 鸿蒙 | 期望结果 |
|---|---|---|---|---|
| 12345678(分) | ¥123,456.78 | ¥123,456.78 | ¥123,456.78 | ¥123,456.78 |
| -500(分,括号格式) | (¥5.00) | (¥5.00) | (¥5.00) | (¥5.00) |
| 0.126(百分比) | 12.60% | 12.60% | 12.60% | 12.60% |
| null | -- | -- | -- | -- |
这张表我会建议每个涉及跨端的项目都建一份。格式化的断言不通过,其他业务逻辑都不要测了,因为展示层已经错了。
5. 异常输入与边界场景:格式化函数也要有“防御装甲”
很多页面崩溃和数据显示异常,不是业务逻辑的问题,而是格式化函数收到了意料之外的输入。既然 formatCurrency 和 formatPercent 要作为公共资产在全项目铺开,它们就必须对异常输入有明确的防御策略。
5.1 常见异常输入与兜底策略
我把项目中实际遇到的异常输入归成几类,每类都明确处理方式:
| 异常类型 | 示例 | 兜底行为 |
|---|---|---|
| 空值 | null、undefined、空字符串 | 返回--或配置的 emptyPlaceholder |
| 非数字字符串 | "abc" | 返回空值占位符 |
| NaN / Infinity | NaN、Infinity | 返回空值占位符 |
| 字符串数字 | "12345"、"12.5" | 按数字解析后格式化 |
| 超过安全整数范围 | 9007199254740993 | 项目内部约定按最大值截断或抛异常 |
这里最重要的原则是:格式化函数永远不抛异常。它处于展示链路的末端,一旦抛异常,轻则页面白屏,重则整个列表渲染失败。兜底返回--至少能让用户知道“这里应该有一个值,但现在没拿到数据”,这比直接崩溃更友好。
5.2 超大金额、超小金额与精度溢出
金融应用还会遇到余额宝收益这类超小数字,比如年化收益每日入账0.000123元。如果走 formatCurrencyFromCents,0.000123元转成整数分是0.0123分,四舍五入变成0.01分,展示成¥0.00其实没问题,因为确实没有满一分钱。但如果产品经理要求“显示到厘”,那就得在数据层提前把最小单位换成厘,而不是在格式化层硬凑。
超大数据方面,如果后端传的金额超过 JS 安全整数范围,parseInt的精度就会丢失。我们的方案是:接口层对超大金额统一用字符串传递,格式化层通过BigInt或者字符串拆分的方式处理。不过目前 99% 的金融场景不会到万亿级别,所以这个处理做成可选扩展,不做进主路径,避免把核心函数搞得太重。
5.3 推荐的单测用例清单
格式化工具是全项目的基石函数,单元测试的性价比极高。我强烈建议至少覆盖这些用例:
describe('formatCurrencyFromCents', () => { test('常规金额千分位和小数位', () => { expect(formatCurrencyFromCents(12345678)).toBe('¥123,456.78'); }); test('负数符号格式', () => { expect(formatCurrencyFromCents(-500)).toBe('-¥5.00'); }); test('负数括号格式', () => { expect(formatCurrencyFromCents(500, { negativeFormat: 'parenthesis', symbolPosition: 'suffix' })).toBe('($5.00)'); // 注意实际返回会带上符号,按实现断言 }); test('空值兜底', () => { expect(formatCurrencyFromCents(null)).toBe('--'); }); test('非法输入兜底', () => { expect(formatCurrencyFromCents('abc')).toBe('--'); }); }); describe('formatPercent', () => { test('常规比例转百分比', () => { expect(formatPercent(0.126)).toBe('12.60%'); }); test('千分位百分比', () => { expect(formatPercent(12345.678)).toBe('1,234,567.80%'); }); test('正号展示', () => { expect(formatPercent(0.0345, { withSign: true })).toBe('+3.45%'); }); test('负数被禁止时兜底', () => { expect(formatPercent(-0.01, { allowNegative: false })).toBe('--'); }); test('已放大输入', () => { expect(formatPercent(12.34, { inputScaled: true })).toBe('12.34%'); }); });我特别想强调“负数被禁止时兜底为空值”这个设计。在金融场景里,利率或收益率出现了负数,往往意味着异常。与其展示一个让人困惑的-3.00%,不如用占位符触发后续的异常判断逻辑。当然,盈亏类场景负数必须展示,所以allowNegative默认是true,具体用哪个由业务层显式决定。
5.4 从“工具函数”到“资产”的团队落地经验
最后分享一点团队落地的经验。工具函数写完之后,我们做了一件事:把它从普通的 utils 目录提升到了独立的基础库里,并写了一份格式化规范文档,明确了以下几条团队红线:
- 所有金额展示必须走 formatCurrency / formatCurrencyFromCents,禁止在业务代码里手写
toFixed(2)。 - 所有百分比展示必须走 formatPercent,禁止手写
(num * 100).toFixed(2) + '%'。 - 数据层拿到的金额如果单位是“元”,必须在数据转换层统一转成“分”,展示层只认分。
- 新增格式化需求(比如新货币、新舍入方式)必须改工具库并在单测里补充用例,不允许在业务组件里临时拼字符串。
这几条红线刚开始执行时会有阻力,尤其是一些老手觉得“就一行代码,没必要封装”。但经历一次跨端金额对账事故后,团队就没人再质疑了。工具函数本身不复杂,真正有价值的是把它作为“资产”对待的制度约束。后续如果要把这套逻辑复用到 Flutter 或者其他跨端方案,核心的舍入规则和边界场景清单也可以平迁过去,这才是它作为“核心资产”的意义所在。