為什麼日期與數字需要本地化
TABLE OF CONTENTS
如果你面向全球發貨,同一組字串對不同用戶可能有完全不同的含義:12/01/2025、1.234、1,234、12:00、00:00。這些細微差異絕非表面功夫——它們會影響信任、理解,甚至法律合規。本指南將說明為什麼日期和數字必須在地化、不同地區實際會有哪些變化,以及如何在你的產品中正確實現這些本地化需求。
各地區會有什麼不同
- 日期:順序(MDY、DMY、YMD)、分隔符、月份/日期名稱;部分市場使用非格里曆。
- 時間:12小時制與24小時制、AM/PM標記、時區、夏令時間規則、一週起始日(星期日或星期一)。
- 數字:小數點分隔符(
.與,)、千位分隔方式(1,234、1.234、1 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/YYYY或1,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.DateTimeFormat、Intl.NumberFormat)。 - 根據語系驗證與解析:適當時接受語系輸入;顯示佔位符或範例以引導使用者。
- 時間資訊要明確:顯示時區縮寫或偏移量;若結果依賴時間,允許使用者選擇時區。
- 貨幣清晰:有歧義時優先使用 ISO 貨幣代碼;根據語系尊重貨幣符號的位置與間距。
- 格式統一:所有介面(UI、電子郵件、PDF、匯出檔)統一使用一層格式化工具,確保一致性。
- 格式器快取:
Intl.*建構函式呼叫成本高;每個語系只建立一次並重複使用。
什麼情況下不需在地化
並非所有內容都需要在地化。以下情境建議使用一致、機器可讀的格式:
- 機器可讀的匯出檔:供資料管線或分析工具使用的 CSV 檔,應採用固定格式(如 ISO-8601 日期、以句點作為小數分隔符)。在匯出標頭或 README 文件中清楚說明格式。
- API 回應:REST/GraphQL API 應回傳 ISO-8601 字串格式的日期,以及標準 JSON 格式的數字。讓客戶端根據使用者偏好自行處理在地化。
- 內部日誌與指標:日誌、監控儀表板、資料庫查詢等,使用一致格式有助於解析、彙整與警示。請採用 ISO-8601 與標準數字格式。
- 標準識別碼:交易 ID、訂單編號或跨語系需溝通的參考資訊,應避免語系特定格式。
經驗法則:內容給人閱讀時要在地化;資料給機器處理時要用標準格式。
實作計畫
- 稽核所有顯示面:盤點 UI、電子郵件、PDF、CSV/Excel 匯出、分析報表、日誌中的日期、時間、數字、貨幣。
- 定義地區設定:列出支援的地區語系及預設備援方案;針對每個地區語系明確規範 12/24 小時制。
- 建立工具函式:以共用輔助函式和測試包裝
Intl.*API(或相關函式庫);實作格式化器快取機制。 - 輸入策略:更新表單以接受並驗證地區格式;同時儲存標準值與原始輸入。
- 內容規範:文件化短/長日期、相對日期、貨幣顯示、百分比格式的風格指引。
- 推出計畫:優先轉換流量最高及風險最高的顯示面;如有需要,可加上功能開關逐步釋出。
測試策略
除了手動 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 標準函式庫對地區化支援有限。實際專案建議考慮:
golang.org/x/text用於數字格式化github.com/goodsign/monday處理在地化日期/時間格式github.com/bojanz/currency處理貨幣相關功能
實用資源
- 標準: Unicode CLDR(地區資料標準)、IETF BCP 47(地區識別碼)
- 文件: MDN Intl Reference
- 函式庫:
- Luxon(現代日期/時間函式庫,支援國際化)
- date-fns 搭配地區模組
- Globalize.js(基於 CLDR 的完整國際化解決方案)
- Format.js(專為 React 設計的國際化,支援 ICU 訊息語法)
- 測試: Intl polyfill 適用於舊版瀏覽器
結語
正確處理日期與數字是一項低成本、高效益的優化:能提升信任度、減少錯誤,並帶來更流暢的全球化體驗。集中管理格式化,儲存標準值,並在所有用戶輸入或讀取時間、金錢、數字的地方使用支援地區的 API——同時要知道哪些機器可讀資料可以略過本地化。妥善運用快取與測試,你就能打造一套穩健、可跨地區擴展且不犧牲效能的系統。


