数字转英文和中文表达这个需求乍一看像是编程入门练习题但真正动手写过的人都知道坑远比想象的多。我最早接触这个需求是在一个财务报表导出模块里当时觉得不就是把 123 转成 “一百二十三” 和 “one hundred twenty-three” 吗结果上线后被各种边界数据打得措手不及——小数位、负数、超大数、零的处理、中文的“两”和“二”之争、英文的连字符和 and 的位置每一个细节都能让你返工。这篇文章就把我这些年在这个看似简单的问题上踩过的坑、总结的方法、以及可以直接抄作业的实现思路完整梳理一遍。不管你是刚学编程想找个练手项目还是工作中真的需要做金额大写、票据打印、国际化文案展示这篇内容都能给你一套能跑、能扩展、经得起边界测试的方案。我会从整体设计思路讲起把中英文两套规则拆开对比再深入到具体实现步骤和参数选择最后把我遇到过的问题整理成速查表。全程用大白话加实际代码保证你看完就能动手。1. 整体设计思路与方案选型1.1 为什么这个需求不能“一把梭”很多人第一反应是写一个函数里面塞一堆 if-else 把 0 到 9 映射成单词然后按位拼接。这个思路在两位数以内没问题一旦上到三位数就崩了因为英文里 21 是 “twenty-one” 而不是 “twenty one”中文里 105 是 “一百零五” 而不是 “一百五”。这两种语言的数字表达规则本质上是不同的语法体系硬塞进一个函数只会让代码变成意大利面条。我的做法是按语言拆成两条独立的转换链路各自维护自己的词典和规则最后在外面套一层统一入口做参数校验和分发。这样做的好处是中文规则改了不影响英文英文要加序数词支持也不碰中文逻辑。从工程角度看这是典型的“关注点分离”虽然听起来有点小题大做但你只要经历过一次需求变更——比如客户突然要求支持繁体中文的“萬”而不是“万”——就会庆幸当初拆开了。具体拆法是这样的底层是一个数字解析层负责把输入统一成“整数部分 小数部分 符号”的结构中间是语言规则层中文一套、英文一套最上面是格式化输出层处理大小写、连接符、单位后缀这些展示细节。这个分层不是一开始就想好的是我在第三次重构时才定下来的前两次都是因为需求变化把代码改得面目全非。1.2 中文和英文规则的核心差异对比在动手之前必须把两种语言的规则差异摸清楚否则写出来的代码一定是打补丁式的。我整理了一张对比表这张表是我每次做类似需求时都会翻出来看的对比维度中文表达英文表达基本单位个、十、百、千、万、亿无“万”单位用 thousand/million/billion分组方式四位一组万进制三位一组千进制零的处理中间连续零只读一个“零”通常不读零但百位和十位之间可能加 and连接符无十位和个位之间用连字符21 → twenty-one特殊读法2 在百位前可读“两”无特殊变体大数单位万、亿、兆部分场景thousand, million, billion, trillion这张表里最关键的是分组方式的差异。中文是四位一组因为中文有“万”这个单位英文是三位一组因为英文只有 thousand/million/billion。这个差异直接决定了你的算法结构——中文要按 4 位切分英文要按 3 位切分。如果你用同一套分组逻辑去处理两种语言必然有一边要写大量特殊判断。我见过有人试图用“先转成英文再翻译成中文”的取巧办法结果在“万”这个单位上直接卡死因为英文里没有万你得把 ten thousand 再映射回“万”这个映射在 10 万、100 万这些位置会变得极其混乱。所以我的建议很明确两条链路独立实现不要互相转换。1.3 输入数据的类型选择与精度问题还有一个容易被忽略的设计决策是输入用什么类型。如果你用浮点数接收用户输入恭喜你0.1 0.2 的问题会在这里等着你。我实测过用 double 接收 123.45转成字符串可能是 “123.44999999999999”这时候你再去拆分小数位得到的就是一堆垃圾。我的处理方式是输入统一用字符串接收在解析层做格式校验和拆分。这样做有三个好处第一避免了浮点精度问题第二可以精确控制小数位数财务场景通常固定两位第三能识别用户输入的非法字符并给出明确报错。解析层的职责就是把 “-123.45” 拆成符号位 negative、整数部分 “123”、小数部分 “45”后续所有处理都基于字符串或整数进行不碰浮点运算。对于超大整数比如超过 JavaScript 安全整数范围的数字符串方案同样适用因为你可以按位处理而不需要真正把它转成数值。这一点在做票据打印或者大额金额展示时特别重要我遇到过 16 位以上的数字用 Number 直接精度丢失改成字符串按位处理后问题消失。2. 核心细节解析与实操要点2.1 中文数字表达的规则拆解中文数字的规则看起来简单实际上细节非常多。我把它拆成几个必须处理的点第一是基本数字映射。零到九对应“零一二三四五六七八九”这个没争议。但“二”和“两”的用法有讲究在百位、千位、万位前口语中常用“两”比如“两百”“两千”“两万”但在“十二”“二十”里必须用“二”。我的处理策略是默认用“二”提供一个开关参数控制是否启用“两”的口语化读法因为正式文书里“二百”比“两百”更规范而口语播报场景“两百”更自然。这个参数化设计让我在同一个项目里同时满足了报表导出和语音播报两个场景。第二是零的处理。这是中文转换里最容易出错的地方。规则是数字中间连续的零只读一个“零”末尾的零不读。比如 1005 读“一千零五”1050 读“一千零五十”1500 读“一千五百”。但 10005 读“一万零五”因为万位和个位之间隔了三个零只读一个。更麻烦的是 100000005 这种读“一亿零五”。我实现时用的是“标记法”先按四位分组每组内部转换组与组之间判断是否需要补“零”。判断逻辑是——如果当前组的千位是零且前面还有非零组就补一个“零”。第三是单位的选择。个、十、百、千是组内单位万、亿、兆是组间单位。注意中文里“万”和“亿”之间是万进制所以 10^8 是“一亿”10^4 是“一万”10^12 是“一万亿”而不是“一兆”“兆”在现代中文里较少用于日常计数但在某些领域仍有使用。我默认实现到“亿”这一级超过的部分用“万亿”组合表达这样能覆盖绝大多数业务场景。2.2 英文数字表达的规则拆解英文的规则相对规整但同样有细节第一是三位分组。从右往左每三位一组每组内部按百位、十位、个位处理组间单位依次是空、thousand、million、billion、trillion。这个分组用取模和整除就能实现但要注意组内为零时的处理——比如 1000000 是 “one million”中间的组全为零时不发音。第二是十位和个位的连接。20 到 99 之间整十数直接读twenty, thirty...非整十数用连字符连接twenty-one, ninety-nine。这里有个常见错误是忘记连字符或者把连字符加到了百位和十位之间。正确规则是百位和后面的部分之间用 “and”英式英语或不用美式英语十位和个位之间用连字符。我默认采用美式不加 and因为大多数编程场景和国际化文案用的是美式但提供参数切换。第三是百位的表达。100 到 999 是 “X hundred”后面如果还有非零部分美式直接接one hundred twenty-three英式加 andone hundred and twenty-three。这个差异在跨国项目里必须参数化我吃过亏——同一个系统给不同地区用户展示没做这个切换被投诉过。第四是零和负数的特殊处理。0 读 “zero”负数前面加 “negative”正式或 “minus”口语。我默认用 “negative”因为财务和法律场景更常见。2.3 小数部分的处理策略小数部分是很多人偷懒的地方直接按位读数字比如 3.14 读“三点一四”和 “three point one four”。这个读法在大多数场景是可接受的但如果你要做金额大写就必须读成“三点一四”对应的“角分”表达比如“三元一角四分”。这是两个不同的需求我在实现时把它们做成两个模式逐位读模式小数点后每个数字单独读适合一般数值展示。货币模式小数部分按角、分处理适合财务票据。逐位读模式实现简单把小数部分当字符串遍历映射即可。货币模式要注意补零——比如 3.5 要读“三元五角”还是“三元五角零分”取决于业务规范。我默认输出“三元五角”因为“零分”在口语里通常省略但提供参数控制是否强制补全。英文的小数读法同理逐位读是 “point three one four”货币场景则是 “three dollars and fourteen cents”。注意英文货币表达里整数和小数之间用 “and” 连接这和数字本身的 and 规则是两回事不要混淆。2.4 边界值的系统性梳理在写代码之前我习惯先把边界值列出来这样测试用例就有了。这个需求的边界值包括0 本身负数-1, -100, -0.5单个数字1-9整十、整百、整千10, 100, 1000带零的中间值101, 1001, 10001连续零100001最大单位边界9999, 10000, 99999999, 100000000小数0.1, 1.05, 100.99超长数字超过 15 位把这些列成表格每实现一个规则就跑一遍能省掉大量调试时间。我现在的习惯是先把测试用例写出来再写实现这样代码写完基本就是绿的。3. 实操过程与核心环节实现3.1 解析层把输入拆成结构化数据解析层是整个转换的地基我把它写成一个独立函数输入是字符串输出是一个包含符号、整数部分、小数部分的对象。核心逻辑分三步function parseNumber(input) { // 第一步去除首尾空格校验合法性 const trimmed String(input).trim(); if (!/^-?\d(\.\d)?$/.test(trimmed)) { throw new Error(非法数字格式: input); } // 第二步提取符号 const isNegative trimmed.startsWith(-); const unsigned isNegative ? trimmed.slice(1) : trimmed; // 第三步拆分整数和小数 const [intPart, decPart ] unsigned.split(.); return { isNegative, intPart, // 字符串保留原始位数 decPart, // 字符串可能为空 isZero: /^0$/.test(intPart) /^0*$/.test(decPart) }; }这里用正则做校验而不是用isNaN原因是isNaN()返回 false、isNaN( )也返回 false这些坑我都踩过。正则^-?\d(\.\d)?$能精确匹配“可选负号 至少一位数字 可选的小数部分”干净利落。整数部分保持字符串形态很关键因为后续分组要按位切转成数字反而可能丢精度。小数部分同理保留原始字符串能精确知道用户输入了几位小数。3.2 中文转换的核心实现中文转换我按“四位分组 组内转换 组间拼接”三步走。先看分组function splitByFour(intPart) { const groups []; let str intPart; while (str.length 0) { const len Math.min(4, str.length); groups.unshift(str.slice(-len)); // 从右往左切 str str.slice(0, -len); } return groups; // 高位在前 }比如 “123456789” 切成[1, 2345, 6789]对应“一亿二千三百四十五万六千七百八十九”的分组结构。组内转换是核心处理千、百、十、个四位const CN_DIGITS [零,一,二,三,四,五,六,七,八,九]; const CN_UNITS [, 十, 百, 千]; function convertGroup(groupStr) { let result ; let zeroFlag false; // 标记是否遇到过零 const len groupStr.length; for (let i 0; i len; i) { const digit parseInt(groupStr[i], 10); const unitIndex len - 1 - i; // 当前位对应的单位索引 if (digit 0) { zeroFlag true; // 记录零但不立即输出 } else { if (zeroFlag result ! ) { result 零; // 前面有零且不是开头补一个零 } zeroFlag false; result CN_DIGITS[digit] CN_UNITS[unitIndex]; } } return result; }这段代码的关键在zeroFlag的处理遇到零先记着等遇到下一个非零数字时才补“零”这样连续零只输出一个末尾零自动丢弃。比如 “1005” 处理过程是1 → “一千”0 → 标记0 → 标记5 → 补“零”再输出“五”结果“一千零五”。而 “1500” 是1 → “一千”5 → “五百”0 → 标记0 → 标记循环结束标记丢弃结果“一千五百”。逻辑正确。组间拼接要处理组间补零和单位const CN_GROUP_UNITS [, 万, 亿, 万亿]; function convertIntegerCN(intPart) { const groups splitByFour(intPart); const groupCount groups.length; let result ; for (let i 0; i groupCount; i) { const groupStr groups[i]; const groupValue parseInt(groupStr, 10); const unitIndex groupCount - 1 - i; if (groupValue 0) { // 整组为零标记需要补零如果后面还有非零组 continue; } // 如果当前组小于1000且不是最高组前面需要补零 if (i 0 groupValue 1000) { result 零; } result convertGroup(groupStr) CN_GROUP_UNITS[unitIndex]; } return result || 零; }这里有个细节groupValue 1000判断的是当前组是否需要补零。比如 “100000005” 分组是[1, 0000, 0005]处理到第三组时 groupValue 是 5小于 1000前面补“零”结果“一亿零五”。而 “12345678” 分组[1234, 5678]第二组 5678 不小于 1000不补零结果“一千二百三十四万五千六百七十八”。这个判断逻辑我调了好几版才对。3.3 英文转换的核心实现英文按三位分组逻辑结构类似但单位不同const EN_ONES [, one, two, three, four, five, six, seven, eight, nine, ten, eleven, twelve, thirteen, fourteen, fifteen, sixteen, seventeen, eighteen, nineteen]; const EN_TENS [, , twenty, thirty, forty, fifty, sixty, seventy, eighty, ninety]; const EN_GROUP_UNITS [, thousand, million, billion, trillion]; function convertGroupEN(groupStr) { const num parseInt(groupStr, 10); if (num 0) return ; let result ; const hundreds Math.floor(num / 100); const remainder num % 100; if (hundreds 0) { result EN_ONES[hundreds] hundred; if (remainder 0) { result ; // 美式不加 and英式这里加 and } } if (remainder 0) { if (remainder 20) { result EN_ONES[remainder]; } else { const tens Math.floor(remainder / 10); const ones remainder % 10; result EN_TENS[tens]; if (ones 0) { result - EN_ONES[ones]; // 连字符连接 } } } return result; }注意 11 到 19 是特殊读法不能拆成 “ten-one”所以remainder 20时直接查EN_ONES数组这个数组前 20 项包含了 0 到 19 的完整映射。这个设计比单独维护一个 teens 数组更简洁。组间拼接function convertIntegerEN(intPart) { const groups splitByThree(intPart); const groupCount groups.length; const parts []; for (let i 0; i groupCount; i) { const groupStr groups[i]; const groupValue parseInt(groupStr, 10); if (groupValue 0) continue; const unitIndex groupCount - 1 - i; let part convertGroupEN(groupStr); if (EN_GROUP_UNITS[unitIndex]) { part EN_GROUP_UNITS[unitIndex]; } parts.push(part); } return parts.length 0 ? parts.join( ) : zero; }英文的组间拼接比中文简单因为不需要补零直接跳过零组、用空格连接即可。这也是两种语言规则差异的体现——中文的零是必须显式读出来的英文的零在组间是隐式的。3.4 小数与符号的统一处理小数部分按逐位读模式实现function convertDecimalCN(decPart) { if (!decPart) return ; const digits decPart.split().map(d CN_DIGITS[parseInt(d, 10)]); return 点 digits.join(); } function convertDecimalEN(decPart) { if (!decPart) return ; const digits decPart.split().map(d EN_ONES[parseInt(d, 10)]); return point digits.join( ); }符号处理放在最外层function numberToChinese(input, options {}) { const parsed parseNumber(input); if (parsed.isZero) return 零; let result convertIntegerCN(parsed.intPart); if (parsed.decPart) { result convertDecimalCN(parsed.decPart); } if (parsed.isNegative) { result 负 result; } return result; }英文同理负数前缀用 “negative”。这里注意零的判断要放在符号处理之前因为 “-0” 应该输出 “零” 而不是 “负零”。3.5 货币模式的扩展实现货币模式是我在实际项目里加的需求核心是把小数部分转成角分function numberToChineseCurrency(input) { const parsed parseNumber(input); const intCN convertIntegerCN(parsed.intPart); // 小数部分补齐两位 let decStr (parsed.decPart 00).slice(0, 2); const jiao parseInt(decStr[0], 10); const fen parseInt(decStr[1], 10); let result intCN 元; if (jiao 0 fen 0) { result 整; } else { if (jiao 0) result CN_DIGITS[jiao] 角; if (fen 0) result CN_DIGITS[fen] 分; } return (parsed.isNegative ? 负 : ) result; }这里(parsed.decPart 00).slice(0, 2)是补零技巧比如输入 “3.5”decPart 是 “5”补成 “500” 取前两位 “50”角是 5、分是 0输出“三元五角”。输入 “3” 则 decPart 为空补成 “00”输出“三元整”。这个技巧比写一堆 if 判断简洁得多。4. 常见问题与排查技巧实录4.1 中文“零”处理的典型错误我在 review 别人代码时见过最多的错误是零的输出位置不对。有个典型案例1000000 被输出成“一百万零”末尾多了个零。原因是循环结束后没有清理 zeroFlag。修复方法是在循环结束后不输出任何标记只在遇到非零数字时才补零。这个 bug 很隐蔽因为 100 这种数不会触发百位后没有零只有整千、整万这种末尾带零的数才会暴露。另一个常见错误是连续零输出多个。比如 10001 输出“一万零零一”。原因是每遇到一个零就输出一次没有用标记合并。正确做法就是我前面代码里的 zeroFlag 方案——遇到零只标记遇到非零才补一个零并清除标记。还有一个更隐蔽的100000001 这种跨组的情况。分组是[1, 0000, 0001]中间组全零被跳过但第三组 1 小于 1000 需要补零结果“一亿零一”。如果补零逻辑写在组内转换里而不是组间拼接里这里就会漏掉。所以补零要分两层组内补零处理组内连续零组间补零处理跨组的零。4.2 英文连字符和空格的混淆英文输出里最常见的格式问题是连字符用错位置。正确规则只有一条十位和个位之间用连字符其他位置用空格。但实际代码里经常出现 “twenty one”缺连字符、“one-hundred”多连字符、“one hundred-twenty three”位置错这几种错误。我的排查方法是写一个正则校验/^[a-z](-[a-z])?( [a-z](-[a-z])?)*$/检查输出里连字符只出现在单词内部、空格只出现在单词之间。这个正则不完美但能抓住大部分格式错误。更彻底的办法是把输出按空格和连字符都切开检查每个片段是否在词典里这样能发现拼写错误。还有一个坑是 “and” 的位置。英式英语里 101 是 “one hundred and one”美式是 “one hundred one”。如果代码里硬编码了 and切换到美式时就会多出来。我的做法是把 and 做成配置项在百位和剩余部分之间根据配置决定是否插入。注意 and 只在百位和十位/个位之间出现不会出现在千位和百位之间1234 是 “one thousand two hundred thirty-four”thousand 和 two 之间没有 and。4.3 大数精度丢失的排查用 Number 类型处理超过 15 位的数字时精度丢失是必然的。我遇到过输入 “12345678901234567”转成 Number 后变成 12345678901234568最后一位变了。排查方法是打印Number(input).toString()和原始字符串对比一眼就能看出来。解决方案就是全程用字符串。分组、组内转换、拼接都不需要真正的数值运算只需要按位取字符和查表。唯一需要转数字的地方是parseInt(groupStr, 10)但每组最多 4 位中文或 3 位英文远在安全范围内。所以只要保证分组正确大数问题自然消失。4.4 常见问题速查表问题现象可能原因排查方法解决方案末尾多“零”zeroFlag 未清理测试 100、1000循环结束丢弃标记连续多个“零”每遇零就输出测试 10001用标记合并连续零跨组漏“零”补零只在组内做测试 100000001组间拼接时判断补零英文缺连字符拼接时用空格测试 21、99十位个位间用连字符英文多 and硬编码英式规则测试 101and 做成配置项大数末位错误用了 Number 类型对比输入输出字符串全程字符串处理小数精度错误浮点运算测试 0.10.2小数按字符串处理负数输出“负零”零判断在符号后测试 -0先判零再处理符号4.5 几个实操心得第一个心得是先写测试再写实现。这个需求的测试用例很好列把边界值表格里的每一项写成断言实现过程中跑一遍就知道哪里错了。我现在的习惯是测试文件比实现文件先创建虽然听起来反直觉但在这个需求上效率提升明显。第二个心得是词典和规则分离。数字映射、单位映射都放在常量数组里转换逻辑只负责查表和拼接。这样以后要加繁体中文或者别的语言只需要换词典逻辑不用动。我有个项目后来要支持日语数字就是靠这个结构在半天内加完的。第三个心得是参数化而不是硬编码。and 用不用、“两”用不用、货币模式补不补“整”这些都做成 options 参数。看起来增加了复杂度但实际项目里需求变化太频繁参数化能让你少改很多次代码。我的默认配置是美式英文、正式中文用“二”不用“两”、货币模式补“整”这套默认值覆盖了大部分场景。第四个心得是输出前做一次自校验。我在转换函数最后加了一步把输出的中文再解析回数字写一个简单的反向解析和输入对比不一致就抛异常。这个自校验在开发阶段帮我抓到了好几个隐蔽 bug上线后可以关掉以节省性能。反向解析不需要很精确能抓住数量级错误和明显的零错误就够了。5. 扩展场景与性能考量5.1 序数词和百分比等衍生需求数字转英文的衍生需求里序数词first, second, third是最常见的。实现思路是在基数词基础上做后缀变换大部分加 “th”但 1/2/3 是 first/second/third21/22/23 又是 twenty-first 这种组合形式。我的做法是单独维护一个序数词映射表处理 1-1920 以上按“整十 序数个位”拼接整十本身加 “-eth”twentieth, thirtieth。中文的序数词简单前面加“第”即可“第一”“第二十一”没有变形问题。百分比就是在转换结果后加“百分之”或 “percent”。注意中文里“百分之五十”和“50%”的读法差异以及英文 “fifty percent” 中间有空格。这些衍生需求我建议都做成独立的包装函数不要塞进主转换函数里保持主函数的单一职责。5.2 批量转换的性能优化单次转换的性能完全不用担心纳秒级别。但如果要做批量转换比如一次转一万条就有优化空间了。我实测过一个朴素实现转 10 万条耗时约 200ms主要开销在字符串拼接和正则校验上。优化手段有两个一是把正则校验提到批量入口处做一次内部转换函数跳过校验二是用数组 join 代替字符串累加减少中间字符串对象。优化后同样数据量降到 80ms 左右。不过说实话除非你的场景真的对性能敏感否则没必要做这些优化代码可读性更重要。5.3 国际化场景下的注意事项如果这个功能要用于国际化产品有几个点要提前考虑。第一是数字格式的地区差异比如有些地区用逗号做小数点、用点做千分位解析层要能识别这些格式。第二是单位差异比如“万”在日语里读法不同英文的 billion 在不同地区含义曾有差异长级差和短级差虽然现代基本统一用短级差billion 10^9但老系统里可能遇到长级差billion 10^12。第三是 RTL 语言从右往左书写的拼接顺序问题这个在阿拉伯语等语言里需要注意不过中文和英文都是 LTR暂时不涉及。我在实际项目里的做法是把语言规则做成可插拔的配置对象每种语言一个配置文件包含词典、单位、连接规则、零处理策略。主流程只依赖配置接口不依赖具体语言。这样加新语言就是加一个配置文件的事主流程一行不用改。这个架构在后期扩展时省了大量时间。5.4 一个完整的调用示例把前面的模块串起来最终对外暴露的 API 大概是这样// 中文正式风格 numberToChinese(123456789.45); // 输出一亿二千三百四十五万六千七百八十九点四五 // 中文货币模式 numberToChineseCurrency(1234.5); // 输出一千二百三十四元五角 // 英文美式 numberToEnglish(123456789); // 输出one hundred twenty-three million four hundred fifty-six thousand seven hundred eighty-nine // 英文英式 numberToEnglish(101, { british: true }); // 输出one hundred and one // 负数 numberToChinese(-1001); // 输出负一千零一这套 API 我在三个项目里复用基本没改过核心逻辑只是按需加了配置项。代码量不大中文转换约 120 行英文约 100 行加上解析和工具函数总共 300 行左右单文件就能放下不需要拆模块。最后分享一个我在调试时常用的小技巧把转换过程的分组结果打印出来。中文转换时打印四位分组数组英文打印三位分组数组对照着看就能快速定位是分组错了还是组内转换错了。这个习惯帮我省了大量时间尤其是处理那些看起来“莫名其妙”的输出时一看分组就明白了。数字转换这个需求规则本身不复杂复杂的是边界和细节把边界列全、把规则拆细、把配置留好剩下的就是体力活了。