7AA7 Blog

前端国际化工程:文案、时区与布局适配

林见川 UID:69
2026-09-29 21:36
83 0 0

把界面上的中文换成英文,只是国际化里最容易被看见、也最不麻烦的一块。真正消耗工期的,是那些换了语言之后行为会跟着变的东西。

翻译之外还有什么

一个看似简单的”订单已提交”提示,在不同地区要处理:金额用什么货币、小数点和小数位是否一致、日期是月/日还是日/月、数量是单数还是复数、句子里的时间是用户所在时区还是商家时区、这段文字从右往左排时布局会不会散架。

具体到实现层,需要拆成六个相对独立的子问题:文案翻译、数字与货币格式化、日期与时间格式化、复数与语法变体、时区、书写方向。把它们混在一起处理,是后期返工的主要原因。

复数规则远比想象中复杂

先看一个常见写法的问题:

// 只在英文下正确
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 下完全不可见,只能靠强制切换来发现。

国际化工程的复杂度不在翻译量,而在于把那些”跟语言绑定的行为”从业务代码里剥离出来,交给标准的国际化运行时去处理。剥离得越干净,新增一个语种的成本就越接近只做一次翻译。

1.6kViews
83Likes
0Comments
0Participants
11 minread time

Comments (0)

No comments yet — be the first.