Numeral.js是一个轻量级的数字格式化库,常用的时间和金额展示基本都能覆盖。但真实项目里总会冒出一些特殊需求:财务系统要输出中文大写金额,报表页面希望把12345678显示成1234.57万,还有的地方要求正数带加号、负数用红色括号包裹。这些需求如果直接散落在各个组件里写格式字符串,后期几乎没法统一维护。比较好的做法是基于Numeral.js的插件机制封装一层自定义格式,并用TypeScript把类型约束补齐,让格式化逻辑集中管理。本文就来完整讲一下这套封装的思路和实现。

一、先弄清楚Numeral.js的插件注册机制
Numeral.js对外暴露了一个register方法,这是官方提供的扩展入口。它接受一个对象,包含locale和format两个核心属性。注册format类型后,格式字符串中出现的对应标识就会被交给你的自定义函数处理,而不是走内置的四舍五入和小数位逻辑。
需要特别注意的是,自定义format的匹配规则:Numeral会从格式字符串中提取未被内置语法识别的部分,然后去已注册的自定义format里查找。举例来说,注册了一个叫cnmoney的格式类型后,写numeral(1024).format('cnmoney')就会进入你自己的format函数,函数签名是(value, format, numeral),其中value是当前数值,format是完整格式字符串,numeral是实例本身。
还有一个容易踩的坑:自定义format的注册是全局生效的,如果项目里有多套格式化规则冲突,注册顺序会影响结果。建议把所有注册逻辑收敛到一个初始化模块里,应用启动时统一执行一次,避免各模块自行注册导致不可控。
二、用TypeScript定义类型契约
直接调用register时,TypeScript会报参数类型不匹配,因为Numeral.js的官方类型声明里register的入参定义得比较宽泛。我们需要自己补一层声明合并,给自定义格式扩展出明确的类型。下面是一个基础的类型定义文件:
// types/numeral-plugin.d.ts
import 'numeral';
declare module 'numeral' {
// 自定义format的类型签名
export interface NumeralFormat {
(value: number, format: string, numeral: Numeral): string;
}
// 注册器扩展,约束format名称必须是字面量联合类型
export interface NumeralRegister {
(options: { locale?: string; format: { type: string; regexps: { format: RegExp; unformat?: RegExp }; format: NumeralFormat } }): void;
}
}
用字面量联合类型约束格式名称是这套封装的核心价值。如果不加约束,format('cnmoney')和format('cnmony')这种手误只能在运行时发现;而把合法的格式名收敛成'cnmoney' | 'wan' | 'signed'这样的联合类型,写错一个字母编辑器立刻飘红,重构改名时也能全局追踪。可以通过再声明一个SafeFormatString类型来实现:
type CustomFormatName = 'cnmoney' | 'wan' | 'signed';
// 交叉类型叠加,让format方法同时接受内置格式和自定义格式
declare module 'numeral' {
interface Numeral {
format(input?: CustomFormatName | string): string;
}
}
这里选择对format的入参做扩展而不是替换,是为了兼容项目里已有的内置格式字符串,迁移成本最低。类型声明完成后,后续所有插件的实现都要满足这个契约,编译期就能挡住大部分低级错误。
三、实现三个典型插件:万元单位、中文大写、带符号
先看最常用的万元单位转换。报表场景下大数字直接展示可读性差,转成万并保留两位小数更符合中文用户习惯。实现思路很简单:把数值除以一万,再复用Numeral内置的0,0.00格式输出:
import numeral from 'numeral';
// 万元单位格式化插件
numeral.register('format', 'wan', {
regexps: {
format: /wan/,
unformat: /wan/
},
format: (value, format) => {
// 从格式字符串中解析小数位数,默认2位
const digitsMatch = format.match(/d(\d+)/);
const digits = digitsMatch ? Number(digitsMatch[1]) : 2;
const scaled = value / 10000;
// 复用内置格式处理千分位和小数
return numeral(scaled).format(`0,0.${'0'.repeat(digits)}`) + '万';
},
unformat: (str) => {
// 反格式化:去掉万字后还原为原始数值
const clean = str.replace(/万/g, '');
return numeral(clean).value() * 10000;
}
});
注意unformat方法也要一并实现,否则表格组件里用户输入"3.5万"做筛选时会解析失败。这个细节在很多项目里都被忽略了。
第二个例子是中文大写金额,财务对账单的硬性要求。核心是把数值按亿、万、元三级拆分,逐级映射到中文数字:
const CN_NUM = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
const CN_UNIT = ['', '拾', '佰', '仟'];
// 将0到9999的整数段转成中文大写
function segmentToCn(n: number): string {
let result = '';
let zeroPending = false;
const digits = String(n).split('').map(Number);
digits.forEach((d, i) => {
const unit = CN_UNIT[digits.length - 1 - i];
if (d === 0) {
zeroPending = result.length > 0;
} else {
if (zeroPending) result += '零';
zeroPending = false;
result += CN_NUM[d] + unit;
}
});
return result;
}
numeral.register('format', 'cnmoney', {
regexps: { format: /cnmoney/ },
format: (value) => {
if (value === 0) return '零元整';
const abs = Math.abs(value);
const yi = Math.floor(abs / 100000000);
const wan = Math.floor((abs % 100000000) / 10000);
const yuan = Math.floor(abs % 10000);
const fen = Math.round((abs % 1) * 100);
let result = '';
if (yi > 0) result += segmentToCn(yi) + '亿';
if (wan > 0) result += segmentToCn(wan) + '万';
if (yuan > 0) result += segmentToCn(yuan) + '元';
if (fen === 0 && result) {
result += '整';
} else {
const jiao = Math.floor(fen / 10);
const cent = fen % 10;
if (jiao > 0) result += CN_NUM[jiao] + '角';
if (cent > 0) result += CN_NUM[cent] + '分';
}
return value < 0 ? '负' + result : result;
}
});
这个实现处理了连续零的合并、整数末尾的整字、负数前缀等边界情况,基本覆盖财务系统的常规要求。如果业务涉及超大规模金额或者特殊舍入规则,可以在拆分前先对value做一次预处理。
第三个是带符号格式,常用于同比环比数据。它展示了自定义format和内置格式字符串混用的能力:
numeral.register('format', 'signed', {
regexps: { format: /signed/ },
format: (value, format) => {
// 把signed替换成内置格式,叠加正负号逻辑
const inner = format.replace(/signed/, '0,0.00');
const base = numeral(Math.abs(value)).format(inner);
const sign = value > 0 ? '+' : '';
return sign + base;
}
});
// 使用示例
numeral(0.156).format('signed'); // +0.16
numeral(-1234.5).format('signed'); // -1,234.50
四、封装成模块并在业务中统一调用
插件写完后,建议封装一个门面模块,对外只暴露初始化函数和类型安全的调用入口,把Numeral的细节隐藏起来。这样即便将来要换格式化库,业务代码也不用动:
// formatters/index.ts
import numeral from 'numeral';
import '../plugins/wan';
import '../plugins/cnmoney';
import '../plugins/signed';
export type FormatType = 'wan' | 'cnmoney' | 'signed';
export function formatNumber(value: number, type: FormatType): string {
return numeral(value).format(type);
}
// 应用入口调用一次确保插件注册
export function initFormatters(): void {
// import副作用已完成注册,这里可做校验或locale初始化
numeral.locale('chs');
}
单元测试方面,由于注册是全局的,测试文件之间共享状态,建议在测试入口统一导入插件模块,用例只验证formatNumber的输出。针对中文大写金额,务必覆盖零值、纯小数、连续零、负数、极大值这几类边界输入,这些恰恰是线上问题的高发区。
最后总结一下这套封装的收益:类型层面,格式名称在编译期受约束,手误无处遁形;架构层面,所有格式化规则集中在一处,改一处全局生效;维护层面,每个插件是独立文件,职责单一,新增格式只需注册新的type。对于数字展示密集的中后台项目,这套方案能显著降低格式化逻辑的失控风险。
TypeScriptNumeral.js数字格式化修改时间:2026-09-12 16:32:44