熱乎乎的前端"數字" 轉換 "貨幣字串", 如此有趣的知識來啦 (Intl.NumberFormat)

lulu_up 發表於 2022-06-27
前端

一、背景

    貨幣轉換外掛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

     這次就是這樣, 希望與你一起進步。