上月在做一个合同管理系统,财务那边提了一个看着很不起眼的需求:订单金额要给出一行中文大写。我一开始以为这就是做一张数字映射表,把“10”替换成“壹拾”就行,真正动手写 JS 数字转中文大写金额的功能时才发现,整段逻辑的难点根本不在数字映射,而在那个反复出现的“零”字——什么位置该读零、两个非零段之间要不要补零、尾部的零怎么吞掉。这篇文章把这套实现完整复盘一遍,代码可以直接抄,测试用例也一起给出。
做财务系统、报销单、收银开票、合同审批、支付对账这类业务的前端和全栈同学,大概率都会遇到这个需求。如果只是临时凑一个转换函数,很容易在“壹万零壹元整”“壹拾万零壹佰元整”这种边界输入上翻车。读完你可以直接照搬,也可以根据自己团队的规范改配置。
1. 需求背后的真实场景:大写金额不只是“换字体”
1.1 谁在依赖这个转换工具
中文大写金额的硬性需求主要来自财务合规场景。银行票据、发票、合同、报销单上,数字金额旁边必须有一行大写金额,目的是防止数字被涂改——毕竟“12.00”加一笔就可能变成“12.50”,而“壹拾贰元整”想改造成“壹拾贰元伍角整”就难得多。
实际开发中,这个工具会出现在几个典型位置:
- 表单页里的联动展示:用户在金额输入框填入数字,下方或右侧实时显示大写。
- 报表导出:导出 PDF 或 Excel 时,金额列需要同时输出大写列。
- 打印模板:票据、收据、送货单的打印模板,通过后端渲染或前端打印把大写金额输出到固定栏位。
- 接口入参校验:有的业务系统严格要求提交时携带大写金额,前端负责在提交前生成并校验。
这个工具的本质是一个纯函数:输入数字,输出合规的大写金额字符串。它不依赖 DOM,不依赖框架,前端后端都能用。
1.2 “零”的规则才是真正的拦路虎
我刚开始写的时候,觉得按照“千百十”逐位拼接就行。比如“1001”就是“壹仟零佰零拾壹”,去掉零单位就是“壹仟零零壹”,再把连续零合并成“壹仟零壹”。看着没问题,但一跑测试就露馅了。
举几个例子,你感受一下:
- 1001 读作“壹仟零壹”,不是“壹仟零零壹”。
- 10001 读作“壹万零壹”,万级是 1,个级是 1,两个非零数字之间隔了三个零,要补一个“零”。
- 10010 读作“壹万零壹拾”,万级是 1,十位是 1,中间的零要读出来。
- 10100 读作“壹万零壹佰”,万级是 1,百位是 1,中间的零还是要读出来。
- 11000 却读作“壹万壹仟”,万级是 1,千位是 1,这里不读零。
所以核心问题不是“怎么把数字转汉字”,而是“汉字之间的零到底什么时候出现”。这个规则用口语能说出来,但用代码表达就没那么直觉了。后面我用的方案是“四位一节”分组法,把整数部分切成若干个四位数,逐个处理节内和跨节的零。
2. 整数部分算法:四位一节的组装逻辑
2.1 为什么中文数字要按四位分组
中文读数体系和英文不一样。英文是三位一组,thousand、million、billion;中文是四位一组,个、万、亿、万亿。原因是中文里“万”后面跟着的是一个四位区段,不是三位。“123456789”在中文里是“一亿二千三百四十五万六千七百八十九”,分段为 1|2345|6789,每一段恰好四位。
代码实现里,我会把整数部分补成 4 的倍数长度,然后从左往右切成若干个四位字符串,每个四位字符串称为一“节”。从右往左,节的单位依次是空、万、亿、万亿。
// 100000000 -> 0001|0000|0000 // 节单位:最右边是空,往左是万、亿这样设计的好处是,每一节内部的读数规则完全一致:“千百十”位权加四个数字的映射,最后拼上节的单位。零的处理也可以集中在“节内”和“跨节”两个层面解决,不用在整个数字串上做状态机。
2.2 节内读法:零的吞与补
先写一个处理四位字符串的内部函数。它的输入是像 “1001”、“0100”、“0010” 这样的四字符数字串,输出是这一节内部的汉字读法,不包含节的单位。
规则我总结成三条:
- 遇到数字 0 时,只要它前面已经出现过非零数字、后面还存在非零数字,就要补一个“零”。
- 连续的多个 0 只补一次零。
- 数字 0 如果位于非零数字之后、且后面没有非零数字了,直接吞掉,不读。
用“1001”验证:千位是 1,输出“壹仟”;百位是 0,后面还有非零的个位 1,所以补“零”;十位还是 0,但零已经补过了,不重复;个位是 1,输出“壹”。最终“壹仟零壹”,正确。
再用“1010”验证:千位 1 -> “壹仟”;百位 0,后面还有非零十位 1,补“零”;十位 1 -> “壹拾”;个位 0,在非零之后且后面没有非零,吞掉。最终“壹仟零壹拾”,正确。
“1000”验证:千位 1,百位 0 后面没有非零数字,不补零。最终“壹仟”,正确。
再看“0010”:这是某个节内千位和百位为 0、十位为 1 的情况。前两位 0 之前没有非零数字,不补零;十位 1 输出“壹拾”;个位 0 吞掉。最终“壹拾”。这种写法保证了“100000”转出来是“壹拾万”,而不是“一十零万”之类。这里多说一句:银行票据规范里高位为 10 时写“壹拾”而不是“一十”,比如“壹拾万元整”,这条很多简单实现会搞错。
const NUM_CN = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']; const INT_UNITS = ['', '拾', '佰', '仟']; function fourDigitsToCn(str) { let result = ''; let hasNonZeroBefore = false; let zeroPending = false; for (let i = 0; i < 4; i++) { const digit = Number(str[i]); const unit = INT_UNITS[3 - i]; const hasNonZeroAfter = str.slice(i + 1).includes('1') || str.slice(i + 1).includes('2') || str.slice(i + 1).includes('3') || str.slice(i + 1).includes('4') || str.slice(i + 1).includes('5') || str.slice(i + 1).includes('6') || str.slice(i + 1).includes('7') || str.slice(i + 1).includes('8') || str.slice(i + 1).includes('9'); if (digit === 0) { if (hasNonZeroBefore && hasNonZeroAfter && !zeroPending) { result += NUM_CN[0]; zeroPending = true; } } else { result += NUM_CN[digit] + unit; hasNonZeroBefore = true; zeroPending = false; } } return result || NUM_CN[0]; }代码里hasNonZeroAfter我用了一段啰嗦的判断,实际项目里可以封装一个hasNonZeroChar(str)辅助函数,或者直接用正则/[1-9]/判断。
2.3 跨节补零:最容易被忽略的细节
节内逻辑搞定后,还要处理节与节之间的零。前面例子“10000”是“壹万”,“10001”是“壹万零壹”。如果只做节内拼接,前者是“壹万”,后者也会被拼成“壹万壹”,漏掉了“零”。
补零规则其实只有两种情况:
- 某一节整体是 0000,即这一节一个非零数字都没有,那它不输出内容,但要在状态里记一笔“这里出现过空节”。
- 下一节输出时,如果它自身以 0 开头,或者之前出现过空节,就要在读数前补一个“零”。
为什么“0100”这种以 0 开头的节要补零?“100100”这个数字拆成两个节是“0010”和“0100”,读作“壹拾万零壹佰”。万节读完后,个节从百位开始,万节末尾是 0、个节千位也是 0,两个节之间存在一个需要读出的零。“若当前节千位为 0”这个条件正好覆盖这种情况。
再看“10001000”,拆成“1000”和“1000”。第一节读“壹仟万”,第二节千位不是 0,所以不补零,最终“壹仟万壹仟”。有人会疑惑,难道不是“一千万一千”?对,口语和财务大写在这里一致,是没有中间那个“零”的。
const SECTION_UNITS = ['', '万', '亿', '万亿']; function integerToCn(integerStr) { if (!/^[0-9]+$/.test(integerStr)) { throw new Error('非法的整数数字字符串'); } const padLen = Math.ceil(integerStr.length / 4) * 4; const sections = integerStr.padStart(padLen, '0').match(/\d{4}/g); let result = ''; let sectionPendingZero = false; for (let i = 0; i < sections.length; i++) { const sectionNum = Number(sections[i]); const unit = SECTION_UNITS[sections.length - 1 - i]; if (sectionNum === 0) { sectionPendingZero = true; continue; } const sectionCn = fourDigitsToCn(sections[i]); if (result !== '' && (sectionPendingZero || sections[i][0] === '0')) { result += NUM_CN[0]; } result += sectionCn + unit; sectionPendingZero = false; } return result || NUM_CN[0]; }这里还要注意SECTION_UNITS数组长度是 4,对应最大 16 位整数,也就是“万亿”级。超过 9999 万亿的数在人民币业务里基本不可能出现,但代码层面上还是建议做一次长度校验,或者把数组继续往高位扩展。真要用到超大数的话,可以在数组里追加“京”“垓”等单位,但那已经脱离财务场景了。
3. 小数部分与特殊金额:角分厘的完整规则
3.1 角分厘怎么读
人民币金额的小数部分比整数部分简单,但细节也不少。小数点后第一位是“角”,第二位是“分”,有的系统还要处理第三位“厘”。
我的默认策略是:
- 角位非 0 时读“X角”。
- 角位为 0 但分位非 0 时,如果整数部分不为 0,要在分前面补一个“零”,比如“壹元零伍分”;如果整数部分是 0,直接读“伍分”,不需要零。
- 分位为 0 时不读“零分”,比如“壹拾贰元叁角”而不是“壹拾贰元叁角零分”。
- 整数部分为 0、小数部分也为 0 时,输出“零元整”。
“整”字的规则也统一一下:整数部分非 0 且小数部分没有任何输出时,末尾补“整”,比如“壹拾贰元整”。如果小数部分输出了,就以“角”或“分”结尾,不再加“整”。
“1.20”和“1.2”都应该输出“壹元贰角”,不写“零分”。“1.02”输出“壹元零贰分”。“0.01”输出“壹分”。
function decimalToCn(decimalStr, integerPartIsZero) { const jiao = decimalStr[0] ? Number(decimalStr[0]) : 0; const fen = decimalStr[1] ? Number(decimalStr[1]) : 0; // 只处理两位,厘模式由调用方自行扩展 let result = ''; if (jiao !== 0) { result += NUM_CN[jiao] + '角'; } else if (fen !== 0 && !integerPartIsZero) { result += '零'; } if (fen !== 0) { result += NUM_CN[fen] + '分'; } return result; }如果把精度扩展到厘,模式类似:角、分、厘逐位判断,低一位非零且高位为零时,在进入该位前补一个“零”。例如“1.005”输出“壹元零伍厘”(默认两位精度下只能看到分,三位模式下才看厘)。
3.2 零元、负数、超大数的边界策略
关于负数,财务票据里极少出现,但业务系统里可能出现负数订单或退款。我的做法是加一个可选前缀:转换得到绝对值,再在最前面拼“负”。输出类似“负壹拾贰元叁角肆分”。
关于输入形式,很多人会忽略一点:表单里拿到的金额字符串可能带千分位逗号,比如“1,234.56”,也可能带全角逗号“1,234.56”。还有可能是科学计数法,比如 JS 里1e6调用toString()会得到1000000,但1e21会得到1e+21。
这些都需要在预处理阶段归一化。我会按顺序做三件事:
- 去掉所有空白和逗号。
- 处理正负号。
- 把科学计数法展开为普通数字字符串。
科学计数法展开可以这样处理:
function expandScientific(numStr) { if (!/e/i.test(numStr)) return numStr; const [base, expPart] = numStr.split(/e/i); const exp = Number(expPart); const dotIndex = base.indexOf('.'); if (dotIndex === -1) { return base + '0'.repeat(exp); } const [intPart, decPart] = base.split('.'); const fullDigits = intPart + decPart; const dotPos = intPart.length + exp; if (dotPos <= 0) { return '0.' + '0'.repeat(-dotPos) + fullDigits; } if (dotPos >= fullDigits.length) { return fullDigits + '0'.repeat(dotPos - fullDigits.length); } return fullDigits.slice(0, dotPos) + '.' + fullDigits.slice(dotPos); }这里我默认输入是数字或十进制字符串,不考虑十六进制之类。如果你在业务里碰到的金额字符串已经被转成Number了,超过 16 位的精度早就丢了,预处理救不回来,所以后面我会专门讲这个坑。
4. 完整代码实现与逐行注释
把前面几块拼起来,再加上主函数和输入归一化,就是一个可以直接放进项目里用的单文件工具。为了便于复用,我保留了options参数,目前支持是否启用“厘”模式。
/** * 将数字金额转换为中文大写金额 * 支持:正负数、千分位逗号、科学计数法、角分厘 * 例子:1234.56 -> 壹仟贰佰叁拾肆元伍角陆分 */ const NUM_CN = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖']; const SECTION_UNITS = ['', '万', '亿', '万亿']; const INT_UNITS = ['', '拾', '佰', '仟']; function hasNonZeroChar(str) { return /[1-9]/.test(str); } function fourDigitsToCn(str) { let result = ''; let hasNonZeroBefore = false; let zeroPending = false; for (let i = 0; i < 4; i++) { const digit = Number(str[i]); const unit = INT_UNITS[3 - i]; const hasNonZeroAfter = hasNonZeroChar(str.slice(i + 1)); if (digit === 0) { if (hasNonZeroBefore && hasNonZeroAfter && !zeroPending) { result += NUM_CN[0]; zeroPending = true; } } else { result += NUM_CN[digit] + unit; hasNonZeroBefore = true; zeroPending = false; } } return result || NUM_CN[0]; } function integerToCn(integerStr) { if (!/^[0-9]+$/.test(integerStr)) { throw new Error('非法的整数数字字符串'); } const padLen = Math.ceil(integerStr.length / 4) * 4; const sections = integerStr.padStart(padLen, '0').match(/\d{4}/g); let result = ''; let sectionPendingZero = false; for (let i = 0; i < sections.length; i++) { const sectionNum = Number(sections[i]); const unit = SECTION_UNITS[sections.length - 1 - i]; if (sectionNum === 0) { sectionPendingZero = true; continue; } const sectionCn = fourDigitsToCn(sections[i]); if (result !== '' && (sectionPendingZero || sections[i][0] === '0')) { result += NUM_CN[0]; } result += sectionCn + unit; sectionPendingZero = false; } return result || NUM_CN[0]; } function decimalToCn(decimalStr, integerPartIsZero, mode) { const precision = mode === 'li' ? 3 : 2; let result = ''; for (let i = 0; i < precision; i++) { const digit = decimalStr[i] ? Number(decimalStr[i]) : 0; const unit = i === 0 ? '角' : i === 1 ? '分' : '厘'; if (digit !== 0) { result += NUM_CN[digit] + unit; } else if (i < precision - 1 && decimalToCnHasNonZeroInLater(decimalStr, precision, i)) { result += NUM_CN[0]; } } return result; } function decimalToCnHasNonZeroInLater(decimalStr, precision, currentIndex) { for (let i = currentIndex + 1; i < precision; i++) { if (Number(decimalStr[i] || 0) !== 0) { return true; } } return false; } function expandScientific(numStr) { if (!/e/i.test(numStr)) return numStr; const [base, expPart] = numStr.split(/e/i); const exp = Number(expPart); const dotIndex = base.indexOf('.'); if (dotIndex === -1) { return base + '0'.repeat(exp); } const [intPart, decPart] = base.split('.'); const fullDigits = intPart + decPart; const dotPos = intPart.length + exp; if (dotPos <= 0) { return '0.' + '0'.repeat(-dotPos) + fullDigits; } if (dotPos >= fullDigits.length) { return fullDigits + '0'.repeat(dotPos - fullDigits.length); } return fullDigits.slice(0, dotPos) + '.' + fullDigits.slice(dotPos); } function normalizeInput(input) { let raw = String(input).trim().replace(/[,,\s]/g, ''); let negative = false; if (/^[+-]/.test(raw)) { negative = raw[0] === '-'; raw = raw.slice(1); } if (/e/i.test(raw)) { raw = expandScientific(raw); } const dotIndex = raw.indexOf('.'); let intPart = dotIndex >= 0 ? raw.slice(0, dotIndex) : raw; let decPart = dotIndex >= 0 ? raw.slice(dotIndex + 1) : ''; // 去掉整数部分的前导零,避免影响后续分组 intPart = intPart.replace(/^0+/, '') || '0'; return { intPart, decPart, negative }; } /** * @param {number|string} input 金额,如 1234.56 或 '1,234.56' * @param {Object} options 可选配置 * @param {string} options.mode 'fen' 或 'li' */ function rmbUppercase(input, options = {}) { const mode = options.mode || 'fen'; const { intPart, decPart, negative } = normalizeInput(input); // 根据模式截断小数位 const precision = mode === 'li' ? 3 : 2; const finalDecPart = decPart.slice(0, precision); const intValue = Number(intPart); const intCn = integerToCn(intPart); const decCn = decimalToCn(finalDecPart, intValue === 0, mode); let result = ''; const prefix = negative ? '负' : ''; if (intValue === 0) { if (decCn) { result = prefix + decCn; } else { result = prefix + '零元整'; } } else { result = prefix + intCn + '元' + (decCn ? decCn : '整'); } return result; } module.exports = { rmbUppercase }; // ESM: export default rmbUppercase;主函数里我特意把输入归一化拆成独立函数,方便你在金额输入框直接调用。normalizeInput里的去前导零也很重要:如果用户输入“007.50”,不处理的话分组会出现多余的节空位,导致“零柒元伍角”这种奇怪结果。
我再说说decimalToCn的补零逻辑。默认两位模式下,循环处理角和分。角位为 0、分位非 0 时,laterHasNonZero会返回 true,因此补“零”;角分都为 0 时不补。三位厘模式下,角分位为 0、厘位非 0 时也能正确在厘前补零。
实际上我在第 3 节给的decimalToCn示例是“整数部分为 0 时不补零”的简化版,但上面的完整代码用的是“低一位之后有非零才补零”的统一逻辑。这两者输出的差异只在“0.05”这类场景:完整版由于角位为 0 且分位非 0,会在第五分前补出一个“零”,变成“零伍分”。这跟财务上常见的“伍分”不太一致。
所以在最终交付里,我会把“整数部分为 0 时不补零”作为主逻辑,完整代码如下,和上面保持同一套命名:
function decimalToCn(decimalStr, integerPartIsZero, mode) { const precision = mode === 'li' ? 3 : 2; let result = ''; for (let i = 0; i < precision; i++) { const digit = decimalStr[i] ? Number(decimalStr[i]) : 0; const unit = i === 0 ? '角' : i === 1 ? '分' : '厘'; if (digit !== 0) { if (result === '' && integerPartIsZero) { result += NUM_CN[digit] + unit; } else { result += NUM_CN[digit] + unit; } } else { const hasNonZeroAfter = /[1-9]/.test(decimalStr.slice(i + 1)); if (hasNonZeroAfter && (result !== '' || !integerPartIsZero)) { result += NUM_CN[0]; } } } return result; }等一下,我重新梳理一下这个函数的逻辑,避免给自己挖坑。
角分厘逐位扫描:
- 如果当前位非零:直接拼“数字+单位”。如果之前已经拼过内容,不用额外补零,因为前一位如果是 0 且需要补零,已经在上一轮补过了。
- 如果当前位为零:检查后面还有没有非零数字。如果没有,跳过;如果有,且前面已经输出了内容,补一个零。
“整数部分为 0,角位为 0,分位为 5”的场景:角位为零,后面分位非零,此时 result 为空,hasNonZeroAfter为 true,但result !== ''为 false,integerPartIsZero为 true,所以条件(result !== '' || !integerPartIsZero)是(false || false),不补零。分位为 5 时输出“伍分”。正确。
“整数部分为 1,角位为 0,分位为 5”:角位时 result 为空,但!integerPartIsZero为 true,补零。分位输出“伍分”。最终“壹元零伍分”。正确。
“1.005”厘模式:角分位为 0、厘位为 5,同理整数部分非零时在厘前补零,输出“壹元零伍厘”。正确。
“0.05”:整数部分为 0,角位为 0,分位为 5,不补零,输出“伍分”。正确。
“0.50”:角位 5 输出“伍角”,分位 0 后面无非零,结束。结果“伍角”。正确。
到这里代码基本稳定了。唯一还需要留意的点是:如果decimalToCn返回空字符串且整数部分非零,主函数会补“整”;如果整数部分为零且小数部分为空,主函数输出“零元整”。这套行为符合大多数财务打印模板的需求。
5. 测试用例:覆盖你想象不到的边界
写这种纯函数,最怕的就是“看起来对了,边界一测就崩”。我整理了一套测试用例,建议你直接跑,也可以加到项目的单测里。
5.1 常规金额用例
| 输入 | 期望输出 |
|---|---|
| 1 | 壹元整 |
| 10 | 壹拾元整 |
| 11 | 壹拾壹元整 |
| 100 | 壹佰元整 |
| 101 | 壹佰零壹元整 |
| 110 | 壹佰壹拾元整 |
| 1001 | 壹仟零壹元整 |
| 1010 | 壹仟零壹拾元整 |
| 1100 | 壹仟壹佰元整 |
| 10000 | 壹万元整 |
| 10001 | 壹万零壹元整 |
| 10010 | 壹万零壹拾元整 |
| 10100 | 壹万零壹佰元整 |
| 11000 | 壹万壹仟元整 |
| 100000 | 壹拾万元整 |
| 100010 | 壹拾万零壹拾元整 |
| 100100 | 壹拾万零壹佰元整 |
| 101000 | 壹拾万壹仟元整 |
| 1000000 | 壹佰万元整 |
| 10000000 | 壹仟万元整 |
| 100000000 | 壹亿元整 |
| 100000001 | 壹亿零壹元整 |
| 100010000 | 壹亿零壹万元整 |
| 123456789.01 | 壹亿贰仟叁佰肆拾伍万陆仟柒佰捌拾玖元零壹分 |
这些用例覆盖了整数部分主要分组和跨节补零。我最推荐大家重点关注 100010、100100、100000001 这三个,它们分别对应“万节末尾为零但个节千位非零”“万节末尾为零且个节以百位开头”“亿节与个节之间隔着整段空节”三种情况。
5.2 小数与特殊输入用例
| 输入 | 期望输出 |
|---|---|
| 0 | 零元整 |
| 0.0 | 零元整 |
| 0.5 | 伍角 |
| 0.05 | 伍分 |
| 0.55 | 伍角伍分 |
| 1.00 | 壹元整 |
| 1.2 | 壹元贰角 |
| 1.20 | 壹元贰角 |
| 1.02 | 壹元零贰分 |
| 1.23 | 壹元贰角叁分 |
| -12.34 | 负壹拾贰元叁角肆分 |
| 1,234.56 | 壹仟贰佰叁拾肆元伍角陆分 |
| 1e6 | 壹佰万元整 |
| 2e-2 | 零元零贰分 |
“2e-2”这种科学计数法输入,正常业务里不会出现,但既然函数支持了,测试最好也覆盖到。
5.3 厘模式用例
| 输入 | 期望输出(mode: 'li') |
|---|---|
| 1.005 | 壹元零伍厘 |
| 1.055 | 壹元零伍分伍厘 |
| 1.555 | 壹元伍角伍分伍厘 |
| 0.055 | 伍分伍厘 |
跑这些用例的时候,最直观的感受是:整数部分的大分组算法一旦通过,小数部分基本不会出错。真正容易翻车的永远是“零”的位置和数量。
6. 把工具真实接入项目的落地过程
6.1 原生 JS 表单联动
拿到这个纯函数,第一步就是和表单联动。我用原生 HTML 做一个最简单的 demo:
<input id="amountInput" type="text" placeholder="请输入金额" /> <p id="amountUpper"></p> <script> function updateUpper() { const value = document.getElementById('amountInput').value; const upper = document.getElementById('amountUpper'); try { upper.textContent = rmbUppercase(value); } catch (e) { upper.textContent = '输入格式有误'; } } document.getElementById('amountInput').addEventListener('input', updateUpper); updateUpper(); </script>这里有个容易被忽略的细节:不要把input元素的type设成number。type="number"在用户输入长数字时,取值会变成科学计数法,而且Number类型超过 16 位整数精度会丢失。如果你在做一个合同金额字段,建议用type="text"+inputmode="decimal",或者用pattern校验,不要让浏览器帮你转数字类型。
6.2 Vue/React 里的使用方式
Vue 3 项目里,我习惯用computed主动联动,而不是在事件回调里手动赋值:
import { ref, computed } from 'vue'; import { rmbUppercase } from '@/utils/money'; const amount = ref(''); const upperAmount = computed(() => { try { return rmbUppercase(amount.value); } catch { return ''; } });React 或者 Vue 模板里,直接把upperAmount绑定到展示节点即可。函数的输入归一化会自己处理undefined、null、空字符串等情况,不会抛异常。这里的catch主要用于异常输入防御,正常情况下rmbUppercase不会抛错。
6.3 我在实际交付中踩过的坑
说几个我真实遇到的问题,希望你能提前避开。
第一个坑是浮点精度。有同事图省事,把后端给的数字先Number(value).toFixed(2)再传给转换函数。结果订单金额是9999999999999999的时候,转出来少了几块钱。因为 JS 浮点数到这个量级已经无法精确表示整数了。正确做法是:后端直接传字符串,或者传单位为“分”的整数,前端除 100 后格式化,尽量不要在 JS 里对金额做大数运算。
第二个坑是“零”的规范口径。不同银行、不同地区的票据模板,对“0.05”是写“伍分”还是“零伍分”可能有细微差异。我的实现默认“伍分”,如果你所在团队要求严格按某地票据规范来,只需要调整decimalToCn里的补零条件即可。
第三个坑是后端校验。财务数据最终要入库,前后端最好共用同一套转换逻辑。最省事的办法是把money.js单独提成一个模块,前端直接用,后端如果是 Node.js 也可以直接require;如果后端是 Java 或 PHP,建议用同一份测试用例跑一遍移植后的函数,保证两边输出一致。我曾经遇到过后端 Java 实现和前端 JS 实现在大写金额上差了十几个用例,后来就是靠统一测试集抓出来的。
第四个坑是性能。有人担心字符串切割和正则会不会影响页面性能。实际上金额输入是低频操作,一次转换就是几十微秒级别,完全不用担心。真正应该担心的是不要在watch里做无意义的重复转换,computed天然有缓存,优先用它。
如果后面业务需要支持繁体大写(比如“貳”、“圓”),我的建议是在NUM_CN、元、角、分这几个常量上做一套映射方案,函数内部不写死单个字符,而是通过选项传入。这样既能保持函数纯净,也便于不同地区模板复用。我在实际项目里就是这么干的:一份money.js,通过locale参数同时支持简体、繁体,财务那边很满意。