Chuyển đến nội dung
MINH VO Ghi chép từ công việc
của một kỹ sư tại Việt Nam
Cơ bản12 phút đọc

Định dạng số quốc tế mà không sửa chuỗi thủ công

Giữ đúng đơn vị tiền và số nguyên lớn, bảo toàn các phần định dạng theo locale, phân biệt hiển thị với tính toán và phân tích đầu vào.

Bố cục trình duyệt bằng giấy màu ô liu và be, thể hiện trang máy tính và trang di động hẹp hơn

API trả về 12345. Trước khi chọn ký hiệu tiền tệ, hãy xác định số nguyên đó đang đếm đơn vị gì. Theo giao ước cent, nó có nghĩa là 123,45 đô la; theo giao ước đồng nguyên, nó có nghĩa là 12345 đồng. Locale không giải quyết được sự mơ hồ này. Số chữ số thập phân thường dùng khi hiển thị một đồng tiền cũng không giải quyết được.

Bài này dành cho người đã quen với hàm JavaScript và biểu mẫu HTML, đang cần hiển thị số tiền cho độc giả quốc tế. Ví dụ chấp nhận hai giao ước lưu trữ giả định và ba locale. Nó giữ nguyên số nguyên đã lưu, đưa ra một đối chứng cố ý làm mất độ chính xác, đồng thời chỉ rõ những ký tự mà bộ định dạng thực sự trả về. Ví dụ không tính thuế, quy đổi ngoại tệ hay phân tích giá nhập theo từng locale.

Hiểu đơn vị trước khi chọn cách trình bày

Ứng dụng chấp nhận số nguyên USD đếm cent, với số mũ hai, và số nguyên VND đếm đồng nguyên, với số mũ không. Cả hai đều là quyết định trong schema API của ví dụ. Dịch vụ khác có thể lưu theo thang đo khác; sao chép bảng này mà không kiểm tra giao ước của dịch vụ sẽ dẫn tới diễn giải sai.

Các tùy chọn của hàm dựng NumberFormat mô tả cách hiển thị tiền tệ và số chữ số thập phân mặc định. Mặc định đó thuộc về trình bày, không mô tả dữ liệu trên máy chủ của bạn. Đọc resolvedOptions().maximumFractionDigits rồi dùng nó để giải mã một số nguyên chưa có tài liệu là trộn hai trách nhiệm. Ở đây, tiền tệ và số mũ được kiểm tra cùng nhau, sau đó cả hai giới hạn chữ số hiển thị đều được đặt rõ ràng.

Hai giao ước diễn giải 12345 thành USD 123,45 hoặc VND 12345, kèm ba kết quả locale đã quan sát cho mỗi giao ướcMở ảnh kích thước đầy đủ ↗

Cùng các ký tự lưu trữ tạo ra giá trị theo đơn vị chính khác nhau dưới hai giao ước. Trong từng cột, đổi locale giữ nguyên giá trị đã diễn giải. Chọn tiền tệ khác trong biểu mẫu là cố ý chọn giao ước khác; không có tỷ giá nào tham gia.

Giữ nguyên số nguyên khi chuyển sang phần hiển thị

Lưu đoạn sau vào money.js. Số tiền hợp lệ là chuỗi số nguyên ASCII ở dạng chuẩn, có tối đa mười tám chữ số và có thể có dấu trừ phía trước. Khoảng trắng, số không thừa ở đầu, âm không, dấu thập phân và cách viết số mũ đều bị từ chối. Giới hạn này xác định miền đầu vào của ví dụ; có cho phép hoàn tiền hay số dư âm hay không vẫn là quy tắc sản phẩm riêng.

// money.js: this application's accepted storage contracts.
const EXPONENTS = Object.freeze({ USD: 2, VND: 0 });
const LOCALES = ["en-US", "vi-VN", "fr-FR"];

function majorDecimal({ amountMinor, currency, exponent }) {
  if (typeof currency !== "string" || !Object.hasOwn(EXPONENTS, currency)
      || exponent !== EXPONENTS[currency]) {
    throw new RangeError("contract");
  }
  if (typeof amountMinor !== "string"
      || !/^-?(?:0|[1-9][0-9]{0,17})$/.test(amountMinor)
      || amountMinor === "-0") {
    throw new RangeError("integer");
  }
  const integer = BigInt(amountMinor);
  const magnitude = integer < 0n ? -integer : integer;
  const digits = magnitude.toString().padStart(exponent + 1, "0");
  const decimal = exponent === 0 ? digits
    : `${digits.slice(0, -exponent)}.${digits.slice(-exponent)}`;
  return (integer < 0n ? "-" : "") + decimal;
}

function supportsExactDecimalInput() {
  const probe = new Intl.NumberFormat("en-US", {
    useGrouping: false, maximumFractionDigits: 2
  });
  return ["9007199254740993", "90071992547409.93"].every(value =>
    probe.format(value) === value
    && probe.formatToParts(value).map(part => part.value).join("") === value);
}

function formatMoney(record, locale) {
  const major = majorDecimal(record);
  if (!LOCALES.includes(locale)) throw new RangeError("locale");
  if (!supportsExactDecimalInput()) throw new Error("runtime");
  const formatter = new Intl.NumberFormat(locale, {
    style: "currency", currency: record.currency,
    minimumFractionDigits: record.exponent,
    maximumFractionDigits: record.exponent
  });
  return {
    major,
    text: formatter.format(major),
    parts: formatter.formatToParts(major),
    // Deliberately lossy control, never the authoritative display.
    numberControl: formatter.format(Number(record.amountMinor) / 10 ** record.exponent)
  };
}

BigInt giữ chính xác số nguyên trong lúc mã tách dấu và độ lớn. Thêm số không trước khi chèn dấu thập phân giúp 5 cent trở thành 0.05, còn -5 trở thành -0.05. Luồng tạo kết quả chính không chia qua một số dấu phẩy động nhị phân. Tài liệu BigInt giải thích độ chính xác của số nguyên và nguy cơ khi chuyển giá trị lớn trở lại Number.

Bộ định dạng nhận chuỗi thập phân vừa tạo. Các triển khai hiện đại có thể giữ chính xác đầu vào thập phân đó, như tài liệu format và thao tác ToIntlMathematicalValue trong đặc tả mô tả. Triển khai cũ chuyển chuỗi qua Number. Phép kiểm tra khả năng nhỏ này thử hai đầu vào lớn liên quan với cả hai phương thức định dạng, rồi từ chối trường hợp mất độ chính xác đã được bộc lộ. Nó không phải polyfill hay bộ kiểm tra tuân thủ đầy đủ; ví dụ vốn đã yêu cầu BigInt và các API JavaScript khác mà nó sử dụng.

Danh sách locale cho phép xác định phạm vi giao diện, không cài thêm dữ liệu locale còn thiếu. Nếu bản triển khai cần đúng những locale ấy, bạn còn phải kiểm tra chúng có sẵn trong runtime đích; Intl có thể dùng locale thay thế. Các quan sát ở đây đến từ Chrome 153, nơi cả ba locale được yêu cầu đều có sẵn.

Giữ nguyên thứ tự các phần mà bộ định dạng trả về

Lưu đoạn sau vào ui.js. Mã tạo nội dung văn bản bằng textContent, dùng phần tử strong bao quanh phần tiền tệ. Nó giữ mọi phần theo đúng thứ tự trả về, kể cả literal và dấu phân cách. Tài liệu formatToParts định nghĩa các phần có kiểu này; nối các giá trị của chúng tạo lại kết quả định dạng tương ứng.

// ui.js: loaded after money.js.
function mountMoney(root) {
  const form = root.querySelector("form");
  const fields = root.querySelector("fieldset");
  const amount = root.querySelector("#amount-minor");
  const currency = root.querySelector("#currency");
  const locale = root.querySelector("#locale");
  const display = root.querySelector("#formatted");
  const exact = root.querySelector("#major-decimal");
  const comparison = root.querySelector("#number-control");
  const error = root.querySelector("#money-error");
  function render(event) {
    event?.preventDefault();
    try {
      const result = formatMoney({
        amountMinor: amount.value, currency: currency.value,
        exponent: EXPONENTS[currency.value]
      }, locale.value);
      const nodes = result.parts.map(({ type, value }) => {
        const node = document.createElement(type === "currency" ? "strong" : "span");
        node.textContent = value;
        return node;
      });
      display.replaceChildren(...nodes);
      exact.textContent = result.major;
      comparison.textContent = result.numberControl;
      error.textContent = "";
      amount.removeAttribute("aria-invalid");
    } catch (cause) {
      display.textContent = exact.textContent = comparison.textContent = root.dataset.empty;
      error.textContent = cause.message === "integer" ? root.dataset.integerError
        : cause.message === "runtime" ? root.dataset.runtimeError : root.dataset.contractError;
      if (cause.message === "integer") {
        amount.setAttribute("aria-invalid", "true");
        amount.focus();
      } else {
        amount.removeAttribute("aria-invalid");
      }
    }
  }
  form.addEventListener("submit", render);
  fields.disabled = false;
  render();
  return () => {
    form.removeEventListener("submit", render);
    fields.disabled = true;
  };
}
const stopMoney = mountMoney(document.querySelector("#money-demo"));

Các phần tử được tạo vẫn nằm trong dòng văn bản và đóng góp nội dung thực của chúng. Dời mọi ký hiệu tiền tệ lên đầu sẽ xóa bỏ cách sắp xếp của locale. Xóa mọi khoảng trắng cũng làm thay đổi kết quả: trong chuỗi VND tiếng Pháp đã quan sát, dấu nhóm là khoảng trắng hẹp không ngắt U+202F, còn literal trước tiền tệ là khoảng trắng không ngắt U+00A0. Chúng là hai ký tự khác nhau dù trên ảnh có thể trông gần giống nhau.

Các phần USD và VND tiếng Pháp giữ thứ tự tiền tệ và các khoảng trắng U+00A0, U+202FMở ảnh kích thước đầy đủ ↗

Bảng này chỉ rõ các ký tự đã ghi nhận; tên khoảng trắng là chú thích, không phải văn bản thay thế được giao diện chèn vào. Ký hiệu và khoảng cách cụ thể có thể khác theo triển khai và phiên bản dữ liệu locale. Kiểm thử ứng dụng nên xác minh giá trị dự định cùng cấu trúc liên quan, thay vì coi dấu câu trên một ảnh là hằng số đúng ở mọi nơi.

Chạy giao diện với điều khiển HTML gốc

Đặt các tệp này cùng demo.css, rồi phục vụ thư mục qua HTTP cục bộ. Hai script kiểu classic có defer thực thi theo thứ tự trong tài liệu. Nếu ui.js không tải được, fieldset vẫn bị vô hiệu hóa. Nếu thiếu money.js nhưng ui.js chạy, các điều khiển được bật, kết quả bị xóa và giao diện báo lỗi giao ước. Cách xử lý lỗi tối giản này chưa phân biệt thiếu mã định dạng với giao ước không được hỗ trợ. Ví dụ không có phương án dự phòng do máy chủ kết xuất.

<!doctype html>
<html lang="vi">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Giao ước số tiền, rồi tới locale</title>
  <link rel="stylesheet" href="demo.css">
  <script src="money.js" defer></script>
  <script src="ui.js" defer></script>
</head>
<body>
  <section id="money-demo" aria-labelledby="demo-title"
    data-empty="Chưa có số tiền được định dạng."
    data-integer-error="Dùng tối đa 18 chữ số ASCII, có thể thêm dấu trừ. Không có dấu phân cách, số 0 thừa ở đầu hoặc âm 0."
    data-runtime-error="Runtime này không giữ nguyên đầu vào thập phân chính xác mà ví dụ cần."
    data-contract-error="Giao ước số tiền hoặc locale không được hỗ trợ.">
    <h1 id="demo-title">Giao ước số tiền, rồi tới locale</h1>
    <p>Số tiền giả định. Đổi tiền tệ là chọn giao ước đơn vị khác, không phải quy đổi ngoại tệ.</p>
    <form novalidate>
      <fieldset disabled>
        <legend>Chọn số tiền lưu trữ và cách hiển thị</legend>
        <label for="amount-minor">Số nguyên theo đơn vị lưu trữ</label>
        <input id="amount-minor" name="amountMinor" type="text" inputmode="text"
          value="12345" maxlength="19" aria-describedby="amount-help money-error">
        <p id="amount-help">Chữ số ASCII, có thể thêm dấu trừ, không có dấu nhóm hay dấu thập phân.</p>
        <label for="currency">Giao ước số tiền</label>
        <select id="currency" name="currency">
          <option value="USD">USD: số nguyên đếm cent (số mũ 2)</option>
          <option value="VND">VND: số nguyên đếm đồng (số mũ 0)</option>
        </select>
        <label for="locale">Locale hiển thị</label>
        <select id="locale" name="locale">
          <option value="en-US">English (US)</option>
          <option value="vi-VN">Tiếng Việt</option>
          <option value="fr-FR">Français</option>
        </select>
        <button type="submit">Định dạng số tiền</button>
      </fieldset>
    </form>
    <p id="money-error" role="status"></p>
    <p>Thập phân chính xác theo đơn vị chính: <span id="major-decimal">—</span></p>
    <p>Số tiền đã định dạng: <output id="formatted">—</output></p>
    <p>Đối chứng chuyển qua Number: <span id="number-control">—</span></p>
    <p>Đối chứng có thể mất chữ số. Nó chỉ được hiển thị để làm rõ lỗi đó.</p>
  </section>
</body>
</html>
* { box-sizing: border-box; }
body { margin: 1rem; font: 1rem/1.6 system-ui, sans-serif; }
#money-demo { max-inline-size: 42rem; overflow-wrap: anywhere; }
fieldset { min-inline-size: 0; padding: 1rem; border: 1px solid #7b8367; }
label { display: block; margin-block-start: 1rem; }
input, select, button { font: inherit; max-inline-size: 100%; }
input, select { inline-size: 100%; }
button { margin-block-start: 1rem; white-space: normal; }
output, #major-decimal { font-weight: 700; }
#money-error { color: #8a251d; }
:focus-visible { outline: 3px solid #315b32; outline-offset: 3px; }

Tab đi qua số tiền, giao ước, locale rồi nút gửi. Khi gửi thành công từ nút, focus vẫn ở nút; khi cú pháp số nguyên không hợp lệ, focus chuyển tới ô nhập có mô tả lỗi và kết quả cũ bị xóa để không bị hiểu nhầm là số tiền mới. Lỗi khả năng của runtime có thông báo riêng, không gán nhầm trạng thái không hợp lệ cho số nguyên. Hàm dọn dẹp được trả về gỡ listener submit và vô hiệu hóa fieldset.

Biểu mẫu tiếng Việt hiển thị 12345 cent thành 123.45 đơn vị chính theo định dạng tiền tệ tiếng PhápMở ảnh kích thước đầy đủ ↗

Ảnh chụp thật này được tạo sau khi chọn fr-FR bằng bàn phím. Ngôn ngữ tiếng Anh hay tiếng Việt của giao diện không giới hạn locale hiển thị. Văn bản lưu trữ vẫn là 12345, và chuỗi thập phân theo đơn vị chính vẫn là 123.45 với giao ước USD.

Quan sát lỗi độ chính xác mà không dùng nó làm kết quả

Chạy đoạn chẩn đoán này trong console của trang. Các chú thích ghi nhận lần chạy Chrome cụ thể này, không cam kết mọi runtime đều viết ký hiệu tiền tệ giống nhau.

const record = { amountMinor: "9007199254740993", currency: "USD", exponent: 2 };
const shown = formatMoney(record, "en-US");
console.log(shown.major); // 90071992547409.93
console.log(shown.text); // $90,071,992,547,409.93 in this runtime
console.log(shown.numberControl); // $90,071,992,547,409.92 in this runtime
console.log(shown.parts.map(part => part.value).join("") === shown.text); // true
console.log(record.amountMinor); // unchanged: 9007199254740993

Biểu mẫu tiếng Việt hiển thị số tiền USD chính xác kết thúc bằng .93 cạnh đối chứng Number kết thúc bằng .92Mở ảnh kích thước đầy đủ ↗

Chuỗi số nguyên kết thúc bằng 993. Chuyển nó sang Number làm mất thông tin trước phép chia cho một trăm; đối chứng trong runtime này vì vậy kết thúc bằng .92 thay vì .93. Định dạng không thể lấy lại chữ số đã mất. Giao diện gắn nhãn luồng đó là đối chứng lỗi; chỉ luồng giữ chuỗi chính xác cung cấp số tiền hiển thị.

Ngay cả định dạng thành công cũng không chứng minh phép tính tài chính là đúng. Phần dư khi phân bổ, làm tròn thuế và bước làm tròn tiền mặt cần quy tắc nghiệp vụ rõ ràng. Phép chia BigInt cắt bỏ phần dư, còn thư viện thập phân vẫn cần chính sách làm tròn. Các giá trị được chấp nhận ở đây đã khớp với thang hiển thị đã chọn, nên ví dụ không kiểm tra thuật toán làm tròn nghiệp vụ.

Chuỗi theo locale không phải giao ước đầu vào

Nhập 123,45 vào ô số tiền. Nó bị từ chối ngay cả khi locale hiển thị là tiếng Pháp hoặc tiếng Việt. Ô đó yêu cầu số nguyên theo đơn vị lưu trữ, không phải giá theo đơn vị chính đã định dạng cho người đọc; 12345 mới là đầu vào cent tương ứng được chấp nhận. Thay dấu phẩy bằng dấu chấm sẽ âm thầm đổi giao ước của ô, mà vẫn không xử lý được các ký tự nhóm hay hệ chữ số khác.

Biểu mẫu tiếng Việt từ chối đầu vào có dấu phẩy, xóa kết quả và đưa focus về ô số tiền có mô tả lỗiMở ảnh kích thước đầy đủ ↗

Với trình sửa giá dành cho khách hàng, hãy chọn và giải thích cú pháp nhập phù hợp hoặc dùng bộ phân tích theo locale chuyên dụng. Giao diện chẩn đoán này dùng giá trị giao thức chuẩn hóa để làm rõ ranh giới lưu trữ. Máy chủ vẫn cần kiểm tra dữ liệu; không nên gửi lại chuỗi đã định dạng để máy chủ coi đó là số tiền gốc.

Để tự kiểm tra, nhập 0, -5 và 999999999999999999 theo từng giao ước và locale. Hãy dự đoán chuỗi thập phân theo đơn vị chính trước khi gửi, rồi xem các phần mà không xóa literal. Kiểm tra của tác giả so sánh 3.516 bản ghi số tiền/giao ước với phép tính Decimal độc lập, tạo ra 10.548 quan sát locale cho mỗi bản ngôn ngữ. Các đối chứng bàn phím thật, đầu vào sai, lỗi khả năng runtime và dọn dẹp cũng đạt. Bố cục rộng 320px với chữ phóng lớn vẫn giữ toàn bộ kết quả, dù nhãn dài trong select gốc đang đóng có thể chỉ hiện một phần. Đây là kiểm tra trình duyệt trong phạm vi đã nêu, không phải chứng nhận đa trình duyệt hay lời đọc của trình đọc màn hình.

Nguồn tham khảo & đọc thêm

  1. MDN: Intl.NumberFormat
  2. MDN: NumberFormat constructor options
  3. MDN: NumberFormat format
  4. MDN: NumberFormat formatToParts
  5. MDN: BigInt
  6. ECMA-402: ToIntlMathematicalValue
← Về trang bài viết

Hình minh họa

100%