引言
「国际化」听起来像是加个语言包就完事,但真正做过的人都知道:日期在德语里是 31.12.2026、数字在法语里用逗号做小数点、土耳其语的 i 大写是 İ、阿拉伯语要从右往左排版、俄语有六种复数形式、排序规则让 ä 排在 z 之后。这些细节没有一处能靠 if (lang == 'zh') 解决。本文从最底层的 locale 与 LC_ 环境变量讲起,穿过字符集、排序、日期、数字、复数与 RTL,落到 ICU/CLDR 标准与工程实践,帮你把「国际化」从补丁变成体系。
前置:/unicode-encoding-guide/(码点、UTF-8 与规范化)、/time-timezone-handling/(时刻与时区)。排序与比较见 /others-sorting-algorithms/。
目录
- 1. locale 与 LC_ 环境变量
- 2. 字符集、编码与 UTF-8
- 3. 排序与比较规则
- 4. 日期与时间格式化
- 5. 数字、货币与度量
- 6. 复数规则与文本方向
- 7. ICU 与 CLDR 标准
- 8. i18n 工程实践
- 9. 常见坑与测试
- 10. 速查表与一句话记忆
- 延伸阅读
1. locale 与 LC_ 环境变量
locale 是「用户的语言、地区与文化约定」的集合,它影响日期、数字、货币、排序、字符分类等一切「文化相关」行为。
locale 名字的结构:language_TERRITORY.codeset@modifier,如 zh_CN.UTF-8 / en_US.UTF-8
sr_RS.UTF-8@latin(@modifier 变体);C / POSIX(仅 ASCII,无本地化)
LC_ 分类变量:每一类行为各有独立变量,避免「改一个影响全部」。
| 变量 | 影响 |
|---|---|
| LC_CTYPE | 字符分类(是否字母/数字)、大小写转换 |
| LC_COLLATE | 字符串排序与比较 |
| LC_TIME | 日期时间格式 |
| LC_NUMERIC | 数字格式(小数点/千分位) |
| LC_MONETARY | 货币格式 |
| LC_MESSAGES | 系统消息语言(错误提示) |
| LC_ALL | 覆盖以上全部(最高优先级) |
优先级(从高到低):LC_ALL > LC_<CATEGORY> > LANG。LC_ALL 一旦设置,其余全部失效 → 排查 locale 问题先看它。
locale ; locale charmap # 全部变量当前值 / 当前字符集(locale -a 列出已安装)
LC_ALL=de_DE.UTF-8 date # 临时覆盖(只影响本条命令)
LC_TIME=fr_FR.UTF-8 date ; LANG=en_US.UTF-8 sort names.txt
C locale 的陷阱:C/POSIX locale 下 sort 按字节序排(大写全排在小写前),非 ASCII 字符可能被当作「非字母」。脚本里常显式设 LC_ALL=C 让输出可预测、可复现(字节序、英文错误消息);反之面向用户的输出要用用户 locale。
记忆:locale = 语言+地区+文化约定,LC_ 分类控制(CTYPE 字符分类、COLLATE 排序、TIME 日期、NUMERIC 数字、MESSAGES 消息);优先级 LC_ALL > LC_类别 > LANG,排查先看 LC_ALL;脚本里设 LC_ALL=C 求可复现、面向用户则用用户 locale。
2. 字符集、编码与 UTF-8
locale 决定「什么编码被当作默认」,字符集与编码是 i18n 的地基。
字符集(charset):字符到码点的映射(Unicode 是字符集)
编码(encoding):码点到字节的映射(UTF-8/UTF-16);"UTF-8" 既是编码也是字符集标识
echo $LANG ; locale charmap # 例:zh_CN.UTF-8 / UTF-8
file -i document.txt # 猜编码(不一定准)
iconv -f GBK -t UTF-8 in.txt -o out.txt
iconv -f UTF-8 -t GB18030 in.txt -o out.txt # 或 -t UTF-8 -c 丢弃非法字符
UTF-8 的三大优势:ASCII 兼容(ASCII 字符仍是单字节,老系统不崩)、自同步(任意字节都能判断「是否字符起始」,损坏易恢复)、无字节序问题(不像 UTF-16 需要 BOM/端序)→ 现代系统的默认选择。
编码不一致的典型故障:
GBK 文件被当 UTF-8 读 → 乱码("锟斤拷"是 UTF-8 解读 GBK 的产物)
UTF-8 文件被当 Latin-1 读 → 每个中文变两三个怪字符(如 测试)
BOM:UTF-8 BOM(EF BB BF)会让解析器读错首字段(脚本首行 #!/bin/sh 前有 BOM → No such file)
sed -i '1s/^\xEF\xBB\xBF//' file.txt # 去 BOM;dos2unix 同时处理 BOM 与换行
编码声明必须端到端一致:文件编码 → 程序读取编码 → 数据库连接编码 → HTTP Content-Type charset → HTML meta。任一环不一致就乱码,排查时从「最外层」逐环核对。
记忆:字符集是「字符→码点」、编码是「码点→字节」;现代默认 UTF-8(ASCII 兼容、自同步、无端序);乱码排查端到端核对「文件→程序→数据库→HTTP→HTML」五环,警惕 UTF-8 BOM 破坏脚本首行。
3. 排序与比较规则
排序规则(collation)是 locale 最反直觉的部分:同一组字符串,不同 locale 排出不同顺序。
Unicode 码点序(byte order):A < B < ... < Z < a < b < ...(大写在前)
locale 感知序(如 en_US):a < A < b < B(字母优先);ä 与 a、é 与 e 相近(重音不改主序)
经典差异:
德语(de_DE):ä 排在 a 之后(字典序);电话簿序(de_DE@phonebook)把 ä 当 ae
瑞典语(sv_SE):å ä ö 排 z 之后(独立字母);丹麦语(da_DK):æ ø å 排 z 后
捷克语:ch 是独立字母排在 h 之后;西班牙语(传统):ch 与 ll 曾是独立字母
比较的四个级别(ICU Collation Level):
Primary 忽略大小写与重音(a = A = á)
Secondary 区分重音,忽略大小写(a < á,A = a)
Tertiary 区分大小写(a < A);Quaternary 区分标点/变体(默认关闭)
→ 排序用 Primary/Secondary,查找常用 Primary
Unicode 归一化是排序的前提:é 可能是「单码点 U+00E9」或「e + 组合重音 U+0301」,字节不同但语义相同。
import unicodedata, locale
s = "café"
print(len(unicodedata.normalize("NFC", s)), len(unicodedata.normalize("NFD", s))) # 4 vs 5
locale.setlocale(locale.LC_COLLATE, "de_DE.UTF-8")
print(sorted(["Apfel", "Äpfel", "Zebra"], key=locale.strxfrm)) # locale 感知比较键
strxfrm / collation key:把字符串转成"按字节比较即等价于按 locale 比较"的键
→ 排序时算一次键,之后 O(1) 比较(性能关键)
记忆:排序分「码点序」与「locale 感知序」——德语 ä 排 a 后、瑞典语 å ä ö 排 z 后、捷克语 ch 是独立字母;比较有 Primary/Secondary/Tertiary 级别(忽略大小写/重音/区分大小写);排序前必须 Unicode 归一化,用 strxfrm/collation key 做 O(1) 比较。
4. 日期与时间格式化
同一个时刻,各 locale 写法天差地别:
en_US:December 31, 2026 / 12/31/2026 / 12:30 PM
de_DE:31. Dezember 2026 / 31.12.2026 / 12:30
zh_CN:2026年12月31日 / 2026/12/31 / 12:30
fr_FR:31 décembre 2026 / 31/12/2026 / 12:30
易混淆的 MM/DD 与 DD/MM:03/04/2026 在美式是 3 月 4 日、欧式是 4 月 3 日。机器交换一律用 ISO 8601(2026-03-04),只在展示时本地化。
LC_TIME=de_DE.UTF-8 date '+%A %d. %B %Y' # Sonntag 31. Dezember 2026
LC_TIME=ja_JP.UTF-8 date '+%Y年%m月%d日' # 2026年12月31日
import locale
from datetime import datetime
locale.setlocale(locale.LC_TIME, "de_DE.UTF-8")
print(datetime(2026, 12, 31, 12, 30).strftime("%A, %d. %B %Y")) # Donnerstag, 31. Dezember 2026
周起始日(first day of week):不同地区不同,是排程/日历的隐形坑——周日开头(en_US、ja_JP、he_IL)、周一开头(de_DE、fr_FR、zh_CN、ISO 8601)、周六开头(ar_SA、fa_IR)。12/24 小时制:en_US 用 12 小时 + AM/PM,de_DE/zh_CN/fr_FR 用 24 小时制——格式化必须由 locale 决定,别硬编码 %H 或 %I。
时区与 locale 是两个正交维度:locale 决定「怎么写」,时区决定「写哪个时刻」,两者都要正确。
记忆:日期格式随 locale 变(en_US 12/31、de_DE 31.12、zh_CN 2026年12月31日)——机器交换一律 ISO 8601、只在展示时本地化;周起始日(美周日/中德周一/阿拉伯周六)与 12/24 小时制都由 locale 决定;locale 管「怎么写」、时区管「写哪个时刻」,两者正交。
5. 数字、货币与度量
数字分隔符是 i18n 的经典坑:
en_US:1,234,567.89(逗号千分位,点做小数点)
de_DE:1.234.567,89(点做千分位,逗号做小数点)
fr_FR:1 234 567,89(窄空格做千分位,逗号做小数点)
解析用户输入时:1,234 在美式是 1234、在欧式是 1.234(≈1.23)。永远用 locale 感知的解析器,不要 float(s.replace(',', ''))。
import locale
locale.setlocale(locale.LC_NUMERIC, "de_DE.UTF-8")
print(locale.atof("1.234,56")) # 1234.56
print(locale.format_string("%.2f", 1234.56, grouping=True)) # 1.234,56
货币格式:
en_US:$1,234.56(符号在前);de_DE:1.234,56 €(符号在后,空格分隔)
ja_JP:¥1,235(无小数);zh_CN:¥1,234.56
货币三要素:1) 符号/代码(¥ 是 CNY 还是 JPY?必须用 ISO 4217 明确)
2) 小数位数(JPY 0 位、USD 2 位、BHD 3 位)
3) 符号位置与负号表示(会计负数可能用括号)
→ 存金额永远用"最小货币单位整数"或 Decimal,绝不用 float
度量与单位:英制/公制、摄氏/华氏、距离单位都需按 locale 转换与格式化;物理量存储用基准单位 + 精确值,展示时再换算。连「顿号还是逗号」也是 locale 问题(中文用「、」、英文用 , and)。
记忆:数字格式随 locale(en_US 1,234.56 / de_DE 1.234,56)——解析用户输入必须用 locale 感知解析器,别手撸 replace;货币要明确 ISO 4217 代码、小数位数(JPY 0 位)与符号位置;金额用最小单位整数或 Decimal,绝不用 float。
6. 复数规则与文本方向
复数规则远比「加 s」复杂——CLDR 定义了 zero/one/two/few/many/other 六类,各语言用的子集不同。
英语:one / other(1 book / 2 books);中文:other(只有一类,无复数变化)
俄语:one / few / many / other(1 книга / 2 книги / 5 книг)
阿拉伯语:zero/one/two/few/many/other(六类全用);日语:other(同中文)
from babel import Locale
loc = Locale.parse("ru_RU")
print(loc.plural_form(1), loc.plural_form(2), loc.plural_form(5)) # one few many
反模式:if (n == 1) msg = "1 item" else msg = f"{n} items"
→ 只对英语成立,俄语/阿拉伯语直接崩;正确:用 ICU MessageFormat
ICU MessageFormat 示例:
中文:{count, plural, =0 {没有文件} one {# 个文件} other {# 个文件}}
俄语:{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}
性别与选择:{gender, select, male {他邀请了你} female {她邀请了你} other {TA 邀请了你}},可嵌套(select 里套 plural,表达「她邀请了 3 位朋友」)。
文本方向(RTL):
LTR:中文/英文/德文…;RTL:阿拉伯语/希伯来语/波斯语/乌尔都语
→ HTML 用 dir="rtl" 或 CSS direction: rtl
→ 布局要"逻辑属性"化:margin-inline-start 而非 margin-left
双向文本(BiDi)的坑:RTL 与 LTR 混排时(阿拉伯语里嵌英文/数字),显示顺序可能错乱,需用隔离字符(U+2066 LRI / U+2069 PDI)或 HTML <bdi> 隔离用户名等变量内容。
记忆:复数规则由 CLDR 定义(英 one/other、俄 one/few/many/other、阿拉伯六类、中文仅 other)——必须用 ICU MessageFormat,别写 if (n==1);性别用 select;RTL 语言用 dir=rtl + CSS 逻辑属性(margin-inline-start),混排用 bdi/隔离字符防 BiDi 错乱。
7. ICU 与 CLDR 标准
CLDR(Common Locale Data Repository)是 Unicode 联盟维护的本地化数据标准:各 locale 的日期模式、数字格式、货币、复数规则、排序规则等都由它定义。ICU(International Components for Unicode)是把 CLDR 数据变成可调用 API 的库。
CLDR:数据("de_DE 的月份名是 Januar, Februar...")
ICU:代码库(读 CLDR 数据,提供格式化/解析/排序 API)
→ 几乎所有现代运行时都用它们:Java java.text/java.time 基于 CLDR
Node 的 Intl(ECMA-402)基于 ICU;Python 的 Babel 用 CLDR;Go x/text 用 CLDR
Node.js Intl 示例:
const n = 1234567.89;
new Intl.NumberFormat('de-DE').format(n); // "1.234.567,89"
new Intl.NumberFormat('en-US').format(n); // "1,234,567.89"
new Intl.DateTimeFormat('de-DE', { dateStyle: 'long' }).format(new Date());
new Intl.RelativeTimeFormat('zh-CN').format(-1, 'day'); // "1天前"
new Intl.PluralRules('ru-RU').select(5); // "many"
['Äpfel', 'Apfel', 'Zebra'].sort(new Intl.Collator('de-DE').compare); // 感知排序
ICU 提供的能力:NumberFormat / DateTimeFormat / RelativeTimeFormat、PluralRules、ListFormat(「a、b 和 c」)、Collator、MessageFormat、Segmenter(按词/句/字素切分)。为什么不用手写:CLDR 数据量巨大且持续更新(几十种日历、几百种 locale 的规则)→ 用平台 Intl/ICU 而非自己维护数据表。
字素切分(Segmenter)的坑:"👨👩👧".length 在 JS 里不等于 1(是多个码点组成的字素簇);中文/泰语没有词间空格,按词切分需要 Segmenter:
const seg = new Intl.Segmenter('zh-CN', { granularity: 'word' });
[...seg.segment('我爱自然语言处理')].map(s => s.segment); // ["我","爱","自然语言","处理"]
记忆:CLDR 是「本地化数据标准」(日期模式/数字/货币/复数/排序),ICU 是读它的「库」——Java/Node Intl/Python Babel/Go x-text 都基于它;别自己维护 locale 数据表,用平台 Intl/ICU;字素与分词要用 Segmenter(emoji 与中文切分)。
8. i18n 工程实践
资源文件与键设计:
{
"cart.items": "{count, plural, one {# 件商品} other {# 件商品}}",
"user.greeting": "你好,{name}",
"error.network": "网络连接失败,请稍后重试"
}
键命名:按"模块.语义"(cart.items),不要用英文原文做键
错误:"Add to cart"(改文案就全断);正确:"cart.addButton"
禁止字符串拼接:t('hello') + name + t('welcome') → 语序因语言而异
必须整句 + 占位符:t('greeting', { name })
占位符与转义:
占位符用 {name} 而非 %s(%s 无法重排语序);复数/性别用 ICU MessageFormat
HTML 转义:翻译文本可能含 < > & → 按上下文转义
文本节点转义 HTML;属性值转义引号;URL 参数做 URL 编码
语言协商与回退:
协商:Accept-Language 头 / navigator.languages → 匹配最合适的 locale
zh-CN,zh;q=0.9,en;q=0.8(q 是权重);回退链:zh-Hant-TW → zh-Hant → zh → en
curl -H 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8' -I https://example.com
内容与布局的 i18n:
文本膨胀:德语/俄语比英语长 30~40%(按钮/标签要留白),别固定宽度装文字
不要在图片里烧文字(无法翻译)→ 用叠加文字层
排序/筛选/搜索都要 locale 感知(数据库排序规则)
格式化与解析对称:展示用 locale 格式化(Intl.NumberFormat),输入用 locale 解析(反向 / Babel parse),存库用中立格式(ISO 8601 日期、最小单位整数金额、UTF-8)→「本地化只在边界,内部一律中立」。
记忆:键用「模块.语义」不用英文原文、禁字符串拼接(必须整句+占位符)、复数/性别用 ICU;语言协商看 Accept-Language、回退链逐级降级;布局留白应对文本膨胀、别把文字烧进图;本地化只在边界、内部一律中立格式(ISO 日期/整数金额/UTF-8)。
9. 常见坑与测试
十二个高频坑:
1. 用 float 存金额 → 精度丢失(用整数最小单位/Decimal)
2. 手撸 replace(',', '') 解析数字 → 欧式数字直接错
3. if (n == 1) 做复数 → 非英语全崩
4. 字符串拼接组装句子 → 语序/格变化全错
5. 硬编码 MM/DD 或 12 小时制 → 展示错;固定宽度按钮 → 德语溢出/截断
6. 忽略 RTL → 阿拉伯语布局错乱;忘归一化 → 看着一样的字符串不相等
7. 用 C locale 排序中文 → 顺序莫名其妙;把文字烧进图片 → 无法翻译
8. 时区当 locale 用 → "本地时间"含义错;只测中英 → 俄语阿拉伯语翻车
测试策略:
伪本地化:把英文替换成 "Ĥéļļó Ŵóŕļď"(加长+重音)
→ 一次暴露"硬编码文本""宽度不够""字符串拼接"
加长测试:文案统一加长 40%,看布局是否崩
RTL 镜像测试:dir=rtl 下检查布局;复数矩阵测试:0/1/2/5/11/100
字符集测试:emoji、CJK、组合字符、超长单词;时区/日历测试:跨时区、跨 DST、非公历
自动化检查:
# 找出"没走 i18n 的硬编码文案"(启发式)
grep -rnE '"[^"]*[A-Za-z]{3,} [A-Za-z]{3,}' src/ | grep -v i18n
# 检查各语言资源键是否齐全:i18next-parser / vue-i18n-extract / babel 提取
记忆:十二坑集中在「float 存钱、手撸数字解析、if(n==1) 复数、字符串拼接、硬编码格式、固定宽度、忽略 RTL、忘归一化、C locale 排序、文字烧进图、混淆时区、只测中英」;用伪本地化+加长 40%+RTL 镜像+复数矩阵+emoji 做测试,用提取工具查硬编码文案与缺键。
10. 速查表与一句话记忆
| 主题 | 结论 |
|---|---|
| locale 优先级 | LC_ALL > LC_类别 > LANG;脚本显式 LC_ALL=C |
| 字符集 | 现代默认 UTF-8,端到端一致 |
| 乱码排查 | 文件→程序→DB→HTTP→HTML 五环 |
| 排序 | locale 感知,先 Unicode 归一化;级别 Primary/Secondary/Tertiary |
| 日期交换 | ISO 8601,展示才本地化 |
| 周起始 | 美周日/中德周一/阿拉伯周六 |
| 数字 | 解析用 locale 感知,别 replace |
| 金额 | 最小单位整数或 Decimal,禁 float |
| 复数 | CLDR 规则,用 ICU MessageFormat |
| RTL | dir=rtl + CSS 逻辑属性 + bdi |
| 标准 | CLDR 数据 + ICU 库(Intl/Babel) |
一句话记忆:locale 是「语言+地区+文化约定」的集合,LC_ALL > LC_类别 > LANG(排查先看 LC_ALL),脚本设 LC_ALL=C 求可复现;字符集端到端一致用 UTF-8,乱码按「文件→程序→DB→HTTP→HTML」五环查;排序是 locale 感知且必须先 Unicode 归一化(德语 ä 排 a 后、瑞典语 åäö 排 z 后);日期机器交换用 ISO 8601、展示才本地化,周起始与 12/24 小时制随 locale;数字解析用 locale 感知器、金额用整数最小单位或 Decimal 禁 float;复数按 CLDR 用 ICU MessageFormat(俄语四类、阿拉伯六类、中文仅 other),RTL 用 dir=rtl 与逻辑属性;标准是 CLDR(数据)+ ICU(库,即平台 Intl/Babel);工程上键用「模块.语义」、禁字符串拼接、本地化只在边界内部中立——i18n 不是「加个语言包」,而是把「文化差异」当成一等公民来设计。
延伸阅读
- /unicode-encoding-guide/ — 码点、UTF-8 与 Unicode 归一化
- /time-timezone-handling/ — 时刻、时区与夏令时
- /others-sorting-algorithms/ — 排序算法与比较函数的语义
- /others-fuzzy-text-matching/ — 文本比较与相似度度量
- /others-json-yaml-processing/ — 资源文件与消息目录的格式
- /text-processing-toolkit/ — 命令行文本处理与编码转换
- CLDR 项目
- ICU 文档
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。