数字转中文全解析:从规则到自定义实现
数字转中文这件事看起来像是个练手小项目真动手写起来坑比想象中多得多。金额大写、日期读法、小数处理、负数、超大数、零的省略规则、自定义单位……每一条单拎出来都够调半天。我前后在几个内部工具里做过类似功能从最早硬编码一套规则到后来抽成可配置的转换模块踩过的坑基本覆盖了这个需求的全部边角。这篇就把“阿拉伯数字转简(繁)体中文且支持自定义”这件事从头拆一遍讲清楚规则背后的逻辑、参数怎么定、代码怎么组织、哪些地方最容易翻车。适合正在做财务票据、合同生成、报表导出、语音播报文本预处理这类功能的开发者参考也适合想把这个小功能做扎实、做成可复用模块的人。读完你至少能拿到一套可直接抄的转换规则表、一份能跑的实现思路以及一堆别人不会写在文档里的避坑经验。1. 需求拆解与整体设计思路1.1 这个功能到底要解决什么问题“阿拉伯数字转中文”这句话太笼统了。同样是把1234转成中文财务场景要的是“壹仟贰佰叁拾肆元整”报表场景要的是“一千二百三十四”语音播报要的是“一千二百三十四”但读法上要处理“一”和“幺”的取舍日期场景要的是“二〇二四”而不是“二千零二十四”。所以第一步不是写代码而是把使用场景列清楚因为场景决定了规则规则决定了实现结构。我一般会先问三个问题转出来的中文给谁看是给人读还是给机器读有没有金额、编号、序数这类特殊语义这三个问题的答案基本能框定需求边界。比如给财务系统用那必然要支持简体大写壹贰叁、繁体大写壹貳叁、货币单位元角分整还要处理“零”在金额里的特殊写法给普通展示用简体小写一二三加标准单位万亿兆就够了给编号类场景用往往要逐位读不做单位进位。标题里特别提到“支持自定义”这是这个需求最有价值的部分。自定义通常包含几层自定义数字字符映射比如把 0-9 映射成别的符号体系、自定义单位体系万进制还是千进制、有没有“兆”“京”、自定义零的读法、自定义小数点和负号的表达。把这些做成配置项而不是写死在代码里是这个模块能不能复用的关键。1.2 为什么选择“规则表 分段处理”的架构实现这个功能有两条路。一条是纯字符串逐位处理边读边拼另一条是先按数位分段再对每段套规则。我强烈建议走第二条原因是中文数字的进位规则本质上是四位一组的个、十、百、千构成一组组与组之间用万、亿、兆这类大单位连接。你把这个结构想明白整个实现就顺了。具体来说任意一个整数都可以拆成若干个四位段从低位往高位分别是“个级”“万级”“亿级”“兆级”。每一段内部用“千百十”加个位表达段与段之间插入大单位。这样处理的好处是每一段的转换逻辑完全一致可以写成一个独立函数反复调用大单位可以做成数组配置想加“京”“垓”就往后追加零的处理也集中在段内和段间两个位置不会散落各处。提示四位分段是这个功能的地基。如果你一上来就逐字符扫描再判断进位代码会迅速膨胀成一坨难以维护的条件分支后期加繁体或自定义单位几乎要重写。规则表的设计也值得说一下。数字字符映射、单位字符、大单位、零字符、负号、小数点全部抽成配置对象。默认给一套简体小写、一套简体大写、一套繁体大写用户传自定义配置就覆盖默认值。这样同一套核心逻辑能服务所有变体测试也好写——换个配置跑同一批用例就行。1.3 简体、繁体、大写三套体系的差异点很多人以为繁体就是把简体字换一下实际差异比想象中细。简体小写是“一二三四五六七八九十百千万亿”繁体小写是“一二三四五六七八九十百千萬億”这个确实只是字形替换。但大写体系里简繁差异和用字规范要复杂一些简体大写“壹贰叁肆伍陆柒捌玖拾佰仟”繁体大写“壹貳參肆伍陸柒捌玖拾佰仟”其中“贰/貳”“叁/參”“陆/陸”是常见差异点。财务场景还有“圆/元”的取舍规范写法里“元”更通用但不少票据模板用“圆”这个必须做成可配置。还有一个容易忽略的点大写金额里“十”要写成“拾”而且当数值在 10 到 19 之间时规范读法是“壹拾X”而不是“拾X”。比如 15 元要写“壹拾伍元整”不能写“拾伍元整”。这个规则在小写里是反过来的——小写 15 读“十五”前面不加“一”。这种“大小写规则不对称”的地方是这个功能最容易出 bug 的位置之一后面会专门讲。2. 核心规则解析与关键细节2.1 零的省略规则整个功能最烧脑的部分零的处理是数字转中文里最反直觉的地方。规则本身不复杂但边界情况极多。我把它归纳成几条段内连续的零只读一个“零”段末的零不读段首的零不读因为段首零意味着这一级没有有效数字整段跳过如果某一级整体为零但更高一级有数字需要补一个“零”作为占位连接。举几个例子对照着看就清楚了。1001读“一千零一”中间两个零读一个1010读“一千零一十”个位的零不读1100读“一千一百”段末零不读10000读“一万”万级是一、个级全零个级整段跳过不补零10001读“一万零一”个级有有效数字但高位是零要补“零”100000001读“一亿零一”中间亿级和个级之间隔着全零的万级只补一个“零”。看出来了吗补零的判断依据不是“有几个零”而是“两个有效数字之间是否隔着至少一个零位”。实现上我通常这样做先把数字按四位分段每段独立转成不含大单位的字符串转换时记录这段是否“以零开头”和“是否全零”拼接时根据前一段是否全零、当前段是否以零开头来决定要不要插入一个“零”。这套逻辑用状态机描述最清晰比一堆 if-else 可靠得多。注意10这个数在小写里读“十”不读“一十”但110读“一百一十”1010读“一千零一十”。也就是说“十”前面是否省略“一”只在它处于最高位且没有更高位数字时成立。这个特例必须单独处理否则10会输出“一十”在口语化场景里很别扭。2.2 小数、负数与超大数的处理策略小数部分相对简单但要先明确语义。普通展示场景小数点读“点”后面逐位读数字比如3.14读“三点一四”注意是“一四”不是“十四”。金额场景则完全不同小数部分要按角、分处理3.14元是“叁元壹角肆分”而且金额通常保留两位多余位数要按规则舍入。这两种语义必须分开实现不能混用。负数就是在结果前加“负”字这个没什么争议但要注意负号字符的识别——用户输入可能是-、全角、−数学负号做输入清洗时要统一。超大数方面JavaScript 的 Number 精度在超过 2^53 后就不准了所以如果输入是字符串形式的大数千万别先转 Number 再处理直接按字符串分段。这一点在金额和编号场景里非常关键我见过因为先转数字导致末尾几位变成 0 的事故。自定义单位体系时大单位数组的长度决定了支持的最大位数。四位一段单位数组长度 N 就支持到 4N 位。默认给[, 万, 亿, 兆, 京]能覆盖到 20 位日常够用。如果要支持更长的往后追加即可但要注意中文里“兆”以上单位在日常语境中已经很少用读出来反而让人困惑所以默认配置我一般只到“亿”需要时再开。2.3 大小写规则不对称的坑点清单前面提到大小写规则不对称这里系统列一下这些都是实测中真实踩过的场景小写规则大写规则备注10-19十五省略一壹拾伍不省略最高位特例单位用字十百千万亿拾佰仟万亿万、亿大小写同形金额结尾不加加“整”或“正”有角分则不加整零的读法零零一致小数点点点非金额/角分金额语义分叉这张表建议直接放进你的测试用例里每一条都对应至少一个断言。尤其是 10-19 那条我见过不止一个实现把小写规则套到大写上结果票据上出现“拾伍元”财务审核直接打回。3. 实操实现从配置到代码落地3.1 配置对象的设计与默认值先把配置结构定下来这是整个模块的骨架。我用一个对象承载所有可变部分字段包括数字字符数组长度 10、小单位数组十百千长度 3、大单位数组、零字符、负号字符、小数点字符、是否启用大写规则、金额模式开关。默认导出三套预设SIMPLE简体小写、UPPER_SIMPLE简体大写、UPPER_TRADITIONAL繁体大写。const PRESETS { SIMPLE: { digits: [零,一,二,三,四,五,六,七,八,九], units: [,十,百,千], bigUnits: [,万,亿,兆], zero: 零, negative: 负, point: 点, upperCase: false, moneyMode: false }, UPPER_SIMPLE: { digits: [零,壹,贰,叁,肆,伍,陆,柒,捌,玖], units: [,拾,佰,仟], bigUnits: [,万,亿,兆], zero: 零, negative: 负, point: 点, upperCase: true, moneyMode: false }, UPPER_TRADITIONAL: { digits: [零,壹,貳,參,肆,伍,陸,柒,捌,玖], units: [,拾,佰,仟], bigUnits: [,萬,億,兆], zero: 零, negative: 負, point: 點, upperCase: true, moneyMode: false } };自定义就是在这个结构上做浅合并用户传什么覆盖什么。这样设计的好处是新增一套体系不用改核心逻辑加个预设对象就行。金额模式我单独做成开关而不是独立预设因为它改变的是小数部分的语义和字符映射是正交的。3.2 整数分段转换的核心函数核心函数分两层convertSection处理单个四位段convertInteger负责分段、调用、拼接。先看段内转换逻辑是遍历四位遇到非零数字就拼“数字单位”遇到零就标记连续零只记一次。function convertSection(sectionStr, cfg) { // sectionStr 是 1-4 位的字符串如 1001 let result ; let zeroFlag false; const len sectionStr.length; for (let i 0; i len; i) { const d parseInt(sectionStr[i], 10); const unitIndex len - 1 - i; // 对应千百十个 if (d 0) { zeroFlag true; } else { if (zeroFlag result ! ) { result cfg.zero; } zeroFlag false; result cfg.digits[d] cfg.units[unitIndex]; } } return result; }这段代码有几个细节值得说。zeroFlag result ! 这个条件保证了段首的零不会输出因为此时 result 还是空同时段中零只输出一次。段末零因为后面没有非零数字触发输出自然被丢弃。unitIndex的计算依赖段字符串的长度所以传进来的段必须是去掉前导零后的有效表示或者长度固定为 4 但前导零会被 zeroFlag 逻辑吃掉——两种都行我用后者更省事。然后是外层拼接。从低位往高位遍历段每段转换后根据情况决定是否补零、是否加大单位。function convertInteger(intStr, cfg) { if (intStr 0) return cfg.zero; const sections []; for (let i intStr.length; i 0; i - 4) { sections.unshift(intStr.slice(Math.max(0, i - 4), i)); } let result ; let prevAllZero true; for (let i 0; i sections.length; i) { const sec sections[i]; const bigUnitIndex sections.length - 1 - i; const secStr convertSection(sec, cfg); const allZero /^0$/.test(sec); if (allZero) { prevAllZero true; continue; } // 当前段有值但前一段全零且不是第一段需要补零 if (!prevAllZero sec[0] 0 result ! ) { result cfg.zero; } result secStr cfg.bigUnits[bigUnitIndex]; prevAllZero false; } return result; }这里prevAllZero的初始值是 true表示“前面没有有效段”所以第一段即使以零开头也不补零。补零条件!prevAllZero sec[0] 0的意思是前面有有效数字当前段以零开头说明中间隔了零位补一个零。这个逻辑我调了好几轮才稳定建议你写完后用10001、100000001、1001001这几个数验证。3.3 金额模式与小数处理的实现金额模式的核心是把小数部分转成角分。做法是先对数值做两位小数舍入然后整数部分走大写转换小数部分拆成角和分。角为零分不为零时读“零X分”角分都为零结尾加“整”。function convertMoney(numStr, cfg) { const rounded roundToTwo(numStr); // 自行实现两位舍入注意用字符串运算避免精度问题 const [intPart, decPart 00] rounded.split(.); let result convertInteger(intPart, cfg) 元; const jiao parseInt(decPart[0] || 0, 10); const fen parseInt(decPart[1] || 0, 10); if (jiao 0 fen 0) { return result 整; } if (jiao 0) { result cfg.digits[jiao] 角; } else if (fen 0) { result cfg.zero; // 角为零分不为零补零 } if (fen 0) { result cfg.digits[fen] 分; } return result; }舍入这块要特别小心。parseFloat加toFixed(2)在边界值上会出问题比如1.005会变成1.00而不是1.01因为浮点表示不精确。稳妥做法是用字符串做大数运算或者引入一个成熟的十进制库。我在实际项目里吃过这个亏票据金额差一分钱对账时非常难查。提示金额模式下的“元”和“圆”做成配置项别写死。有些票据模板强制用“圆”改起来就是一行配置的事。4. 常见问题排查与避坑实录4.1 典型 bug 速查表下面这张表是我这些年在这个功能上真实遇到过或 review 到的问题按出现频率排序建议直接当测试清单用。问题现象根因修复方式10 输出“一十”最高位十的特例未处理小写模式下最高位为十且无更高位时省略“一”1001 输出“一千零零一”段内零未去重用 zeroFlag 标记连续零10001 输出“一万零一”正确但 100000001 输出“一亿零零一”段间补零逻辑按段数补改为按“是否隔着零位”补一个零金额 1.005 舍入错误浮点精度字符串运算或十进制库大写 15 输出“拾伍”大小写规则混用大写模式不省略“壹”超大数末尾变 0先转 Number 丢精度全程字符串处理繁体“万”没转大单位数组未区分预设里分开配置负数负号不识别输入未清洗全角符号统一替换为半角再判断4.2 输入清洗与边界防御真实环境里用户传进来的东西什么都有前后空格、全角数字、千分位逗号、货币符号、多个小数点、空字符串。我的做法是在入口处做一层清洗把全角转半角、去掉千分位和货币符号、校验格式合法性不合法直接抛错而不是返回奇怪结果。这一步看着琐碎但能挡掉后面 80% 的诡异 bug。边界值方面重点测这几个0、10、100、1000、10000、10001、100000000、100000001、-1、0.01、0.10、1.00、999999999999。这几个数覆盖了零的省略、段间补零、负数、金额角分、超大数几乎所有分支。我习惯把它们写成一个数组每次改完逻辑跑一遍比肉眼检查靠谱得多。4.3 自定义能力的扩展边界“支持自定义”听着美好但要划清边界否则配置项会失控。我的经验是字符映射、单位体系、零和负号、小数点这些纯展示层的东西可以开放自定义但分段规则四位一组、进位逻辑、零的省略算法这些结构性规则不要开放因为一旦开放配置组合爆炸测试根本覆盖不过来。用户真需要不同的分段方式那是另一个功能不该塞进这个模块。另外自定义配置要做校验和兜底。比如用户传的 digits 数组长度不是 10或者 bigUnits 长度和预期不符要在初始化时就报错而不是等到转换时输出乱码。我一般会在合并配置后跑一个validateConfig检查关键字段的类型和长度不通过就回退到默认预设并给出警告。这个习惯救过我好几次尤其是在多人协作、配置由外部传入的场景里。最后分享一个实测有效的小技巧把这个转换函数做成纯函数输入字符串和配置输出字符串不依赖任何外部状态。这样它天然可测试、可缓存、可并发调用嵌到任何系统里都不会有副作用。我在几个不同项目里复用同一份实现靠的就是这个约束——纯函数加配置驱动换个场景只换配置核心代码一行不动。