16
头图

一、背景

    货币转换插件github地址

    这是一个由我编写的轻量级数字转货币字符串插件, 因为发现各个国家的货币相关知识很有趣, 并且当前市场上相关插件比较老, 所以也想与你分享这些有趣知识。

     本插件可以通过用户输入一个数字, 将其转化成货币的字符串格式, 比如说如数字12.345则转换成货币字符串:
image.png

    当今世界上货币的标识方式五花八门非常有趣, 比如"印尼"的小数点是"逗号", 印尼的千分号是"句点", 新加坡的货币符号居然是"SGD", 日元的标识符号也是"¥", 既然遇到这么多奇奇怪怪的写法那当然是要把它统一起来解决啦。

二、货币知识小课堂

- 小数点

    我们国家使用"."(句点)作为小数点, 但是比如 '印尼'、'德国'、'巴西' 等国家都是使用","(逗号)标识小数点:

中国:   123.45
印尼:   123,45
- 千分位

    与小数点一样, 我们国家使用","(逗号)作为千分位, '印尼'等国家使用"."(句点)标识千分位:

中国:   12,345,678
印尼:   12.345.678
- 精确位数

    我们国家比较常见是保留两位小数, 也就是精确到"元角分"的"分", 但是比如"越南盾"就是没有小数的概念, 可能是因为"越南盾"动辄几万所以小数价格几乎没有意义了:

    2022/5/20: 1人民币 ≈ 3400 越南盾

    为帮助大家理解可以一起看看这个新闻:
image.png

- 货币符号的长短与位置

    我们熟知的是 "¥" "$" 他们都只是使用一个符号来表示, 其实世界上还有好多长度大于1的标识方法:

印尼: Rp 123
新加坡: SGD 123
蒙古语: CN¥ 123

不但长度不同, 连货币符号的位置也不同, 不少国家是将货币符号放后面的:

中国  ¥ 123
德国  123 €
越南  123 ₫
冰岛  123 ISK
- 货币符号的重复

    比如日元使用的也是 "¥":
image.png

- 负数的表示方法

    先看下excel里面的表示方式:
image.png

    可以看出默认是"负号 + 货币符号 + 金额", 但我通过网络发现不少人网站采用的是 "货币符号 + 负号 + 金额"的写法, 那么我的理解是需要给予用户自由选择的权利。

中国
-¥123
¥-123

新加坡
-SGD123
SGD-123

越南: 货币符号在后面不存在这些烦恼
-123₫

三、网上已有的方案

     第一个是: accounting.js仓库地址(4.8k Star) 使用的人多, 不太好的点是需要用户来指定展示规则, 也就是默认翻译成美元, 其他的翻译形式与货币符号都需要用户自定义:
image.png

     并且大量的issue指出这个库的计算精度有bug。
     11年前的老库, 并且没有ts支持。

     第二个是: currencyFormatter.js仓库地址(632 Star) 其内所有的配置全靠代码全罗列的写法不实在不优雅, 说白了就是每种语言如何展示都是内置在插件里的, 但是用户无法使用自由组合的展示方式, 比如 $123.4万这种组合展示模式:

image.png

     6年前的老库, 需要实时更新配置文件, 并且没有ts支持。

四、制作插件前的需求分析

学习完上述知识后, 我们就可以在做"插件"之前进行一下需求的统计:

  1. 需求: 可将数字转换为任意国家货币格式。
  2. 需求: 可控制千分号的显隐。
  3. 需求: 可自由指定金额的计算方式"四舍五入", "向下取整", "向上取整"。
    场景: 默认是四舍五入的计算方式, 但是数量级大了的情况下误差也会比较大, 比如商家的 1件商品 1元钱, 达人带货可以获得 1.4%的佣金, 所以每件商品是 1 * 0.014 , 按照四舍五入计算会变成0.01元, 但是向上取整的话就是0.02元, 两种展示方式差别是很大的。
  4. 需求: 可自由指定保留小数的位数, 比如用户实际场景中需要让越南盾精确到一位小数。
    场景: 还是卖货的例子, 比如商家的 1件商品 1元钱, 达人带货可以获得 1.4%的佣金, 所以每件商品是 1 * 0.014, 但是我国默认是保留两位小数, 此时可以让用户指定保留n位小数。
  5. 需求: 提供方法, 返回详尽的货币信息, 辅助用户玩出花样。
    场景: format方法只返回格式化后的货币字符串, 并且要提供一个方法返回大而全的信息方便用户自由组装展示方式, 返回值的格式如下:
// 比如格式化 12345.67 为人民币, 返回的详细信息
{
      isFront: true, // 货币符号是否在金额前方
      currencySymbol: "¥" // 货币符号
      formatValue: "12,345.67", // 货币
      value: 12345.67, // 原本的值
      currencyString: "¥12,345.67", // 格式化后的货币
      negativeNumber: false, // 是否为负数
}

五、Intl.NumberFormat 何许人也

原生方法 Intl.NumberFormat 是对语言敏感的格式化数字类的构造器类

    Intl.NumberFormat MDN

    今天的主角此时才姗姗来迟, 我们可以使用Intl.NumberFormat方法构造出, 浏览器原生支持的格式化货币的方法, 先看下基础用法:

var number = 123456.789;

// 德语使用逗号作为小数点,使用.作为千位分隔符
console.log(new Intl.NumberFormat('de-DE').format(number));
// → 123.456,789

// 大多数阿拉伯语国家使用阿拉伯语数字
console.log(new Intl.NumberFormat('ar-EG').format(number));
// → ١٢٣٤٥٦٫٧٨٩

// India uses thousands/lakh/crore separators
console.log(new Intl.NumberFormat('en-IN').format(number));
// → 1,23,456.789

     pc端的话ie浏览器对Intl.NumberFormat方法的兼容性不好, 它的兼容性如下所示:

image.png

六、如何指定 国家&货币符号, 我就乱指定了会怎么样?

     比如我们要转化成中国地区的中国货币格式:

new Intl.NumberFormat(
  'zh', 
  { 
    style: 'currency', 
    currency: 'CNY' 
   }
).format(12.345);

// ¥12.35

     上面代码中的 'zh' 参数代表中国地区, currency: 'CNY' 代表使用人民币符号, 我整理了如何查询指定地区的code的网站:

查询国家代码: BCP 47 language tag
查询货币代码: ISO 4217 currency codes

乱指定货币会怎么样?

    每次学习一个新的api总是忍不住试试不按规范填写会发生什么, 比如下面这样:

// 土耳其
new Intl.NumberFormat(
  'tr-TR', 
  { 
    style: 'currency', 
    currency: 'TRY' 
   }
).format(12345.678);

// ₺12.345,68

// 我将地区指定为'土耳其' 货币指定为 '人民币'
new Intl.NumberFormat(
  'tr-TR', 
  { 
    style: 'currency', 
    currency: 'CNY' 
   }
).format(12345.678);

// CN¥12.345,68

     上面可以看出货币的小数点与千分位的写法还是'土耳其'的写法规范, 但是货币符号变成了'CN¥', 也就是因为不止一个国家使用¥符号, 所以土耳其当地需要前面加上 CN来区分国家, 所以说本国家展示本国家货币则直接使用原本的货币符号, 而不是中国国内使用CN¥, 而是直接¥。

    被本地化的符号

    人民币的 '¥' 在土耳其变成了 'CN¥', 我在网上搜索了一下发现两种写法都标识人民币, 所以它是土耳其本地用来区分货币符号的展示方式, 此时我突然想到日元也是 ¥ , 那么土耳其是如何展示这些同样使用 ¥ 符号的?

new Intl.NumberFormat(
  'tr-TR', 
  { 
    style: 'currency', 
      currency: "JPY", 
   }
).format(12345.678);

// ¥12.346   日元默认没有小数

     日元是直接展示¥, 所以需要通过写法的不同来区分国家。

七、面对多个国家

    我实际业务中遇到了这个场景, 需要把数字转换成多个国家的货币, 所以我们这个插件需要支持如下的使用方式。

    初始化各种配置参数, 假设我们的插件导出一个 CurrencyFormat 方法, addFormatType添加格式化配置的时候必须指定一个name, 方便后续调用指定的方法:

    下面是我的插件的用法

  const currencyFormat = new CurrencyFormat();
  currencyFormat.addFormatType("人民币", {
    locale:'zh',
    currency: "CNY"
    // ... 其余配置
  });
  
  currencyFormat.addFormatType("新加坡", {
    locale:'zh-SG',
    currency: "SGD",
    // ... 其余配置
  });
  
  currencyFormat.addFormatType("日元", {
    locale:'ja-JP',
    currency: "JPY",
    // ... 其余配置
  });

    这里是使用的方式:

currencyFormat.format('人民币', 12.34)
currencyFormat.format('新加坡', 12.34)
currencyFormat.format('日元', 12.34)

    上述的方式就可以实现, 只配置一次, 即可随处调用。

实际写一下基础代码结构:
class CurrencyFormat {
  formatObj = new Map();
  addFormatType(typeName, options) {
      const { locale, currency } = options;
      const formatFn = new Intl.NumberFormat(locale, {
        currency,
        style: "currency"
      }).format;
      this.formatObj.set(typeName, { formatFn });
  }
  format(typeName, val) {
    const formatItem = this.formatObj.get(typeName);
    if (formatItem) {
      const { formatFn } = formatItem;
      return formatFn(val);
    }
    return "-";
  }
}

     实例化 Intl.NumberFormat, 然后每次调用对应的实力。

八、隐藏与展示千分号

     这个可以很直接的通过一个属性即可, useGrouping 为 false的时候则为不展示'千分号'。

 new Intl.NumberFormat(locale, {
        currency,
        style: "currency"
        useGrouping: false
      });

九、自由指定保留位数

     比如中国默认保留两位小数, 但是用户需要查看小数点后三位的数字, 此时就有必要让用户指定货币格式化后要保留的位数, 参数名称为 maximumFractionDigits:

     要注意, 如果 maximumFractionDigits 是个负数的话会报错:

  new Intl.NumberFormat(locale, {
        currency,
        style: "currency"
        useGrouping: false,
        maximumFractionDigits: 3
      });

    这里重点是如果用户未指定 maximumFractionDigits, 那么此时就是用户使用的 '国家地区' 的保留小数默认值, 在我的插件里这个默认值后续会用来进行指定计算,那么我要如何知道当前的计算是精确到几位小数?

    很遗憾原生没有提供获取某个地区的计算精度的方法, 所以需要人为的计算出来:

    计算的时候要注意特殊的打印格式:

// 通过编号系统中的nu扩展键请求, 例如中文十进制数字
console.log(new Intl.NumberFormat('zh-Hans-CN-u-nu-hanidec').format(number));
// → 一二三,四五六.七八九
方式一:

     正则匹配, 假如数字1.234567转换后的'货币字符串'比如 '$1.23' 或者'1.23₫' , 进行从后往前的匹配, 从遇到到第一个数字开始记录, 如果遇到 '.' 或 ',' 则停止匹配返回结构的长度。

     但其实有更优雅的正则方式, 当我传入0进行格式化时, 只需要匹配出 "数字1 + 小数点 + 数字2" 的模式中的数字2的长度即可:

    let maxFractionDigits = 0;
      const currencyTempString = formatFn(0).replace(/\s/g, "");
      const regVal = /[0〇]+[\.\,]([0〇]+)/g;
      const resArr = regVal.exec(currencyTempString);
      if (resArr) {
        maxFractionDigits = resArr?.[1]?.length;
      }
方式二:

    第一个数字输入数字0, 转换后的'货币字符串'比如 '$0.00' 或者'0.00₫', 第二个数字输入0但同时指定保留小数0位, 然后将两个字符串长度相减后再减一, 限制这个数最小为0。

    伪代码: 默认精确位数 = Math.max( '$0.00'长度 - '$0'长度 - '.'长度 , 0)。

    这个方式有个大坑, 就是node 13版本之前执行可能会报错, 因为此时 maximumFractionDigits 传入0的时候可能会报错!

十、可选择的"四舍五入"、"向下取整"、"向上取整"

     Intl.NumberFormat 方法默认是 '四舍五入'的方式来计算金额, 但实际场景很可能需要用户来指定"向下取整"与"向上取整"这样的规则, 我这边定义为通过 calculationType: 'ceil' | 'floor' 来控制, 同时需要maxFractionDigits 这个参数来指定需要保留几位小数:

    插件内的使用方法:

  currencyFormat.addFormatType("人民币", {
    locale:'zh',
    currency: "CNY"
    calculationType: 'ceil'
  });

    插件的format代码:

  format(typeName, val) {
    const formatItem = this.formatObj.get(typeName);
    if (formatItem) {
      const { formatFn, calculationType, maxFractionDigits } = formatItem;
      const multiple = Math.pow(10, maxFractionDigits);
      if (calculationType === "ceil" || calculationType === "floor") {
        val = Math[calculationType](val * multiple) / multiple;
      }
      const currencyString = formatFn(val);
      return currencyString;
    }
    return "-";
  }

     这个代码里我们获取到 calculationType 变量指定的计算类型, 然后将用户传入的数据先乘上 10的maxFractionDigits 次方, 然后进行Math运算, 计算好后再除以 10的 maxFractionDigits 次方。

十一、对负数的兼容

     如果货币为负数, 比如'-12.34'则插件默认返回"-$12.34", 但是有些场景需要展示为"$-12.34", 如果地区指定为新加坡则展示"-SGD12.35", 明显负号在外层有点看不清了, 那么接下来就处理这个问题。

十二、返回详细信息

     插件默认返回的格式是"$12.34", 但是实际上可能我们要修改一下样式, 比如在货币符号与金额中增加空格 "$ 12.34", 或者需要隐藏符号只展示"12.34", 还有就是上述的'负数'问题, 所以有必要返回详情给用户:

// 比如格式化 12345.67 为人民币
{
      isFront: true, // 货币符号是否在金额前方
      currencySymbol: "¥" // 货币符号
      formatValue: "12,345.67", // 货币
      value: 12345.67, // 原本的值
      currencyString: "¥12,345.67", // 格式化后的货币
      negativeNumber: false, // 是否为负数
}

     新增formatDetail方法, 这里先将数字取绝对值后再进行货币格式化, 代码为:

  getFrontCurrencySymbol = (val) => /^[^\d一二三四五六七八九]+/g.exec(val)?.[0] ?? "";
  getAfterCurrencySymbol = (val) => /[^\d一二三四五六七八九]+$/g.exec(val)?.[0] ?? "";
  formatDetail(typeName, val) {
    const value = Math.abs(val);
    const currencyString = this.format(typeName, value);
    const frontCurrencySymbol = this.getFrontCurrencySymbol(currencyString);
    if (frontCurrencySymbol) {
      return {
        isFront: true,
        currencySymbol: frontCurrencySymbol,
        formatValue: currencyString.slice(frontCurrencySymbol.length) || "0",
        value,
        currencyString,
        negativeNumber: val < 0
      };
    }
    const afterCurrencySymbol = this.getAfterCurrencySymbol(currencyString);
    return {
      isFront: false,
      currencySymbol: afterCurrencySymbol,
      formatValue: currencyString.slice(0, -afterCurrencySymbol.length) || "0",
      value,
      currencyString,
      negativeNumber: val < 0
    };

     如下的使用方式与返回值:

      currencyFormat.addFormatType("中文汉字", {
        locale:'zh-Hans-CN-u-nu-hanidec',
        currency: "CNY",
      });
      
      console.log('中文汉字',currencyFormat.formatDetail('中文汉字', 12345.67));

image.png

十三、缩写

    所谓缩写就是图里这种模式:
image.png

    也就是如何展示长数字, 我这里增加了一个formatAbbreviation方法, 此方法可以返回数字被缩写处理后的字符串, 用法如下 :

const currencyFormat = new CurrencyFormat();
currencyFormat.addFormatType("en_gb", {
    locale: "en-GB",
    currency: "GBP",
});

currencyFormat.formatAbbreviation("en_gb", 123456.789)

// 打印结果是 £123K

    配置方案如下, 比如想让越南盾按中文进行缩略:

  const currencyFormat = new CurrencyFormat();
   currencyFormat.addFormatType("demo_越南_中文", {
        locale: 'vi-VN',
        currency: "VND",
        validAbbreviations: {
            "3": '千',
            "4": '万',
            "8": "亿",
            "13": "兆"
        },
    });

    validAbbreviations 属性指定了当数字超过多少位时进行缩略, 并且指明缩略后的符号, 比如上图就是 '1000' 转换为 '1千₫'。

    当然我们默认内置了一套基础的转换规则:

    validAbbreviationsTypeEN = {
        3: "K",
        6: "M",
        9: "B",
        12: "T",
    };

    那么它的原理我简单阐述下, 其实我是利用了已有的api "formatDetail"来获取到货币详情对象, 此时拿到 真实的数值, 对这个数值与 validAbbreviationsTypeEN进行循环比较 , 看他的位数应该加什么缩略符号。

    但是也要注意一点, 就是缩略符号的位置, 因为货币符号有前有后, 所以当货币符号在前则没有特殊处理, 但是货币符号在后, 就需要先展示缩略符再展示货币符, 例如下方展示的:

   123456.789   -->  Rp12万
   123456.789   -->  12万₫

十四、可指定符号

    真实业务给我上了一课, 不是所有场景都按一些国际化标准进行的, 比如针对新加坡联盟这边需要展示 "S$", 但是其国际上应该展示"$"即可, 此时不想按标准展示符号就需要我们可以让开发者自由指定货币符号, 所以我新增了 targetCurrency 属性:

const currencyFormat = new CurrencyFormat();
currencyFormat.addFormatType("en_gb_targetCurrency1", {
    locale: "en-GB",
    currency: "GBP",
    targetCurrency: "xxxx"
});

currencyFormat.format("en_gb_targetCurrency1", 123456.789)

// 最后输出的结果是"xxxx123,456.79

     原理也比较直接, 在format方法的最终, 判断是否有targetCurrency属性, 然后将其与当前的货币符号进行替换即可。

end

     这次就是这样, 希望与你一起进步。


lulu_up
5.7k 声望6.9k 粉丝

自信自律, 终身学习, 创业者