locale 敏感行为与工程影响
核心问题:locale 到底会改变程序的哪些行为?为什么同一段代码在不同服务器上输出不同结果?
真实场景
你的电商平台按价格排序,在北京测试没问题,但部署到德国服务器后排序结果混乱。原因:服务器 locale 是 de_DE,字符串比较规则不同。locale 不只是"显示语言",它影响排序、数字格式、日期解析等一系列行为。
locale 敏感行为全景
mindmap
root((locale 影响))
数字格式
千分位分隔符
小数点符号
数字系统
日期时间
日期格式顺序
月份名称
星期起始日
12/24小时制
货币
货币符号位置
货币符号本身
排序
字母排序规则
大小写处理
重音字符
复数规则
单复数形式数量
文本方向
LTR vs RTL
度量单位
公制 vs 英制
数字格式
不同 locale 的数字书写方式差异巨大:
| locale | 一百万零五分之三 | 千分位 | 小数点 |
|---|---|---|---|
en-US | 1,000,000.30 | , | . |
de-DE | 1.000.000,30 | . | , |
fr-FR | 1 000 000,30 | (空格) | , |
hi-IN | 10,00,000.30 | 印度分组 | . |
ar-SA | ١٬٠٠٠٬٠٠٠٫٣٠ | ٬ | ٫ |
zh-CN | 1,000,000.30 | , | . |
const num = 1000000.3;
// 不同 locale 的格式化结果
new Intl.NumberFormat('en-US').format(num); // "1,000,000.3"
new Intl.NumberFormat('de-DE').format(num); // "1.000.000,3"
new Intl.NumberFormat('fr-FR').format(num); // "1 000 000,3"
new Intl.NumberFormat('ar-SA').format(num); // "١٬٠٠٠٬٠٠٠٫٣"
// ⚠️ 危险:直接 parseFloat() 解析德语格式数字
parseFloat('1.000.000,30'); // 1 (错误!解析成 1)
// 正确做法:不要用 parseFloat 解析用户输入,用专门的库
// 或在服务端始终以标准格式(en-US 或 API 格式)传输数字
工程建议:前后端数字传输
API 传输:始终使用 JSON 数字类型(无格式)
API → 前端:number 类型,不带任何格式字符
前端显示:用 Intl.NumberFormat 根据用户 locale 格式化
❌ 错误:API 返回 "1,000,000.30"(字符串,有格式)
✅ 正确:API 返回 1000000.3(JSON 数字)
日期与时间格式
日期顺序
| locale | 格式 | 例子 |
|---|---|---|
en-US | MM/DD/YYYY | 03/22/2026 |
en-GB | DD/MM/YYYY | 22/03/2026 |
zh-CN | YYYY/MM/DD | 2026/03/22 |
de-DE | DD.MM.YYYY | 22.03.2026 |
ja-JP | YYYY年MM月DD日 | 2026年03月22日 |
ko-KR | YYYY. MM. DD. | 2026. 03. 22. |
歧义陷阱:03/04/2026 在美国是 3 月 4 日,在英国是 4 月 3 日!
// 始终用 ISO 8601 格式在系统间传输日期
// YYYY-MM-DD 或 YYYY-MM-DDTHH:mm:ssZ(UTC)
const isoDate = '2026-03-22T00:00:00Z';
// 显示时用 Intl.DateTimeFormat 按用户 locale 格式化
new Intl.DateTimeFormat('en-US').format(new Date(isoDate)); // "3/22/2026"
new Intl.DateTimeFormat('en-GB').format(new Date(isoDate)); // "22/03/2026"
new Intl.DateTimeFormat('zh-CN').format(new Date(isoDate)); // "2026/3/22"
new Intl.DateTimeFormat('ja-JP').format(new Date(isoDate)); // "2026/3/22"(CLDR 决定)
// 自定义格式
new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(isoDate)); // "2026年3月22日"
星期起始日
| locale | 星期起始日 |
|---|---|
en-US | 星期日 |
zh-CN、en-GB、de-DE | 星期一 |
| 大部分伊斯兰国家 | 星期六 |
这影响日历控件的渲染。多数 UI 框架提供 firstDayOfWeek 配置项。
12 小时 vs 24 小时制
| locale | 时制 |
|---|---|
en-US | 12 小时(AM/PM) |
zh-CN、de-DE、fr-FR | 24 小时 |
en-GB | 通常 24 小时(正式),12 小时(口语) |
// 24小时制
new Intl.DateTimeFormat('zh-CN', { hour: 'numeric', minute: 'numeric', hour12: false })
.format(new Date('2026-03-22T15:30:00')); // "15:30"
// 12小时制
new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: 'numeric', hour12: true })
.format(new Date('2026-03-22T15:30:00')); // "3:30 PM"
货币格式
| locale | 货币 | 格式示例 |
|---|---|---|
en-US | USD | $1,234.56 |
zh-CN | CNY | ¥1,234.56 |
de-DE | EUR | 1.234,56 € |
fr-FR | EUR | 1 234,56 € |
ja-JP | JPY | ¥1,235(日元无小数) |
ar-SA | SAR | ١٬٢٣٤٫٥٦ ر.س. |
注意货币符号的位置(前缀/后缀)和小数位数也随 locale 不同。
// 货币格式化
const formatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
formatter.format(1234.56); // "1.234,56 €"
// 注意:货币代码(EUR)和 locale 是独立的
// locale 决定格式,currency 决定货币单位
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' })
.format(1234.56); // "€1,234.56"(en-US 格式,但是欧元)
字符串排序(Collation)
这是最容易被忽视但影响最大的 locale 敏感行为。
为什么排序会出问题
// JavaScript 默认排序:按 Unicode 码点
['banana', 'apple', 'cherry'].sort();
// ['apple', 'banana', 'cherry'] ✅ 巧合正确
// 但带重音字符就出问题了
['résumé', 'resume', 'résume'].sort();
// ['resume', 'résumé', 'résume'] ❌ 可能不符合法语习惯
// 德语排序:ä 应该紧跟 a
['Äpfel', 'arm', 'Boot', 'bar'].sort();
// ['Boot', 'arm', 'bar', 'Äpfel'] ❌ 按码点排
使用 Intl.Collator 正确排序
// 德语排序(ä 视为 a 的变体)
const deCollator = new Intl.Collator('de-DE');
['Äpfel', 'arm', 'Boot', 'bar'].sort(deCollator.compare);
// ['Äpfel', 'arm', 'bar', 'Boot'] ✅
// 中文排序:按拼音
const zhCollator = new Intl.Collator('zh-CN', { sensitivity: 'base' });
['张', '李', '王', '赵'].sort(zhCollator.compare);
// ['李', '王', '张', '赵'](拼音顺序)
// sensitivity 选项
// 'base': 忽略重音和大小写(最宽松)
// 'accent': 区分重音,忽略大小写
// 'case': 忽略重音,区分大小写
// 'variant': 区分重音和大小写(最严格)
数据库排序规则
-- MySQL:按中文拼音排序
SELECT name FROM products
ORDER BY CONVERT(name USING gbk) COLLATE gbk_chinese_ci;
-- PostgreSQL:设置 locale-aware 排序
CREATE TABLE products (
name TEXT COLLATE "zh-CN-x-icu" -- 需要 pg_icu 扩展
);
-- 常用排序规则
-- utf8mb4_zh_0900_as_cs:中文按拼音
-- utf8mb4_unicode_ci:Unicode 通用不区分大小写
-- utf8mb4_bin:按字节排序(最快,但无语言规则)
正则表达式与 locale
// Unicode 属性转义(需要 /u 标志)
/\p{Script=Han}/u.test('中'); // true(汉字)
/\p{Script=Latin}/u.test('a'); // true(拉丁字母)
/\p{Script=Arabic}/u.test('ع'); // true(阿拉伯字母)
// 通用字母匹配(支持所有语言)
/\p{Letter}/u.test('ü'); // true
/\p{Letter}/u.test('中'); // true
/\p{Letter}/u.test('1'); // false
// 旧的 \w 不匹配非 ASCII 字母
/\w/.test('ü'); // false ❌
/\p{L}/u.test('ü'); // true ✅
文本方向(Bidi)
阿拉伯语、希伯来语、波斯语等从右向左书写(RTL)。同一段文本中混合 LTR 和 RTL 内容时,浏览器的双向文本算法(Unicode Bidi Algorithm)会处理文字方向。
<!-- 声明整个页面的文字方向 -->
<html dir="rtl" lang="ar">
<!-- 单个元素切换方向 -->
<p dir="ltr">English text in an RTL page</p>
<!-- auto:让浏览器根据内容自动判断 -->
<p dir="auto">مرحبا Hello مرحبا</p>
RTL 支持详见第 07 章。
度量单位系统
| 系统 | 国家/地区 | 长度 | 重量 | 温度 |
|---|---|---|---|---|
| 公制 | 大多数国家 | km、m、cm | kg、g | °C |
| 英制 | 美国、缅甸、利比里亚 | miles、feet、inches | pounds、ounces | °F |
| 混用 | 英国 | 距离用 miles,身高用 feet | 体重混用 | °C(官方) |
// Intl.MeasureFormat(实验性,但支持度在提升)
// 目前建议用独立的单位转换 + Intl.NumberFormat
function formatTemperature(celsius, locale) {
const isUSCustomary = ['en-US', 'en-AS', 'en-GU', 'en-MH', 'en-MP', 'en-PR', 'en-VI'].includes(locale);
if (isUSCustomary) {
const fahrenheit = celsius * 9/5 + 32;
return `${Math.round(fahrenheit)}°F`;
}
return `${Math.round(celsius)}°C`;
}
formatTemperature(22, 'zh-CN'); // "22°C"
formatTemperature(22, 'en-US'); // "72°F"
locale 对服务端的影响
服务端代码同样受 locale 影响,尤其是在容器/云环境中:
# 查看系统 locale
locale
# 输出可能是:
# LANG=en_US.UTF-8
# LC_TIME=en_US.UTF-8
# LC_NUMERIC=en_US.UTF-8
# ...
# 设置 locale(不同 Linux 发行版不同)
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
Python 的 locale 陷阱:
import locale
# 获取当前系统 locale
locale.getlocale() # ('en_US', 'UTF-8')
# 设置为德语格式
locale.setlocale(locale.LC_NUMERIC, 'de_DE.UTF-8')
locale.format_string('%.2f', 1234567.89) # '1234567,89'(德语小数点)
# ⚠️ 危险:setlocale 是全局的,会影响多线程
# 生产代码应避免调用 setlocale,改用 babel 库
from babel.numbers import format_number
format_number(1234567.89, locale='de_DE') # '1.234.567,89'
工程实践总结
| 数据类型 | 存储/传输格式 | 显示格式化 |
|---|---|---|
| 数字 | JSON number(无格式) | Intl.NumberFormat |
| 日期 | ISO 8601 UTC(2026-03-22T00:00:00Z) | Intl.DateTimeFormat |
| 货币金额 | JSON number + 货币代码(分开传输) | Intl.NumberFormat currency |
| 字符串排序 | 数据库 collation 或 Intl.Collator | Intl.Collator |
| 文字方向 | HTML dir 属性 + CSS logical properties | 见第 07 章 |
进入第 02 章:掌握 locale 基础后,开始学习如何在 React/Next.js 和 Vue 中搭建完整的 i18n 框架。