Currency.js是一个轻量的JavaScript货币计算库,它的核心能力在于解决浮点数运算带来的精度问题,比如0.1 + 0.2 !== 0.3这类经典坑。但它本身并不负责汇率获取,跨币种转换时需要开发者自己传入汇率值。在一个真实项目里,汇率可能来自静态配置、第三方API、数据库缓存等多种来源,如果没有统一的类型抽象,每个调用点都要自己处理汇率加载、判空、刷新等细节,代码很快会失控。本文围绕如何用TypeScript为汇率来源设计提供者类型定义展开,给出一套可直接落地的方案。

一、为什么需要抽象汇率提供者
最直接的写法是在调用处硬编码汇率,例如currency(100).divide(7.2)来近似人民币转美元。这种方式在小demo里没问题,但一旦汇率来源变化,比如从固定值换成实时接口,所有调用点都要修改。更麻烦的是,不同调用点可能各自实现了汇率获取逻辑,有的同步、有的异步、有的抛异常、有的返回null,行为不一致会埋下大量隐患。
抽象出提供者接口后,调用方只依赖抽象而不依赖具体实现。无论是静态汇率表、带缓存的API提供者,还是测试用的mock提供者,只要满足同一个类型签名,就能互相替换。TypeScript的静态检查会在编译期校验实现是否完整,漏掉一个方法或者参数类型不对都会直接报错,这比纯JavaScript靠运行时踩坑要可靠得多。
从设计模式的角度看,这本质上是策略模式的应用:汇率获取策略被封装成独立的对象,转换逻辑只关心“给我一个汇率”,不关心汇率从哪来、什么时候来。后面会看到,配合依赖注入或简单的注册器,可以做到运行时切换提供者而不改动业务代码。
二、设计核心Provider接口
先定义基础类型。汇率本质上是一个从币种对到数值的映射,币种用ISO 4217标准的三字码表示。接口设计上需要考虑同步与异步两种场景,下面给出一个渐进式的定义:
// 币种代码类型,约束为三个大写字母组成的字符串字面量模板
type CurrencyCode = `${string}${string}${string}`;
// 汇率查询结果
interface RateResult {
base: CurrencyCode; // 基准币种,例如 'USD'
quote: CurrencyCode; // 目标币种,例如 'CNY'
rate: number; // 1 base = ? quote
fetchedAt: number; // 汇率获取时间戳
}
// 错误对象,携带上下文信息方便排查
class RateNotFoundError extends Error {
constructor(public readonly pair: string) {
super(`汇率未找到: ${pair}`);
this.name = 'RateNotFoundError';
}
}
// 核心提供者接口
interface RateProvider {
/** 查询汇率,不存在时抛出 RateNotFoundError */
getRate(base: CurrencyCode, quote: CurrencyCode): Promise<RateResult>;
/** 可选:判断是否支持某个币种对 */
supports?(base: CurrencyCode, quote: CurrencyCode): boolean;
}这里有几个设计细节值得说明。第一,RateResult携带了fetchedAt时间戳,为后续实现汇率过期逻辑留出空间。第二,supports被声明为可选方法,因为静态汇率表往往能提前判断支持情况,而远程API可能需要真正请求后才知道。第三,错误统一用自定义异常类,调用方可以用instanceof精确捕获,而不是靠字符串匹配错误消息。
关于getRate返回Promise还是同步值,建议统一为异步。即使当前实现是同步的静态表,也返回Promise,这样将来切换到网络请求时调用方代码完全不用改。TypeScript的async关键字会让同步函数自动包装成Promise,实现成本几乎为零。
三、实现静态汇率表提供者与API提供者
有了接口之后,先实现最简单的静态提供者,适合汇率固定或测试场景:
class StaticRateProvider implements RateProvider {
private readonly rates = new Map<string, RateResult>();
constructor(initial: Record<string, number>) {
const now = Date.now();
for (const [pair, rate] of Object.entries(initial)) {
// pair 格式为 'USD-CNY'
const [base, quote] = pair.split('-') as [CurrencyCode, CurrencyCode];
this.rates.set(pair, { base, quote, rate, fetchedAt: now });
}
}
supports(base: CurrencyCode, quote: CurrencyCode): boolean {
return this.rates.has(`${base}-${quote}`);
}
async getRate(base: CurrencyCode, quote: CurrencyCode): Promise<RateResult> {
const result = this.rates.get(`${base}-${quote}`);
if (!result) {
throw new RateNotFoundError(`${base}-${quote}`);
}
return result;
}
}再实现一个基于远程接口的提供者,重点是加入缓存与过期控制,避免每次转换都发起网络请求:
interface ApiProviderOptions {
endpoint: string;
ttl: number; // 缓存有效期,毫秒
fetchImpl?: typeof fetch; // 允许注入 fetch,方便测试
}
class ApiRateProvider implements RateProvider {
private cache = new Map<string, RateResult>();
constructor(private readonly options: ApiProviderOptions) {}
async getRate(base: CurrencyCode, quote: CurrencyCode): Promise<RateResult> {
const key = `${base}-${quote}`;
const cached = this.cache.get(key);
// 命中且未过期直接返回
if (cached && Date.now() - cached.fetchedAt < this.options.ttl) {
return cached;
}
const doFetch = this.options.fetchImpl ?? fetch;
const resp = await doFetch(`${this.options.endpoint}?base=${base}&symbols=${quote}`);
if (!resp.ok) {
throw new Error(`汇率接口请求失败,状态码 ${resp.status}`);
}
const data = await resp.json();
const result: RateResult = {
base,
quote,
rate: data.rates[quote],
fetchedAt: Date.now(),
};
this.cache.set(key, result);
return result;
}
}注意构造函数里注入了fetchImpl,默认使用全局fetch。这是典型依赖注入手法,单元测试时可以传入一个返回固定数据的假实现,不需要真的发起网络请求,也不用借助额外的mock库。缓存策略采用简单的TTL判断,对大多数汇率场景够用;如果对一致性要求更高,可以在此基础上扩展为定时预刷新。
四、封装Currency.js的转换函数与注册器
提供者就绪后,写一个类型安全的转换入口,把Currency.js和提供者串起来:
import currency from 'currencyjs';
interface ConvertOptions {
precision?: number;
from?: string; // 币种符号显示,如 '$'、'¥'
}
async function convert(
provider: RateProvider,
amount: number,
from: CurrencyCode,
to: CurrencyCode,
options: ConvertOptions = {}
): Promise<string> {
const { rate } = await provider.getRate(from, to);
const converted = currency(amount).multiply(rate);
return converted.format({ precision: options.precision ?? 2 });
}如果项目里存在多个汇率来源并且需要动态切换,可以再加一层注册器。用Map维护名称到提供者的映射,配合泛型保证注册和获取的类型一致:
class ProviderRegistry {
private providers = new Map<string, RateProvider>();
register(name: string, provider: RateProvider): this {
this.providers.set(name, provider);
return this; // 支持链式调用
}
get(name: string): RateProvider {
const provider = this.providers.get(name);
if (!provider) {
throw new Error(`未注册的提供者: ${name}`);
}
return provider;
}
}
// 使用示例
const registry = new ProviderRegistry()
.register('static', new StaticRateProvider({ 'USD-CNY': 7.2 }))
.register('api', new ApiRateProvider({ endpoint: 'https://api.ipipp.com/rates', ttl: 300000 }));
// 按环境切换:测试用静态,生产用API
const activeProvider = process.env.NODE_ENV === 'test'
? registry.get('static')
: registry.get('api');
convert(activeProvider, 100, 'USD', 'CNY').then(console.log);这套类型定义的收益在维护阶段体现得最明显。当需要新增一种提供者,比如从Redis读汇率,只要实现RateProvider接口,TypeScript会立刻提示哪些成员还没实现;调用convert的地方传入了不匹配的对象也会编译报错。此外所有提供者的错误行为保持一致,上层只需统一捕获RateNotFoundError与请求异常两类错误,处理逻辑不会因来源不同而分叉。整体方案没有引入额外运行时依赖,纯粹靠类型系统约束行为,对打包体积几乎没有影响,适合直接移植到任何使用Currency.js的TypeScript项目中。
TypeScriptCurrency.js汇率转换修改时间:2026-09-05 14:52:41