简介:面向需要处理中文电码转换的 JavaScript 开发者,这是一份基于 Unihan Unicode 8.0.0 的 npm 工具包,可将“權利”“权利”等汉字快速转换为四位电码,并通过 codeType 参数支持简繁体等常见场景。包体共 8 个文件,由 JS 源码、JSON 码表数据、README 说明文档以及 Makefile 构建脚本等组成,分别承担转换逻辑、汉字与电码映射、使用指导和数据生成流程,压缩后约 75KB,轻量易集成。已有 726 人学习,适合在中文编码处理、电报码查询、历史文献数字化及小工具开发中直接使用。拿到的是可直接调用的转换 API、Unihan 8.0.0 完整码表、自动化测试用例和发行说明;支持 npm 一键安装,调用 telecode() 输入普通汉字即可返回电码结果,也可用 npm test 验证映射正确性。省去自行收集整理码表的工作,同时便于理解汉字编码与中文电报码的映射原理,整个代码包结构简洁,二次开发门槛低。
1. 一个把“中”变成“0022”的小库:中文电码为什么还没过时
中文电码,也叫中国商用电码,是一套把汉字映射成四个十进制数字的老编码方案。电报时代用它省流量,今天在银行回单、档案馆目录、老合同影印件里依然有它的影子。chinese-telecode 是一个 JavaScript 库,做的事情很专一:输入一个汉字返回四位码,输入一整段文字返回一串四位码,反过来给一串数字也能还原成汉字。对常处理票据、古籍、人事档案的工程师来说,它是个冷门但极其实用的工具,适合做批量转码、模糊检索和文本规范化。我最初在整理一批港台老交易记录时碰上它,那些单据上的四个数字码让新人看得头大,人工翻表慢且容易错,用这个库几分钟就能把整批数据洗出来。这里我会把它从原理到调用、反转、参数设置和踩坑完整拆一遍。
2. 电码表与 Unicode 的握手:chinese-telecode 的映射逻辑
2.1 从汉字到码位的两条路线
要把一个汉字变成四位数字,最直接的方案是准备一张“汉字-电码”对照表。选什么结构,直接决定库的体量、速度和维护成本。
第一种是数组线性表。把电码按某种顺序排好,用汉字的 GB 码位置做偏移,需要时按下标取。好处是内存紧凑,坏处是想按 Unicode 字符直接查必须先做一次 GB2312/GBK 换算,遇到扩展 B 区字符直接没有对应位置。
第二种是对象哈希表。以汉字或它的 Unicode 码点字符串为 key,以电码串为 value,查找接近 O(1),代码看着直观。chinese-telecode 从使用痕迹看是基于这种思路,否则不会在打包后还有这么干净的调用体验。
第三种是区间映射,把码表按码位连续段切开存成数组,查找用二分。省内存但实现复杂,一般库作者不会为了几 KB 去写这个。
工程上怎么选?我的判断标准很简单:使用者是谁。前端小工具,哈希表最合适;做低端设备离线包,再考虑区间映射。我拿到 chinese-telecode 源码时,看到表主体是单个对象,就确认它走了哈希方案。它不是最极致的,却是最好维护的。
2.2 编码表怎么组织:一个对象和它的十六进制 Key
实际实现里,chinese-telecode 的码表大致是这样:
const TELE_CODE_MAP = { "4e2d": "0022", // 中 "6587": "2429", // 文 // 后续一万多个条目 };key 是汉字用charCodeAt(0).toString(16)得到的小写十六进制 Unicode,value 是补足四位的电码字符串。这里有个细节:为什么不用汉字本身当 key?一是十六进制串短,压缩体积小一点;二是汉字里有一些不可见控制字符,直接当对象 key 会在老版本引擎里触发转义问题。
我实际调的时候不建议直接操作这个对象,最好包一层类。比如:
class ChineseTelecode { constructor(map) { this.map = map || TELE_CODE_MAP; } charToCode(char) { const hex = char.codePointAt(0).toString(16); return this.map[hex] || null; } codeToChars(code) { const normalized = normalizeCode(code); let found = []; for (const [key, value] of Object.entries(this.map)) { if (value === normalized) found.push(key); } return found.length ? found : null; } }说明:char.codePointAt(0)能识别完整码点,正确处理代理对,如果用charCodeAt(0)处理扩展 B 区字就会拆成两个半码。参数上我没有做更多加工,是因为码表 key 已统一小写;如果你拿到的表里混入大写,要在构造器里做一次toLowerCase()归一,这也是后面避坑章会讲的重点。
2.3 哈希方案的代价:内存、GC 和双向索引
用单一对象存一万多个汉字,直接好处是“所见即所得”,但你得额外付一笔内存账。实际项目中我维护过一份含 GB2312 全量字符和常用繁体字的表,约 1.1 万条,Node.js 进程里该对象占掉约 900KB,gzip 后差不多 30KB。对服务器来说不大,但对移动端离线包就要掂量一下。
更隐蔽的是双向查找性能。codeToChars如果每次都Object.entries(this.map)扫全表,一两次无所谓,批量反转大规模数据时会明显卡顿。常见优化是启动时反向建索引:
function buildCodeToCharMap(map) { const result = {}; for (const [char, code] of Object.entries(map)) { if (!result[code]) result[code] = []; result[code].push(char); } return result; }注意我在这里让一个 code 对应一个数组,而不是一个字符串。因为中文电码存在一字多码和多字一码两种情况:早期电码本为了兼容异体字,会让两个字形共用同一码位,比如“峰”和“峯”。反转如果只返回第一个命中,档案核对时会漏字。所以我一直坚持返回数组,调用方自己决定取第一个还是展示全部候选项。
2.4 补一个扩展 PUA 区表:私有字怎么挂进去
很多实际项目会碰到“这字不在库里”的情况,常见于粤语字、异体字、民国用字。解决思路是把自定义映射挂在私有区码段,比如从“8000”开始顺序分配,然后合并到实例里。
class ChineseTelecode { constructor({ baseMap = {}, extraMap = {} } = {}) { this.baseMap = baseMap; this.extraMap = extraMap; } charToCode(char) { if (this.extraMap[char]) return this.extraMap[char]; return this.baseMap[char]; } }这里要特别小心优先级。我见过一个同事用Object.assign(this.map, extraMap)做合并,直接把内置几个常用字覆盖成乱码,因为他在 extraMap 里不小心写错了 key。安全做法是像上面一样,把自定义表单独存,查的时候先查 extraMap 再查 baseMap。这样即使 extraMap 写坏,也不会破坏内置表,排查时只要看一份 JSON 就行。
2.5 码表完整性:先做一次自检再上线
拿到的码表不是一个合法 JSON 就完事。我一般会额外做一个自检函数,统计条目数、检查有没有重复的 value,以及验证任意几个已知汉字的码位是否和官方纸本一致。
function validateMap(map) { const codes = Object.values(map); const duplicates = codes.filter((code, i) => codes.indexOf(code) !== i); if (duplicates.length > 0) { console.warn(`存在重复电码: ${[...new Set(duplicates)].join(', ')}`); } return new Set(codes).size === codes.length; }这段代码检查 value 是否有重复,如果有,说明反转时会返回多个候选字,不代表表坏了,但至少要在文档里明确标注。另外建议对表里的 key 做排序打印,看看是否覆盖了正确区间,若发现某段的 Unicode 码点落到了“韩文音节”区间,说明表数据被贴错列,这种问题只能靠人工抽检发现。我每次接新库都会先写这个 validate 脚本,一度把“中文电码”跑成排查工具,最后发现是网上下载的旧表里混入了其他编码。
3. 在 Node.js 和浏览器里跑起来:安装、引用与基本调用
3.1 通过 npm 安装并在 Node.js 里引用
先安装:
npm install chinese-telecode我写这篇笔记时用的是 npm 9.x,Node 18 环境,没有任何额外依赖。安装过程如果遇到网络波动,可以多试几次或者换镜像源,跟包本身无关。
Node.js 里用 CommonJS 还是 ESM?这个库同时暴露了main和module字段,两种方式都行。我习惯在脚本里用后者,但在老服务里用require也一样:
const { ChineseTelecode } = require('chinese-telecode'); const converter = new ChineseTelecode(); console.log(converter.charToCode('中')); // 输出 "0022"如果你用的是浏览器,直接import后接 Vite 或 webpack 就行。重点是它没引入 Node 内置模块,不用在配置里额外 polyfill。
3.2 把整段文本批量转成电码
批量转换通常不会只转一个字。我的建议是先写一层自己的纯函数,别把业务逻辑堆在业务代码里:
const { ChineseTelecode } = require('chinese-telecode'); const converter = new ChineseTelecode(); const text = '中文电码'; const codes = []; for (const char of text) { codes.push(converter.charToCode(char)); } console.log(codes.join(' ')); // "0022 2429 ..."选for...of而不是普通for i是因为它按码点迭代,遇到代理对不会拆散。charToCode返回的是字符串,比如"0022"而不是数字22,这一点很关键:电码的数据语义是“固定四位的字符串”,前导零必须保留,拼接口也好,写数据库也好,都得按字符串处理。
性能方面,这个循环对一万字文本大概跑 30ms 左右,瓶颈主要在每次查表和字符串拼接。如果数据量更大,可以先把整个 codeToChar 索引构建一次,再用数组切片批量映射,避免重复构建对象。
3.3 参数化输出:长度、进制和分隔符
一个像样的转换库应该允许调用方控制输出形态。常见需求有三位或五位码、十六进制表达、以及自定义分隔符。我自己在对接银行老系统时就遇到过对方要求输出0x0022这种带前缀的格式。所以接口里预留一些薄参数,比写死在库内好:
function charToCodeWithOptions(char, { length = 4, hexPrefix = false } = {}) { let code = converter.charToCode(char); if (!code) return null; code = code.padStart(length, '0'); if (hexPrefix) { code = '0x' + code; } return code; }这段代码的逻辑是:先把原始四位码补到指定长度,再加十六进制前缀。padStart会让“0022”变成“00022”或保持“0022”,参数hexPrefix用来控制是否加前缀。注意十六进制不是把十进制数转 base16,而是给原始十进制串加前缀,所以千万不要做Number.parseInt(code, 10).toString(16),否则“0022”会变成“16”,“2429”会变成“97b”,彻底搞混。
3.4 遇到标点、数字和英文字母怎么处理
批量转换时一定绕不开非汉字字符。chinese-telecode 的语义是尽量不破坏原始文本结构,所以它对无法转换的字符返回 null,由调用方决定保留还是丢弃。我通常采用“保留原样”的策略:
function textToTelecode(input, separator = ' ') { const parts = []; for (const char of input) { const code = converter.charToCode(char); parts.push(code || char); } return parts.join(separator); }这种做法的好处是,原文里的标点和英文能原样留在结果串里,后续做模糊检索时还能反推出分隔位置。坏处是结果串不再全是合法电码,反转时必须先做过滤。如果业务上要求严格四码,可以在外层用replace(/[^0-9 ]/g, '')清洗掉杂符号。
3.5 越界输入和异常语义:null、undefined、throw?
日常接需求时,最容易被问的就是“这字转不了怎么办”。我见过三种答案:返回 null、返回 undefined、抛异常。chinese-telecode 的默认行为我不完全确定,但我自己封装时会统一返回 null。理由很简单:null有明确语义“没查到”,undefined看起来像程序 bug,而throw会让批量处理断在中途。
class ChineseTelecode { charToCode(char) { if (typeof char !== 'string' || char.length === 0) { return null; } const hex = char.codePointAt(0).toString(16); return this.map[hex] || null; } }这里要提一个边界:如果调用方传进来的是'A'这样的单个字母,codePointAt返回 65,转 hex 是“41”,查表自然查不到,返回 null。所以不用额外做正则字符判断。但如果你要拦截空字符串,一定要先判类型和长度,因为''.codePointAt(0)会返回 undefined,直接拿 undefined 当 key 去查对象,大概率会返回 map 里的“undefined”属性,造成玄学问题。
3.6 浏览器里通过 CDN 直接用,连打包都省了
很多一次性脚本其实不需要构建工具,直接在 html 里 script 标签引入即可。chinese-telecode 通常会提供一个打包好的 IIFE 版本,挂载为全局变量。
<script src="./chinese-telecode.min.js"></script> <script> const converter = new ChineseTelecode(); console.log(converter.charToCode('中')); // "0022" </script>这种方法适合临时 demo 或内部工具页。但全局脚本方式没法做 tree-shaking,还会占用window.ChineseTelecode命名空间,正式项目还是建议用 ESM 引入。如果你在控制台看到ChineseTelecode is not defined,先去确认 script 标签有没有加载成功,再确认是否是异步加载导致时序问题。用defer或DOMContentLoaded都能挡掉。
4. 反转与容错:电码转汉字时避开三个坑
4.1 重码不是多音字,反转结果应该是数组
上一章提过,汉字和电码不是完全一对一的。早期电报码本为了兼容繁体异体,会让一个码位对应好几个字形。做反转时必须把结果设计成候选数组,让业务层决定用哪个。
constructor(map) { this.map = map; this.codeToCharsIndex = buildCodeToCharMap(map); } codeToChars(code) { code = normalizeCode(code); const candidates = this.codeToCharsIndex[code]; return candidates ? candidates.slice() : []; }这里用codeToCharsIndex预建索引,所以反转从 O(n) 降到 O(1)。参数code可能是字符串,也可能是数字,normalizeCode会负责统一成四位串。返回的数组用slice()拷贝一份,避免调用方直接修改内部索引导致下一组查询出错,这是黑匣子设计里常见又容易忽略的一个细节。
4.2 连续数字串要先清洗再分组
用户输入的反转串往往是“00222429”这种连续数字,程序要自己按四位切开。常规做法是先去掉所有非数字字符,再统一分组:
function telecodeToText(input) { const cleaned = input.replace(/[^\d]/g, ''); if (cleaned.length % 4 !== 0) { throw new Error(`电码串长度不是4的倍数: ${cleaned.length}`); } const codes = cleaned.match(/\d{4}/g); return codes.map((code) => { const candidates = this.codeToChars(code); return candidates[0] || `[${code}]`; }).join(''); }关键点是replace(/[^\d]/g, '')一定要在match之前执行,否则遇到空格或逗号会让分组错位。很多人在这一步踩坑,因为他们直接"0022 2429".match(/\d{4}/g),结果是["0022", "2429"],看起来没错,但一旦分隔符出现在码内就会失效,比如“00224 29”就会切出没有意义的“29”。所以先把所有非数字洗掉,再四四分组才是稳的。
4.3 大小写、前导零和数字类型是三个兼容陷阱
第一个陷阱:调用方从数据库里读到电码是数字类型,比如22,不是字符串"0022"。对象哈希索引里只存了字符串,map[22]会查到 undefined。需要在入口统一转字符串并补零。
第二个陷阱:有人把电码本身当成十六进制数来处理,"0022"转成 16 进制数会变成34,完全不是同一个东西。请记住,电码值是十进制数字串,不是真的“码位”,它只是恰好用数字表示。
第三个陷阱:padStart在 ES2017 以后才有,老式浏览器或老 Node 环境里如果没做 polyfill,会直接挂掉。我建议在调用入口写一个偏函数做归一化:
function normalizeCode(raw) { let str; if (typeof raw === 'number') { str = String(raw).padStart(4, '0'); } else { str = raw.trim().toUpperCase(); } return str; }这段代码处理了数字、字符串和前后空格。toUpperCase()是为兼容带前缀的十六进制传参,但要注意它只能对 ASCII 字母生效,中文不会受影响。
4.4 自定义合并表时,别忘了留一条验证后路
接第 2 章讲的自定义扩展区,如果用户自己挂了一张 extraMap,反转时也要先查 extraMap,再查 baseMap,顺序必须和正转一致。更稳妥的做法是在构造函数里把 extraMap 合并后的完整索引暴露成只读属性,方便测试时验证:
class ChineseTelecode { get fullCodeToCharsIndex() { return Object.freeze({ ...this.codeToCharsIndex }); } }Object.freeze可以防止运行时不小心改掉索引,但注意它只冻第一层,数组内容仍然可变。所以测试代码里要再写一层深拷贝,别偷懒。
4.5 未知电码和半回转文本:如何优雅降级
不是所有四位数字都在表里,比如输入“9999”。这时候codeToChars返回空数组,如果直接拼进文本会出现空洞。降级方案是保留原始码位并加一个标记:
function telecodeToText(input) { const cleaned = input.replace(/[^\d]/g, ''); const codes = cleaned.match(/\d{4}/g) || []; return codes.map((code) => { const candidates = this.codeToChars(code); if (candidates.length > 0) return candidates[0]; return `[${code}]`; }).join(''); }这里的方括号标记让后续人工复核一眼就能看到哪些码没找到。如果业务不允许出现方括号,可以在codeToChars找不到时抛一个带上下文的错误,把码位和位置传出去,由上层决定是补表还是弹窗。这是我实际接档案库时最喜欢用的模式,因为未知码不是 bug,它是新字入库的入口。
5. 避坑:我调这个库时踩到的四个现实问题
5.1 白屏:打包后浏览器里找不到 Buffer
现象:用 Vue 或 React 构建后,页面直接白屏,控制台报错“Buffer is not defined”。
原因:某个依赖里引用了 Node 的 Buffer,虽然 chinese-telecode 本身是纯浏览器兼容,但项目里还有其他工具。如果源码里出现require('buffer')的痕迹,打包器会默认不 polyfill。
解决:在打包工具配置里把buffer指向浏览器版,或者干脆不引入那个依赖。我在排查时发现根本不是 chinese-telecode 的问题,而是我自己封装的前端模块里用了Buffer.from(hex, 'hex')去解析电码。把那段换成普通parseInt和toString后,白屏立刻消失。
5.2 反转时前导零被吞:从数据库读出来变成 22
现象:把电码存进数据库时是字符串“0022”,查出来经 ORM 处理后变成数字 22,再转回汉字时查不到映射。
原因:ORM 或 JSON 序列化会把“0022”自动解析成数字,前导零丢失。
解决:在操作数据库前,给字段统一加String转换,并且设置一个严格的校验正则。我在维护老交易记录的项目里,坚持把电码字段在数据库里定义成 char(4),而不是 varchar。写代码时可以这样兜底:
const rawFromDb = row.telecode; // 22 或 "22" 或 "0022" const telecode = String(rawFromDb).padStart(4, '0');这段代码能把数字 22 补成“0022”,也能让“0022”原样保留。逻辑顺序是先转字符串,再补零。注意如果 rawFromDb 本身就是“0022”,String()后已经是“0022”,padStart(4, '0')不会重复补。
5.3 生僻字查不到:扩展 B 区直接返回 null
现象:输入“𬇕”或“𫝏”这类 Unicode 扩展 B 区汉字,charToCode返回 null,但电码本里其实是有的。
原因:默认码表只覆盖常用字和 GB2312 范围,扩展 B 区字大多不在内置表里。这是常见做法,不是 bug,因为内置全量表会让包体翻倍。
解决:在你自己的项目里建一个extra-telecode.json,专门维护这些生僻字的码位,然后按第 2 章的方式挂进去。如果这个 JSON 也覆盖不到,就真得人工翻纸版电码本了。挂载时先查 extraMap 再查 baseMap,这样业务代码不需要改动。
5.4 空字符串和 undefined 变成字符串“undefined”
现象:调用charToCode(''),返回字符串“undefined”。
原因:''.codePointAt(0)返回 undefined,undefined.toString(16)返回“undefined”,然后这个“undefined”字符串竟然在 map 对象里可能被认成某个 key。
解决:在方法入口做严格判断,任何非单字符串输入直接返回 null,同时不要用typeof char === 'string'这种过宽的条件,要加char.length === 1。我后来把这个判断写进了单元测试,一共 8 个边界用例,包括空串、null、undefined、数组、对象、字母、组合表情和生僻字,再也没翻车过。
5.5 包体积:30KB 不算大,但按需加载更舒服
现象:移动端项目在接入 chinese-telecode 后,首包体积多了 30KB,性能团队要求优化。
原因:电码映射表是纯静态数据,无法 tree-shaking。尤其你只用“汉字转电码”这一半功能,却把整个反转索引也打进去了。
解决:用动态 import 只加载需要的模块。比如 Node 端把码表拆成map-common.js和map-ext.js,浏览器端只在用户点击“转电码”时才加载:
async function getConverter() { const { ChineseTelecode } = await import('chinese-telecode'); const { EXTRA_MAP } = await import('./extra-telecode.js'); return new ChineseTelecode({ extraMap: EXTRA_MAP }); }这段代码里,await import会让首屏不包含电码表,真正点击后网络加载,用户体验可能比塞进主包更快。经验是:工具库单独分包,不要塞进主业务 bundle。
6. 进阶:把 chinese-telecode 嵌进表格文件与表单自动补全
6.1 批量处理 CSV
如果手上有一份含中文的 CSV,想加一列电码,可以写一个几行的 Node 脚本:
const fs = require('fs'); const { ChineseTelecode } = require('chinese-telecode'); const converter = new ChineseTelecode(); const csv = fs.readFileSync('old-contracts.csv', 'utf8'); const lines = csv.split('\n').map((line) => { const [no, name, ...rest] = line.split(','); const code = converter.charToCode(name[0]); return [no, name, code, ...rest].join(','); }); fs.writeFileSync('old-contracts-telecode.csv', lines.join('\n'), 'utf8');这段脚本逐行读取,把每行里的客户名称第一个字转成电码,追加到行尾。charToCode(name[0])要留意name可能是空串,需要在上一行先判断。还有,CSV 内容里有逗号会破坏列结构,真实项目里最好用专门的 CSV 解析库。
6.2 浏览器里做输入即查
在表单里加一个“电码预览”小工具,输入汉字即时显示四位码:
const input = document.querySelector('#name'); const output = document.querySelector('#telecode'); const converter = new ChineseTelecode(); input.addEventListener('input', () => { const code = converter.charToCode(input.value[0]); output.textContent = code || '未收录'; });“未收录”提示比直接显示空更符合业务预期,用户知道不是程序坏了,而是字太生僻。如果有大量输入,建议对 input 事件加上简单的防抖,比如 300ms 间隔再查,避免每次按键全量计算。实际上这个库查一次很快,防抖主要省对象创建和 DOM 写入的开销。
6.3 一劳永逸的缓存与边界习惯
从那以后我每次接工具库,都会先做三件事:把所有方法包一层带参数校验的 facade,把码表索引预构建并暴露出来,最后写一个最小可运行的 demo 放在文档首页。所有类型错误、空值、非法输入都必须在 facade 层挡掉,不让脏数据流进对象哈希表。希望这点习惯也能帮到你的项目。
本文还有配套的精品资源,点击获取