為什麼日期與數字需要本地化

OpenL Team 10/2/2025

TABLE OF CONTENTS

如果你面向全球發貨,同一組字串對不同用戶可能有完全不同的含義:12/01/20251.2341,23412:0000:00。這些細微差異絕非表面功夫——它們會影響信任、理解,甚至法律合規。本指南將說明為什麼日期和數字必須在地化、不同地區實際會有哪些變化,以及如何在你的產品中正確實現這些本地化需求。

各地區會有什麼不同

  • 日期:順序(MDY、DMY、YMD)、分隔符、月份/日期名稱;部分市場使用非格里曆。
  • 時間:12小時制與24小時制、AM/PM標記、時區、夏令時間規則、一週起始日(星期日或星期一)。
  • 數字:小數點分隔符(.,)、千位分隔方式(1,2341.2341 234)、印度分組(1,23,456)、不換行空格、正負號樣式(連字號與真正的減號 )。
  • 貨幣:符號與ISO代碼、符號位置(前置/後置)、窄空格與寬空格、小數精度(0、2或3位以上)、會計負數格式(如 (1 234,56 €))。
  • 百分比與單位:空格與符號位置(50%50 %)、單位名稱本地化、米制與英制。

對業務的影響

  • 信任與轉換率:價格、費用和日期「看起來不對」會降低購買率並增加流失。
  • 營運風險:誤讀日期可能導致預訂或截止日錯誤;解析錯誤會影響發票、匯出和分析。
  • 合規風險:財務文件格式錯誤可能違反發票、稅務或報告規定。
  • 客服負擔:用戶會因時間、貨幣和數字格式與預期不符而提交工單詢問。

真實案例

某歐洲旅遊預訂平台將出發日期顯示為 01/03/2024,但未標明地區格式。美國用戶將其解讀為「1月3日」,而英國及歐洲用戶則認為是「3月1日」。

影響

  • 12% 的國際訂單訂錯日期
  • 客服在一週內收到超過 3,400 張釐清工單
  • 退款與重新訂票費用高達 230 萬美元
  • 8% 受影響的顧客轉向競爭對手

根本原因:電子郵件確認信中硬編碼為 DD/MM/YYYY 格式,而網站對美國用戶則使用 MM/DD/YYYY。修正只需 3 小時工程時間——這種模糊帶來的損失是預防成本的 800 倍。

教訓:沒有上下文的日期,對任何全球產品來說都是一顆定時炸彈。

常見陷阱

  • 硬編碼格式:將 MM/DD/YYYY1,234.56 直接寫入 UI、電子郵件、PDF 或 CSV 匯出。
  • 儲存在地化字串:存下 “Dec 1, 2025” 而非 ISO 時間,導致解析錯誤與時區偏移。
  • 天真解析:使用伺服器預設語系;假設分隔符或 12/24 小時制。
  • 字串拼接:用 "$" + 1234.56 這種方式組合「金額 + 貨幣」,而非依據語系格式化。
  • 時區模糊:顯示本地時間但未明確標示時區,對時間敏感操作造成風險。
  • 瀏覽器不一致:Safari、Firefox、Chrome 格式化日期與數字方式不同;務必在所有目標瀏覽器測試。
  • 伺服器端渲染缺口:Node.js 若未完整安裝 ICU 資料(node --with-intl=full-icu),會產生不完整或錯誤的格式。
  • 效能陷阱:在迴圈或渲染週期中重複建立新的 Intl.* 實例,而非快取格式器。

最佳實踐

  • 儲存標準值:日期/時間以 ISO‑8601 格式並採用 UTC;金額以最小單位(如分)或高精度小數儲存。
  • 根據使用者語系呈現:顯示時使用語系感知的 API(如 Intl.DateTimeFormatIntl.NumberFormat)。
  • 根據語系驗證與解析:適當時接受語系輸入;顯示佔位符或範例以引導使用者。
  • 時間資訊要明確:顯示時區縮寫或偏移量;若結果依賴時間,允許使用者選擇時區。
  • 貨幣清晰:有歧義時優先使用 ISO 貨幣代碼;根據語系尊重貨幣符號的位置與間距。
  • 格式統一:所有介面(UI、電子郵件、PDF、匯出檔)統一使用一層格式化工具,確保一致性。
  • 格式器快取:Intl.* 建構函式呼叫成本高;每個語系只建立一次並重複使用。

什麼情況下不需在地化

並非所有內容都需要在地化。以下情境建議使用一致、機器可讀的格式:

  • 機器可讀的匯出檔:供資料管線或分析工具使用的 CSV 檔,應採用固定格式(如 ISO-8601 日期、以句點作為小數分隔符)。在匯出標頭或 README 文件中清楚說明格式。
  • API 回應:REST/GraphQL API 應回傳 ISO-8601 字串格式的日期,以及標準 JSON 格式的數字。讓客戶端根據使用者偏好自行處理在地化。
  • 內部日誌與指標:日誌、監控儀表板、資料庫查詢等,使用一致格式有助於解析、彙整與警示。請採用 ISO-8601 與標準數字格式。
  • 標準識別碼:交易 ID、訂單編號或跨語系需溝通的參考資訊,應避免語系特定格式。

經驗法則:內容給人閱讀時要在地化;資料給機器處理時要用標準格式。

實作計畫

  1. 稽核所有顯示面:盤點 UI、電子郵件、PDF、CSV/Excel 匯出、分析報表、日誌中的日期、時間、數字、貨幣。
  2. 定義地區設定:列出支援的地區語系及預設備援方案;針對每個地區語系明確規範 12/24 小時制。
  3. 建立工具函式:以共用輔助函式和測試包裝 Intl.* API(或相關函式庫);實作格式化器快取機制。
  4. 輸入策略:更新表單以接受並驗證地區格式;同時儲存標準值與原始輸入。
  5. 內容規範:文件化短/長日期、相對日期、貨幣顯示、百分比格式的風格指引。
  6. 推出計畫:優先轉換流量最高及風險最高的顯示面;如有需要,可加上功能開關逐步釋出。

測試策略

除了手動 QA 外,還需實作自動化測試以防止回歸錯誤:

  • 快照測試:針對每個支援語系凍結格式化結果。若有變動,需經審查以確保為預期調整。
  • 往返測試:驗證 format → parse → format 能產生穩定結果。可捕捉精度損失或格式歧義問題。
  • 邊界案例:測試負數、零、極大數值(數十億/兆)、夏令時間切換、閏年、邊界日期(1 月 1 日、12 月 31 日)。
  • 跨瀏覽器驗證:於 Chrome、Firefox、Safari、Edge 執行自動化測試,捕捉渲染差異。
  • 地區備援:驗證請求不支援語系時能優雅降級。
  • 效能基準:量測格式化器建立成本;確保生產環境快取機制正常運作。

效能建議

Intl.* API 功能強大,但若使用不當可能影響效能。請遵循以下原則:

// ❌ 不建議:每次呼叫都建立新格式化器
function formatPrice(amount, locale) {
  return new Intl.NumberFormat(locale, { 
    style: 'currency', 
    currency: 'USD' 
  }).format(amount);
}

// ✅ 良好做法:根據語言環境快取格式化器 const formatters = new Map(); function getFormatter(locale) { if (!formatters.has(locale)) { formatters.set(locale, new Intl.NumberFormat(locale, { style: ‘currency’, currency: ‘USD’ })); } return formatters.get(locale); }

function formatPrice(amount, locale) { return getFormatter(locale).format(amount); }


- **快取格式化器實例**:`Intl.*` 的建構成本很高(約 1–5 毫秒);使用現有實例格式化則非常快(約 0.01 毫秒)。
- **避免在迴圈中建立格式化器**:切勿在 `map()`、`forEach()` 或渲染迴圈內建立格式化器。
- **延遲載入語言環境資料**:對於網頁應用,考慮使用程式碼分割,只載入所需語言環境,減少 bundle 大小。
- **伺服器端**:每個請求/回應週期只使用一個格式化器;避免在大量資料集內為每個項目建立格式化器。

## QA 檢查清單

- 日期在 `en-US`、`en-GB`、`de-DE`、`fr-FR`、`hi-IN`、`ja-JP`、`ar-EG` 中皆無歧義。
- 時間使用正確的 12/24 小時制;AM/PM 標記出現在預期位置;時區在時間敏感流程中顯示。
- 數字使用正確的小數點與分組符號;標準語言環境(如 `fr-FR`)使用不換行空格。
- 貨幣顯示正確的符號/代碼、位置及小數精度;負數顯示方式正確。
- 輸入欄位能接受並解析使用者語言環境資料(或明確強制支援格式);驗證訊息具語言環境意識。
- 匯出(CSV/PDF)符合語言環境預期,或明確說明為機器消費的固定格式。
- 跨瀏覽器:日期、時間與數字在 Chrome、Firefox、Safari、Edge 上渲染一致。
- 效能:不在迴圈中建立格式化器;快取實例於多次渲染間重複使用。

## 程式碼範例

### JavaScript

```js
// 日期
const date = new Date('2025-01-12T00:00:00Z');

console.log(new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeZone: 'UTC' }).format(date));
// → 1/12/25

console.log(new Intl.DateTimeFormat(‘en-GB’, { dateStyle: ‘short’, timeZone: ‘UTC’ }).format(date)); // → 12/01/2025(日/月/年)

console.log(new Intl.DateTimeFormat(‘ja-JP’, { dateStyle: ‘medium’, timeZone: ‘UTC’ }).format(date)); // → 2025/01/12

// 顯示時區的時間 const time = new Date(‘2025-01-12T18:05:00Z’);

console.log(new Intl.DateTimeFormat(‘en-US’, { dateStyle: ‘short’, timeStyle: ‘short’, timeZone: ‘America/New_York’, timeZoneName: ‘short’ }).format(time)); // → 1/12/25, 1:05 PM EST

console.log(new Intl.DateTimeFormat(‘de-DE’, { timeStyle: ‘short’, timeZone: ‘Europe/Berlin’, hour12: false }).format(time)); // → 19:05

// 數字 const n = 1234.56; console.log(new Intl.NumberFormat(‘en-US’).format(n)); // → 1,234.56 console.log(new Intl.NumberFormat(‘de-DE’).format(n)); // → 1.234,56 console.log(new Intl.NumberFormat(‘fr-FR’).format(n)); // → 1 234,56(NBSP 作為群組分隔符)

// 印度數字格式 console.log(new Intl.NumberFormat(‘hi-IN’).format(1234567.89)); // → 12,34,567.89

// 貨幣 console.log(new Intl.NumberFormat(‘en-US’, { style: ‘currency’, currency: ‘USD’ }).format(n)); // → $1,234.56

console.log(new Intl.NumberFormat(‘de-DE’, { style: ‘currency’, currency: ‘EUR’ }).format(n)); // → 1.234,56 €

console.log(new Intl.NumberFormat(‘ja-JP’, { style: ‘currency’, currency: ‘JPY’ }).format(1234)); // → ¥1,234(無小數位)

// 百分比與單位 console.log(new Intl.NumberFormat(‘en-US’, { style: ‘percent’, maximumFractionDigits: 0 }).format(0.5)); // → 50% console.log(new Intl.NumberFormat(‘fr-FR’, { style: ‘percent’, maximumFractionDigits: 0 }).format(0.5)); // → 50 %

/* 格式化器快取範例 */ class LocaleFormatter { constructor() { this.cache = new Map(); }

getDateFormatter(locale, options) { const key = date:${locale}:${JSON.stringify(options)}; if (!this.cache.has(key)) { this.cache.set(key, new Intl.DateTimeFormat(locale, options)); } return this.cache.get(key); }

formatDate(date, locale, options = { dateStyle: ‘medium’ }) { return this.getDateFormatter(locale, options).format(date); } }

const formatter = new LocaleFormatter(); console.log(formatter.formatDate(new Date(), ‘en-US’)); // → Jan 12, 2025 console.log(formatter.formatDate(new Date(), ‘fr-FR’)); // → 12 janv. 2025


### Python

```python
from babel.dates import format_date, format_time, format_datetime
from babel.numbers import format_number, format_currency, format_percent
from datetime import datetime
import pytz

# 日期
date = datetime(2025, 1, 12)
print(format_date(date, format='short', locale='en_US'))  # → 1/12/25
print(format_date(date, format='short', locale='en_GB'))  # → 12/01/2025
print(format_date(date, format='medium', locale='ja_JP')) # → 2025/01/12

# 含時區的時間
tz_ny = pytz.timezone('America/New_York')
tz_berlin = pytz.timezone('Europe/Berlin')
time = datetime(2025, 1, 12, 18, 5, tzinfo=pytz.UTC)

print(format_datetime(time.astimezone(tz_ny), 'short', tzinfo=tz_ny, locale='en_US'))
# → 1/12/25, 1:05 PM

print(format_time(time.astimezone(tz_berlin), format='short', tzinfo=tz_berlin, locale='de_DE'))
# → 19:05

# 數字
n = 1234.56
print(format_number(n, locale='en_US'))    # → 1,234.56
print(format_number(n, locale='de_DE'))    # → 1.234,56
print(format_number(n, locale='fr_FR'))    # → 1 234,56

# 印度數字格式
print(format_number(1234567.89, locale='hi_IN'))  # → 12,34,567.89

# 貨幣
print(format_currency(n, 'USD', locale='en_US'))  # → $1,234.56
print(format_currency(n, 'EUR', locale='de_DE'))  # → 1.234,56 €
print(format_currency(1234, 'JPY', locale='ja_JP')) # → ¥1,234

# 百分比
print(format_percent(0.5, locale='en_US'))  # → 50%
print(format_percent(0.5, locale='fr_FR'))  # → 50 %

Java

import java.text.NumberFormat;
import java.time.ZonedDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
import java.util.Currency;

// 日期
ZonedDateTime date = ZonedDateTime.of(2025, 1, 12, 0, 0, 0, 0, ZoneId.of("UTC"));

DateTimeFormatter usFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
    .withLocale(Locale.US);
System.out.println(usFormatter.format(date)); // → 1/12/25

DateTimeFormatter gbFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
    .withLocale(Locale.UK);
System.out.println(gbFormatter.format(date)); // → 12/01/2025

DateTimeFormatter jpFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
    .withLocale(Locale.JAPAN);
System.out.println(jpFormatter.format(date)); // → 2025/01/12

// 時間
ZonedDateTime time = ZonedDateTime.of(2025, 1, 12, 18, 5, 0, 0, ZoneId.of("UTC"));
ZonedDateTime timeNY = time.withZoneSameInstant(ZoneId.of("America/New_York"));

DateTimeFormatter usTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
    .withLocale(Locale.US);
System.out.println(usTimeFormatter.format(timeNY)); // → 1:05 PM

// 數字
double n = 1234.56;
NumberFormat usFormat = NumberFormat.getInstance(Locale.US);
System.out.println(usFormat.format(n)); // → 1,234.56

NumberFormat deFormat = NumberFormat.getInstance(Locale.GERMANY);
System.out.println(deFormat.format(n)); // → 1.234,56

NumberFormat frFormat = NumberFormat.getInstance(Locale.FRANCE);
System.out.println(frFormat.format(n)); // → 1 234,56

// 貨幣
NumberFormat usCurrency = NumberFormat.getCurrencyInstance(Locale.US);
usCurrency.setCurrency(Currency.getInstance("USD"));
System.out.println(usCurrency.format(n)); // → $1,234.56

NumberFormat deCurrency = NumberFormat.getCurrencyInstance(Locale.GERMANY);
deCurrency.setCurrency(Currency.getInstance("EUR"));
System.out.println(deCurrency.format(n)); // → 1.234,56 €

NumberFormat jpCurrency = NumberFormat.getCurrencyInstance(Locale.JAPAN); jpCurrency.setCurrency(Currency.getInstance(“JPY”)); System.out.println(jpCurrency.format(1234)); // → ¥1,234


### Go

```go
package main

import (
    "fmt"
    "time"
    "golang.org/x/text/language"
    "golang.org/x/text/message"
    "golang.org/x/text/number"
)

func main() {
    // 數字
    n := 1234.56

    pUS := message.NewPrinter(language.AmericanEnglish)
    fmt.Println(pUS.Sprintf("%.2f", n)) // → 1,234.56

    pDE := message.NewPrinter(language.German)
    fmt.Println(pDE.Sprintf("%.2f", n)) // → 1.234,56

    pFR := message.NewPrinter(language.French)
    fmt.Println(pFR.Sprintf("%.2f", n)) // → 1 234,56

    // 貨幣(使用 number 套件)
    fmt.Println(pUS.Sprint(number.Decimal(n, number.Scale(2))))  // → 1,234.56

    // 日期 - Go 的 time 套件使用 layout 而非地區格式化
    // 若需完整的國際化日期格式,建議使用 github.com/goodsign/monday 或類似套件
    date := time.Date(2025, 1, 12, 0, 0, 0, 0, time.UTC)
    fmt.Println(date.Format("01/02/2006")) // 美國格式: 01/12/2025
    fmt.Println(date.Format("02/01/2006")) // 歐洲格式: 12/01/2025
    fmt.Println(date.Format("2006/01/02")) // ISO 格式: 2025/01/12
}

注意:Go 標準函式庫對地區化支援有限。實際專案建議考慮:

實用資源

結語

正確處理日期與數字是一項低成本、高效益的優化:能提升信任度、減少錯誤,並帶來更流暢的全球化體驗。集中管理格式化,儲存標準值,並在所有用戶輸入或讀取時間、金錢、數字的地方使用支援地區的 API——同時要知道哪些機器可讀資料可以略過本地化。妥善運用快取與測試,你就能打造一套穩健、可跨地區擴展且不犧牲效能的系統。