学完这篇,你能把前端多语言从「字符串拼接 + if 判断」升级成一套可维护的规范:数值、日期、货币交给 Intl API 格式化,句子的形态(单复数、性别、语序)交给 ICU 消息格式,改文案不用改代码。
第一步:先把「值格式化」和「句子形态」拆开
很多人 i18n 写崩,是因为把两件事混在模板字符串里。记住分工:
- Intl API 管值:数字、货币、日期、相对时间、列表连接词。
- ICU 消息格式管句法:一个句子在不同语言下哪里放变量、变量有几个形态。
const nf = new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' });
nf.format(1234.5); // "¥1,234.50"
const dt = new Intl.DateTimeFormat('ja-JP', { dateStyle: 'long' });
dt.format(new Date()); // "2025年3月8日"
别自己写 `'¥' + n.toFixed(2)`——千分位、阿拉伯语数字、货币符号位置全由 locale 决定。
第二步:确定消息 key 与文件结构
npm i @formatjs/intl intl-messageformat
npx formatjs extract "src/**/*.{ts,tsx}" --out-file lang/zh-CN.json
用点分层级的英文 key,不要拿中文原文当 key,否则一个错别字就得全库替换。
{
"cart.items": "{count, plural, =0 {购物车是空的} other {购物车里有 # 件商品}}",
"user.greeting": "{gender, select, male {先生} female {女士} other {朋友}},欢迎回来"
}
第三步:用 ICU 语法写复数与选择
三种最常用的:
- `{count, plural, ...}` —— 按数量形态选分支,`#` 代表当前数值。
- `{n, selectordinal, ...}` —— 序数(第 1、第 2、第 3)。
- `{gender, select, ...}` —— 按枚举值选分支。
`=0`、`=1` 是精确匹配,`other` 分支必须写,否则运行时报错。
注意:永远不要手写 `n > 1 ? '条' : '条'` 这种判断。英语只有 one/other,俄语有 one/few/many/other,阿拉伯语有 zero/one/two/few/many/other,硬编码必然翻车。
第四步:运行时格式化,并缓存 Intl 实例
import { createIntl, createIntlCache } from '@formatjs/intl';
const cache = createIntlCache(); // 关键:缓存 Intl 实例
const intl = createIntl(
{
locale: 'zh-CN',
defaultLocale: 'zh-CN',
timeZone: 'Asia/Shanghai',
messages,
onError: (e) => console.warn(e.code, e.id), // 缺翻译时别静默吞掉
},
cache
);
intl.formatMessage({ id: 'cart.items' }, { count: 3 });
注意:`new Intl.NumberFormat()` 创建开销很大,在列表渲染里每次 new 会明显掉帧,一定要用 `createIntlCache` 或自己维护 Map 缓存。
第五步:做语言协商与兜底链
用户浏览器语言不一定在支持列表里,用 `supportedLocalesOf` 做 best-fit 匹配,再逐级回落:
function pickLocale(supported, requested) {
const hit = Intl.NumberFormat.supportedLocalesOf(requested, { localeMatcher: 'best fit' })[0];
return hit || supported[0];
}
const locale = pickLocale(['zh-CN', 'en-US', 'ja-JP'], navigator.languages);
顺序建议:URL 参数 → 用户设置 → `navigator.languages` → 站点默认。URL 参数必须优先,否则分享出去的链接别人看到的语言和自己不一样。
第六步:补 polyfill,并在构建期体检
npm i @formatjs/intl-pluralrules @formatjs/intl-numberformat @formatjs/intl-datetimeformat
入口文件按需引入对应 locale 数据:
import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-pluralrules/locale-data/zh';
同时把编译放进 CI:
npx formatjs compile lang/zh-CN.json --out-file lang/compiled/zh-CN.json
npx formatjs extract "src/**/*.tsx" --out-file /tmp/check.json --throws
`--throws` 能让「代码里用了某个 id 但语言包里没有」直接让流水线失败,比上线后看到 `cart.items` 裸奔强得多。
注意:老版本 Safari 和部分 Node 环境缺少 locale 数据,`Intl.NumberFormat` 会静默退回 en-US,不报错但输出全错,这类问题只能靠 polyfill 加真机测试发现。
第七步:日期一律显式指定时区
new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeZone: 'Asia/Shanghai' }).format(ts);
服务器时间戳是对的,但用户在日本打开就变成另一天。要么统一按站点时区展示,要么明确标注「本地时间」。
小结
- 值格式化交给 `Intl.NumberFormat / DateTimeFormat / RelativeTimeFormat / ListFormat`,别手拼符号。
- 句法交给 ICU:`plural` 处理数量,`select` 处理枚举,`other` 分支不能少。
- Intl 实例必须缓存,否则列表渲染性能会塌。
- 语言协商顺序:URL → 用户设置 → 浏览器 → 默认。
- 旧环境加 polyfill,构建期用 `--throws` 卡住缺失翻译。
- 日期显式指定 `timeZone`,不依赖用户机器。