为什么日期和数字需要本地化

目录
如果您进行全球运输,同样的字符串可能对不同用户意味着不同的内容:12/01/2025
、1.234
、1,234
、12:00
、00:00
。这些细微的差异不仅仅是外观问题——它们影响信任、理解,甚至法律合规性。本指南解释了为什么日期和数字必须进行本地化,实际在不同地区会发生哪些变化,以及如何在您的产品中正确实施这些变化。
不同地区的变化
- 日期:顺序(MDY vs DMY vs YMD)、分隔符、月份/日期名称;某些市场使用非公历。
- 时间:12小时制 vs 24小时制、AM/PM标记、时区、夏令时规则、工作周开始(星期日 vs 星期一)。
- 数字:小数分隔符(
.
vs,
)、分组样式(1,234
vs1.234
vs1 234
)、印度分组(1,23,456
)、不间断空格、减号/加号样式(连字符 vs 真正的减号−
)。 - 货币:符号 vs ISO代码、符号位置(前缀/后缀)、窄间距 vs 宽间距、小数精度(0、2或3+)、会计负数(例如,
(1 234,56 €)
)。 - 百分比和单位:间距和符号位置(
50%
vs50 %
)、单位的地区名称、公制 vs 英制。
商业影响
- 信任和转换:价格、费用和日期“看起来不对”会减少购买并增加流失。
- 操作风险:误读日期可能会导致预订或截止日期的变化;解析错误会破坏发票、导出和分析。
- 合规性:格式错误的财务文件可能违反发票、税务或报告规则。
- 支持负担:用户会打开工单以澄清与预期不符的时间、货币和数字输入。
现实世界事件
一家欧洲旅行预订平台显示出发日期为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 小时制。
- 字符串拼接: 构建 “amount + currency” 如
"$" + 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);
}
```js
// ✅ 良好:根据语言环境缓存格式化器
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–5ms);使用现有实例进行格式化速度很快(约 0.01ms)。 - 避免循环:切勿在
map()
、forEach()
或渲染循环中创建格式化器。 - 延迟加载语言环境数据:对于 Web 应用,考虑代码拆分以仅加载所需的语言环境,减少包大小。
- 服务器端:在每个请求/响应周期中使用单个格式化器;避免在大数据集中为每个项目创建格式化器。
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
// 日期
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 (day/month/year)
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
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
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 包使用布局而不是区域设置格式
// 对于完整的国际化日期格式化,使用 github.com/goodsign/monday 或类似工具
date := time.Date(2025, 1, 12, 0, 0, 0, 0, time.UTC)
fmt.Println(date.Format("01/02/2006")) // US: 01/12/2025
fmt.Println(date.Format("02/01/2006")) // EU: 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 参考
- 库:
- Luxon (现代日期/时间库,支持国际化)
- date-fns 带有语言环境模块
- Globalize.js (基于 CLDR 的全面国际化)
- Format.js (专注于 React 的国际化,使用 ICU 消息语法)
- 测试: Intl polyfill 用于旧版浏览器
结束
正确处理日期和数字是一项低投入、高影响的胜利:更高的信任度,更少的错误,以及更流畅的全球体验。集中格式化,存储规范值,在用户读取或输入时间、金钱或数字的地方使用支持语言环境的 API,并知道何时跳过机器可读数据的本地化。通过适当的缓存和测试,您将构建一个在不同语言环境中扩展而不牺牲性能的强大系统。