把界面上的中文换成英文,只是国际化里最容易被看见、也最不麻烦的一块。真正消耗工期的,是那些换了语言之后行为会跟着变的东西。
翻译之外还有什么
一个看似简单的”订单已提交”提示,在不同地区要处理:金额用什么货币、小数点和小数位是否一致、日期是月/日还是日/月、数量是单数还是复数、句子里的时间是用户所在时区还是商家时区、这段文字从右往左排时布局会不会散架。
具体到实现层,需要拆成六个相对独立的子问题:文案翻译、数字与货币格式化、日期与时间格式化、复数与语法变体、时区、书写方向。把它们混在一起处理,是后期返工的主要原因。
复数规则远比想象中复杂
先看一个常见写法的问题:
// 只在英文下正确
const label = count === 1 ? '1 item' : `${count} items`;
中文里”1 个文件”和”5 个文件”形式相同,英文有单复数两种,俄语有三种,阿拉伯语有六种。CLDR 定义了六个复数类别:zero、one、two、few、many、other。每种语言从中挑出自己需要的子集。
| 语言 | 使用的类别 | 说明 |
|---|---|---|
| 中文 / 日文 | other | 无复数变形 |
| 英文 | one, other | 1 与其余 |
| 俄文 | one, few, many | 按末位数字分组,21 与 1 同类,11 与 5 同类 |
| 阿拉伯文 | zero, one, two, few, many, other | 六类全用 |
| 波兰文 | one, few, many, other | 规则与俄语又不同 |
判定规则依赖的不只是数值大小,还涉及整数位、小数位、末位、末两位。俄语里 21 用 one(двадцать один файл),11 用 many(одиннадцать файлов),凭直觉写判断必然出错。
正确做法是交给标准库:
const pr = new Intl.PluralRules('ru-RU');
pr.select(1); // 'one'
pr.select(2); // 'few'
pr.select(5); // 'many'
pr.select(21); // 'one'
pr.select(1.5); // 'other'(小数值通常归入 other)
拿到类别之后再去文案表里取对应形式,而不是在代码里写 if (n === 1)。
ICU 消息格式解决语序问题
复数的另一半问题是:数字往往嵌在句子里,而不同语言的语序不同。把句子切成几段在代码里拼接,翻译人员就失去了调整语序的能力。
ICU MessageFormat 把这类问题统一成一种声明式语法:
{name} 上传了 {count, plural,
one {# 个文件}
other {# 个文件}
},共 {size, number, ::compact-short}
同一个意思在英文里可能是:
{name} uploaded {count, plural,
one {# file}
other {# files}
} ({size, number, ::compact-short})
要点:
#在 plural 分支内代表当前数值,不用重复占位符。- 占位符带显式类型(
number、date、plural、select),运行时由实现负责格式化,代码里不做字符串拼接。 select处理性别、格等其他语法变体。- 交给翻译的必须是 ICU 原文,不能是渲染后的结果,否则语序就锁死了。
时区的三个层次
时区问题几乎都源于把三个层次混为一谈。
存储层用 UTC。数据库、日志、服务间通信一律 UTC,可以存 ISO 8601 带 Z 的串,也可以存 epoch 毫秒。这一层不存在”用谁的时区”的选择题。
传输层带上偏移量。给前端的时间戳要能无歧义还原成时刻,ISO 8601 带偏移(2026-10-21T08:30:00+08:00)或 epoch 都行。不要传不带偏移的本地时间字符串。
展示层按用户时区渲染,而且用的是 IANA 时区标识(Asia/Shanghai),不是固定偏移。偏移会随夏令时变化,固定偏移在半年后就会错一个小时。
const fmt = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
dateStyle: 'long',
timeStyle: 'short',
});
fmt.format(new Date('2026-10-21T00:30:00Z')); // 按东八区渲染
几个高频坑:
new Date('2026-10-21')按 UTC 解析,new Date('2026-10-21T00:00')按本地解析。同一个日期两种结果,跨时区环境下是经典 bug 源。- 纯日期字段不该带时区。生日、账单日、节假日这类”日历上的一天”,用
2026-10-21这样的纯日期字符串存。一旦转成带时区的时刻,往西几个时区就变成前一天。 - 聚合报表必须显式指定时区。”每天的订单量”里的”天”以哪个时区为界,必须写清楚,否则前后端统计对不上。
相对时间与绝对时间
“3 分钟前”读起来友好,但它有两个硬伤:不精确,而且会过期——页面开着不动,字符串不会自己更新,五分钟后再看还是”3 分钟前”。
取舍的经验:
- 新鲜内容(通知、评论、实时状态)用相对时间,同时把绝对时间放进
title或aria-label,鼠标悬停可见。 - 归档内容、合同、日志、审计记录用绝对时间,精确性优先。
- 折中做法是设一个阈值,比如一周以内显示相对时间,超过就自动切成绝对日期。
- 相对时间也要本地化,用
Intl.RelativeTimeFormat,不要自己拼”ago”/”前”。 - 长驻页面需要定时重算,或者干脆用绝对时间避免这个问题。
const rtf = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' });
rtf.format(-3, 'minute'); // 3 分钟前
文案抽取与翻译的工作流
工程化的关键在于让”写代码”和”翻译”两件事解耦。
key 命名按功能域,不按文案内容。checkout.submit.order 比 common.submit 好,common.submit 比 提交 好。原因是同一个词在不同语境下翻译不同——英文里 “Open” 作动词和作形容词是两个词。用英文原文当 key 更糟:改一次文案就要动所有引用它的代码。
占位符必须有名字。{userName} 可翻译,{0} 不可翻译——翻译人员不知道第 0 个参数是什么,语序也就无从调整。
缺失要有兜底策略。开发环境直接抛错或在界面上高亮未翻译的 key,让问题在提交前暴露;生产环境回退到默认语言,同时上报缺失 key,避免白屏。
抽取要自动化。构建时扫描源码生成待翻译文件,别手工维护两份清单。抽取工具要能保留 ICU 结构(复数、select 分支),而不是只抽出扁平字符串。
给翻译人员提供上下文。key 的功能域、出现位置、截图、字符长度限制。只给一个孤立的词,翻错的概率很高。
RTL 布局用逻辑属性
从右往左的书写方向(阿拉伯语、希伯来语)不是把 text-align 改成 right 就完事。
核心做法是用逻辑属性替代物理属性:
/* 不推荐:物理属性,RTL 下全部要重写 */
.card { margin-left: 16px; padding-right: 8px; text-align: left; }
/* 推荐:逻辑属性,自动跟随书写方向 */
.card {
margin-inline-start: 16px;
padding-inline-end: 8px;
text-align: start;
}
对应的映射关系是:left/right → inline-start/inline-end,top/bottom → block-start/block-end。inset、border-radius(border-start-start-radius 等)也都有逻辑版本。
几处逻辑属性覆盖不到的地方需要单独处理:
transform和translateX不受逻辑属性影响,水平位移动画要按方向取反。box-shadow和background-position的水平偏移同样需要手动翻转。- 图标是否镜像要逐个判断。返回箭头、进度指示、列表缩进应该镜像;播放按钮、时钟指针、音量图标不应该——镜像了反而违背习惯。
- 双向文本(BiDi) 是个独立难题。RTL 段落里嵌入英文单词、数字串、代码片段时,双向算法会按字符自身方向排列,导致标点或单位跑到视觉上错误的位置——例如一个阿拉伯语句子里出现 “API v2 (2026)”,括号对可能被拆开。用
<bdi>包裹动态内容,或给它加unicode-bidi: isolate,把这段隔离出双向算法的作用范围,比在字符串里手工插入 Unicode 控制符可靠得多。
验证方式:把整个应用在 RTL 下完整跑一遍,重点看 flex/grid 的排列顺序、绝对定位元素、动画方向、图标朝向,以及表格里数字列的对齐。这类问题在 LTR 下完全不可见,只能靠强制切换来发现。
国际化工程的复杂度不在翻译量,而在于把那些”跟语言绑定的行为”从业务代码里剥离出来,交给标准的国际化运行时去处理。剥离得越干净,新增一个语种的成本就越接近只做一次翻译。
Comments (0)
No comments yet — be the first.
Please Sign in to join the discussion.