前端国际化工程:不只是把中文替换成英文
我们这套电商中后台要出一个英文版,一开始大家都以为是小工作量。设计稿改几个字,把中文抽成语言包,接上一个 t() 函数,齐活。
真正把英文切出来那天,问题一屏一屏往外冒:订单列表的操作按钮里,中文的“导出”两个字换成 Export data 直接把整列表头顶歪;日期还是 2024年10月31日 的中文格式,混在英文界面里格外扎眼;金额显示成 1234.5,既没有千分位也没有货币符号;后端返回的错误提示,弹出来还是一句中文。
这些问题有个共同点——它们没有一个属于“翻译”。按钮撑歪是布局问题,日期格式是数据格式化问题,接口错误是前后端契约问题。那天我算是彻底认清一件事:国际化本质上是工程能力,翻译只是其中最显眼、但占比最小的一部分。文案抽成 JSON 那一步反而是最简单的。
文案 key 不能拿中文当标识
最简单的语言包,很多人第一版会写成这样:
1{ 2 "保存": "Save", 3 "取消": "Cancel" 4}
拿中文原文当 key,看着直观,但埋了一个雷:哪天产品把“保存”改成“保存并提交”,key 也跟着变,历史里所有引用这个 key 的地方、所有已有语言的翻译全部失效。key 应该是稳定的语义标识,文案本身可以随时改。
我们组现在按业务域组织 key:
1{ 2 "order.list.title": "订单列表", 3 "order.list.empty": "暂无订单", 4 "order.detail.submitRefund": "提交退款" 5}
前缀带上模块,好处不只是避免不同页面的 title、submit、description 撞车,还能让语言包按模块拆文件、跟着功能代码一起维护。一个纯 flat 的 zh-CN.json 到几千行的时候,谁都不敢改。
还有个反直觉的点:别为了“复用”把同一个 key 塞到多个语境里共用。中文里“确定”哪儿都能用,但英文里对话框的确定是 OK、表单提交的确定可能是 Confirm、删除确认又该是 Delete。key 一旦被多处复用,翻译就被迫用一个词兼顾所有语境,哪个都不贴切。宁可多几个语义明确的 key,也别图省事共用。
别在代码里拼句子
中文里我们习惯这么写:
1`共 ${count} 个订单`
放到多语言里就是灾难。不同语言语序不一样,把句子拆成 共 + 数字 + 个订单 三段去拼,换成英文语序全乱。更麻烦的是复数——英文有单复数,1 order 和 2 orders,你要是写 count > 1 ? 'orders' : 'order',遇到俄语、阿拉伯语这种有三四种复数形式的语言直接失效。
正确的做法是把整句交给国际化库,用 ICU MessageFormat 描述复数:
1{ 2 "order.count": "{count, plural, one {# order} other {# orders}}" 3}
如果不想依赖库来算复数分支,浏览器其实原生给了 Intl.PluralRules,能告诉你某个数字在某语言下落在哪个复数类别:
1const pr = new Intl.PluralRules('en-US') 2pr.select(1) // 'one' 3pr.select(2) // 'other' 4 5new Intl.PluralRules('ru-RU').select(2) // 'few'
这东西的价值在于:你永远不该自己去记“哪个语言有几种复数”,交给平台。
变量插值也别自己做字符串替换。手写 str.replace('{count}', count) 看着能用,一旦文案里有嵌套变量、有需要按位置调换的占位符,或者变量本身含特殊字符,就会出各种边界问题。国际化库的插值机制会帮你处理这些,包括把用户内容当纯文本转义,避免顺手拼出一个 XSS。举个带命名占位符的例子:
1{ 2 "order.shipTo": "订单将寄往 {city},预计 {days} 天送达" 3}
1t('order.shipTo', { city: '上海', days: 3 })
占位符用命名而不是位置,翻译人员调整语序时就不用担心变量对错位。
日期、数字、货币一律走 Intl
手写日期格式是国际化里最容易埋雷的地方。YYYY-MM-DD 在美国人看是别扭的,货币符号、千分位、小数点符号各地区都不一样:
11,234.50 // en-US 21.234,50 // de-DE 3¥1,234 // ja-JP
这些规则你自己维护不完,全交给 Intl 才可靠:
1new Intl.DateTimeFormat(locale, { 2 year: 'numeric', 3 month: 'long', 4 day: 'numeric', 5}).format(date) 6 7new Intl.NumberFormat(locale, { 8 style: 'currency', 9 currency: 'USD', 10}).format(1234.5)
Intl 家族这几年补得很齐,除了日期数字,Intl.RelativeTimeFormat 能生成“3 天前”“in 2 hours”这类相对时间,也不用自己拼:
1const rtf = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' }) 2rtf.format(-1, 'day') // 'yesterday' 3rtf.format(3, 'day') // 'in 3 days'
排序也是个容易被忽略的角落。前端拿到一批名字直接 array.sort(),用的是字符串 Unicode 码点顺序,德语的变元音、中文的拼音顺序全乱套。正确的做法是用 Intl.Collator,它按语言习惯比较:
1const collator = new Intl.Collator('zh-CN') 2list.sort((a, b) => collator.compare(a.name, b.name))
把“甲乙丙”按拼音排、把带重音的字母归到对的位置,这些规则你自己写不出来,也不该写。同一族的 Intl.ListFormat 还能把数组拼成符合语言习惯的枚举句(A、B 和 C 对 A, B, and C),也免得自己拼顿号。
时区是另一个更隐蔽的坑,比文案更容易出事。后端返回的到底是 UTC 还是本地时间?用户看到的应该按账号时区、浏览器时区,还是业务地区时区?我们订单系统就吃过亏:一个跨时区的下单时间,前端直接 new Date() 按浏览器本地渲染,海外客服和国内运营看到的时间差了好几个小时,对账时对不上。后来统一约定后端一律给 UTC ISO 字符串,前端拿 Intl.DateTimeFormat 带 timeZone 显式格式化,才把这条捋顺。
布局要给长文案留活路
英文通常比中文长,德语更夸张,一个“设置”能翻成 Einstellungen。按钮宽度写死是重灾区:
1.button { 2 width: 80px; /* 中文够用,换语言就爆 */ 3}
改成弹性的:
1.button { 2 min-width: 80px; 3 padding: 0 16px; 4}
表格、卡片、导航尤其要拿接近真实长度的翻译去测,别只用 Save、OK 这种短词自欺欺人。我们内部有个土办法:做视觉走查时故意把语言切到德语,德语能撑住的布局,其它语言基本都稳。
还有一条容易忘——别用背景图承载文字。图片里的文案没法自动翻译,也不利于 SEO 和可访问性,换语言时它就是块死内容。
截断也要小心。中文里一句话超长了,用 text-overflow: ellipsis 截一下没问题,但有些语言的单词本身就很长,硬截可能截在词中间,或者关键信息被吃掉。多语言场景下,能换行就别截断,非要截也要给 title 属性让用户能看到全文,别为了对齐好看牺牲可读性。表格列宽尤其如此,一列固定宽度在中文下清清爽爽,换德语可能每一格都是省略号。
RTL 不是把 text-align 反一下
如果要支持阿拉伯语、希伯来语,就绕不开从右往左的书写方向。很多人以为把文字右对齐就完事,其实整个布局镜像都得跟着翻。
先在根节点声明方向:
1<html dir="rtl" lang="ar">
CSS 里尽量用逻辑属性,让它跟着书写方向自动映射,而不是写死物理方向:
1/* 推荐 */ 2.card { 3 padding-inline-start: 16px; 4 margin-inline-end: 8px; 5} 6 7/* 少写这种,RTL 下不会自动翻转 */ 8.card { 9 padding-left: 16px; 10 margin-right: 8px; 11}
逻辑属性这几年浏览器支持已经很稳,inline-start/inline-end、margin-inline、inset-inline 这一套用起来没心理负担。除了间距,布局方向、面包屑、进度条、轮播的箭头方向都要检查。图标要分类看:品牌 logo 不翻,但表示“下一步”“返回”的方向性箭头通常得翻。
路由和 SEO 得提前设计,不能后补
多语言站点的路由方案基本两种,一种带语言前缀:
1/zh-CN/posts 2/en-US/posts
一种用子域名:
1example.com 2en.example.com
无论哪种,有一批东西必须一开始就想清楚:默认语言怎么跳转、怎么记住用户偏好、搜索引擎的 hreflang 怎么声明、sitemap 和 canonical 怎么带语言维度、CDN 缓存 key 是否包含语言。
hreflang 尤其容易漏。搜索引擎靠它知道同一内容有哪些语言版本,声明不全会导致英文用户搜出中文页:
1<link rel="alternate" hreflang="zh-CN" href="https://example.com/zh-CN/posts" /> 2<link rel="alternate" hreflang="en-US" href="https://example.com/en-US/posts" /> 3<link rel="alternate" hreflang="x-default" href="https://example.com/posts" />
服务端渲染时语言必须参与缓存,否则会串——中文用户命中了别人留在 CDN 里的英文 HTML,这种 bug 排查起来极其痛苦,因为它只在缓存命中时偶现。我们把 Accept-Language 和路由里的 locale 一起纳进缓存 key,才把这条堵死。
语言包要能持续维护,别等上线前突击补
国际化项目最怕的不是启动,是维护。功能一直在加,新文案一直在冒,语言包很容易变成没人负责的孤儿。
关键是让“缺翻译”这件事在开发或 CI 阶段就暴露出来,而不是等上线后页面直接把 key 显示给用户看:
1Missing translation: order.detail.refundReason
我们在 CI 里加了一步校验:拿 zh-CN 当基准,扫所有其它语言文件缺哪些 key,缺了就让流水线红。再配上开发环境下一个 missing 钩子,本地跑到没翻译的 key 会直接告警,不至于混过 review。
翻译也得带上下文。只丢给翻译一个 submit,他根本不知道是“提交订单”还是“提交审批”,两个动作在别的语言里可能是不同的词。key 的语义命名、配套截图、字段说明都帮得上忙。我们现在的约定是:新增文案跟功能代码同一个 PR 提交,绝不留到上线前统一补——那个时间点最容易漏、最容易糊弄。
语言包体积也别忽视。全量语言包塞进首屏 bundle,用户为了看中文界面却下载了七八种语言的文案。更合理的做法是按 locale 拆包、按需异步加载:
1async function loadMessages(locale: string) { 2 const mod = await import(`./locales/${locale}.json`) 3 return mod.default 4}
只加载当前语言,切语言时再动态拉对应的包,首屏能省下不少体积。
API 错误也要国际化
接口错误是国际化里最常被忘掉的一环。后端图省事,直接返回一句拼好的中文让前端展示:
1{ "message": "订单不存在" }
英文用户看到中文错误,前功尽弃。更好的契约是后端只返回稳定的错误码,人类可读的文案由前端按语言映射:
1{ 2 "code": "ORDER_NOT_FOUND", 3 "message": "Order not found" 4}
前端拿 code 去查本地文案,带上兜底:
1const errorMessage = t(`errors.${error.code}`, { 2 defaultValue: t('errors.UNKNOWN'), 3})
这样同一个错误能按用户语言呈现,后端返回的 message 只当日志兜底,不进入国际化主路径。这条边界一旦模糊,前端就得为每个后端返回的句子去做翻译,根本维护不过来。
收尾
英文版最终是切出去了。那天顶歪的表头、串了时区的下单时间,问题都不在“翻译”这一层——它们是布局没留够弹性、日期数字没交给 Intl、错误码和文案没在契约里分开、缓存 key 没算上语言,这几件事没提前做。翻译只是国际化里最显眼的一小块,切完这版我们最先补的也不是文案,而是把这几处漏洞一处一处堵上。