国际化与本地化处理:locale、LC_ 变量与 ICU 实践

系统讲解国际化与本地化处理:locale 概念与 LC_ALL/LC_ 变量优先级、字符集与 UTF-8 边界、排序与比较规则(collation 与 Unicode 归一化)、日期时间格式化与周起始、数字货币与度量格式、复数规则与文本方向(RTL)、ICU 与 CLDR 标准、i18n 工程实践(资源文件/键设计/复数与性别/占位符)、常见坑与测试策略。

引言

「国际化」听起来像是加个语言包就完事,但真正做过的人都知道:日期在德语里是 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_ 环境变量

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
RTLdir=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 文档

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「others」更多文章

  1. 语义化版本与依赖解析:从 SemVer 规则到依赖地狱治理
  2. 图像与媒体工具链:ImageMagick、ffmpeg 与格式选型实战
  3. 加密与证书工具箱:对称非对称、哈希 HMAC 与 openssl 速查