React 国际化 (i18n) 最佳实践
React 国际化 (i18n) 最佳实践
国际化(Internationalization,简称 i18n)是构建全球应用的重要环节。一个优秀的国际化方案不仅要支持多语言翻译,还需要处理日期时间格式、数字格式、货币符号、复数规则、文字方向等复杂问题。React 提供了多种国际化方案,选择合适的方案对项目的可维护性和用户体验至关重要。
国际化库对比
i18next - 功能最全面的国际化方案:
// i18next 配置示例
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend';
i18n
.use(HttpBackend) // 从服务器加载翻译文件
.use(LanguageDetector) // 检测用户语言
.use(initReactI18next) // 绑定 react-i18next
.init({
fallbackLng: 'en', // 默认语言
supportedLngs: ['en', 'zh', 'ja', 'ko'],
ns: ['common', 'home', 'about'], // 命名空间
defaultNS: 'common',
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json', // 翻译文件路径
},
detection: {
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
caches: ['cookie', 'localStorage'],
},
interpolation: {
escapeValue: false, // React 已经处理 XSS
},
react: {
useSuspense: true, // 使用 Suspense 加载翻译
},
});
export default i18n;react-intl - Airbnb 出品的国际化方案:
// react-intl 配置示例
import { IntlProvider, FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl';
const messages = {
en: {
greeting: 'Hello, {name}!',
items: '{count, plural, one {# item} other {# items}}',
income: '{gender, select, male {He earns} female {She earns} other {They earn}} {amount}',
},
zh: {
greeting: '你好,{name}!',
items: '{count} 个项目',
income: '{gender, select, male {他赚} female {她赚} other {他们赚}} {amount}',
},
};
function App() {
const [locale, setLocale] = useState('en');
return (
<IntlProvider locale={locale} messages={messages[locale]}>
<div>
<FormattedMessage id="greeting" values={{ name: 'Alice' }} />
<FormattedNumber value={1234.56} style="currency" currency="USD" />
<FormattedDate value={new Date()} year="numeric" month="long" day="numeric" />
</div>
</IntlProvider>
);
}Format.js - 底层格式化工具集:
实现策略
消息管理最佳实践:
// 翻译文件结构示例
// /locales/en/common.json
{
"welcome": "Welcome to our app",
"navigation": {
"home": "Home",
"about": "About",
"contact": "Contact"
},
"errors": {
"required": "This field is required",
"invalidEmail": "Please enter a valid email address"
}
}
// /locales/zh/common.json
{
"welcome": "欢迎使用我们的应用",
"navigation": {
"home": "首页",
"about": "关于我们",
"contact": "联系我们"
},
"errors": {
"required": "此字段为必填项",
"invalidEmail": "请输入有效的电子邮件地址"
}
}
// 组件中使用翻译
import { useTranslation } from 'react-i18next';
function Navigation() {
const { t } = useTranslation('common');
return (
<nav>
<Link to="/">{t('navigation.home')}</Link>
<Link to="/about">{t('navigation.about')}</Link>
<Link to="/contact">{t('navigation.contact')}</Link>
</nav>
);
}语言检测策略:
// 语言切换组件
import { useTranslation } from 'react-i18next';
function LanguageSwitcher() {
const { i18n } = useTranslation();
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng);
// 保存用户偏好
localStorage.setItem('preferredLanguage', lng);
// 更新 URL 参数
const url = new URL(window.location.href);
url.searchParams.set('lang', lng);
window.history.replaceState({}, '', url.toString());
};
return (
<div className="language-switcher">
<button
onClick={() => changeLanguage('en')}
className={i18n.language === 'en' ? 'active' : ''}
>
English
</button>
<button
onClick={() => changeLanguage('zh')}
className={i18n.language === 'zh' ? 'active' : ''}
>
中文
</button>
</div>
);
}文本提取与翻译工作流:
// i18next-scanner 配置
module.exports = {
input: ['src/**/*.{js,jsx,ts,tsx}'],
output: 'public/locales',
options: {
debug: true,
sort: true,
func: {
list: ['t', 'i18n.t'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
trans: {
component: 'Trans',
i18nKey: 'i18nKey',
defaultsKey: 'defaults',
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
lngs: ['en', 'zh', 'ja'],
ns: ['common', 'home'],
defaultLng: 'en',
defaultNs: 'common',
},
};性能优化
懒加载翻译文件:
// 懒加载翻译配置
i18n
.use(HttpBackend)
.use(initReactI18next)
.init({
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
// 只加载需要的命名空间
partialBundledLanguages: true,
// 预加载常用语言
preload: ['en'],
});
// 动态加载命名空间
function AdminPanel() {
const { t } = useTranslation('admin', { useSuspense: true });
return <div>{t('title')}</div>;
}
// 使用 Suspense 处理加载状态
function App() {
return (
<Suspense fallback={<Loading />}>
<AdminPanel />
</Suspense>
);
}缓存翻译文件:
// 使用 localStorage 缓存
const localStorageBackend = {
type: 'localStorage',
prefix: 'i18next_',
expirationTime: 7 * 24 * 60 * 60 * 1000, // 7天
};
i18n.use(initReactI18next).init({
backend: {
backends: [
localStorageBackend, // 优先从缓存读取
HttpBackend, // 缓存未命中时从服务器加载
],
},
});优化渲染性能:
// 优化前:每次渲染都创建新对象
function UserCard({ user }) {
const { t } = useTranslation();
return (
<div>
<h2>{t('user.greeting', { name: user.name })}</h2>
<p>{t('user.role', { role: user.role })}</p>
</div>
);
}
// 优化后:使用 memo 和 useMemo
const UserCard = memo(function UserCard({ user }) {
const { t } = useTranslation();
const values = useMemo(() => ({ name: user.name }), [user.name]);
return (
<div>
<h2>{t('user.greeting', values)}</h2>
<p>{t('user.role', { role: user.role })}</p>
</div>
);
});最佳实践
组件化翻译:
// 封装翻译组件
interface TranslatedTextProps {
id: string;
values?: Record<string, string | number>;
defaultValue?: string;
}
const TranslatedText: React.FC<TranslatedTextProps> = ({
id,
values,
defaultValue
}) => {
const { t } = useTranslation();
return <>{t(id, values, { defaultValue })}</>;
};
// 使用示例
<TranslatedText id="welcome.message" values={{ name: 'Alice' }} />占位符和插值:
// 插值示例
// en.json
{
"greeting": "Hello, {{name}}!",
"items": "{{count}} item",
"items_plural": "{{count}} items"
}
// zh.json
{
"greeting": "你好,{{name}}!",
"items": "{{count}} 个项目"
}
// 使用
t('greeting', { name: 'Alice' }) // "Hello, Alice!" / "你好,Alice!"
t('items', { count: 1 }) // "1 item" / "1 个项目"
t('items', { count: 5 }) // "5 items" / "5 个项目"日期和时间本地化:
// 使用 Intl API 格式化日期
const formatDate = (date: Date, locale: string) => {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
}).format(date);
};
// en: "Saturday, March 15, 2026"
// zh: "2026年3月15日星期六"
// 使用 react-intl
import { FormattedDate, FormattedRelativeTime } from 'react-intl';
<FormattedDate
value={new Date()}
year="numeric"
month="long"
day="numeric"
/>
<FormattedRelativeTime
value={-5}
unit="minute"
numeric="auto"
/>
// "5 minutes ago" / "5分钟前"数字和货币本地化:
// 使用 Intl API 格式化数字
const formatNumber = (number: number, locale: string) => {
return new Intl.NumberFormat(locale).format(number);
};
// en: "1,234,567.89"
// zh: "1,234,567.89"
// de: "1.234.567,89"
// 格式化货币
const formatCurrency = (amount: number, locale: string, currency: string) => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
}).format(amount);
};
// en-US, USD: "$1,234.56"
// zh-CN, CNY: "¥1,234.56"
// ja-JP, JPY: "¥1,235"RTL(从右到左)布局支持:
// RTL 支持
function App() {
const { i18n } = useTranslation();
const isRTL = ['ar', 'he', 'fa'].includes(i18n.language);
return (
<div dir={isRTL ? 'rtl' : 'ltr'} className={isRTL ? 'rtl' : 'ltr'}>
{/* 内容 */}
</div>
);
}
// CSS 逻辑属性
.card {
padding-inline-start: 16px; /* LTR: padding-left, RTL: padding-right */
margin-inline-end: 8px;
border-start-start-radius: 8px; /* LTR: top-left, RTL: top-right */
}测试国际化:
// 国际化测试示例
import { render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';
describe('Navigation', () => {
it('renders in English', () => {
i18n.changeLanguage('en');
render(
<I18nextProvider i18n={i18n}>
<Navigation />
</I18nextProvider>
);
expect(screen.getByText('Home')).toBeInTheDocument();
});
it('renders in Chinese', () => {
i18n.changeLanguage('zh');
render(
<I18nextProvider i18n={i18n}>
<Navigation />
</I18nextProvider>
);
expect(screen.getByText('首页')).toBeInTheDocument();
});
});i18next 完整进阶
前面只覆盖了 i18next 的基础配置。真实项目里,i18next 的威力体现在命名空间、上下文(context)、嵌套翻译、复数进阶、ICU 插件、Trans 组件、以及插值格式化这些高级能力上。下面逐一深入。
命名空间(Namespace):拆分与按需加载
概念:命名空间是把翻译按逻辑模块(而非语言)切分成多个独立文件的机制。一个语言目录下可以有 common.json、home.json、checkout.json 等多个文件,每个文件就是一个命名空间。
为什么重要:单个 JSON 翻译文件在中大型项目会膨胀到几千个 key、几百 KB。全部一次性加载会拖慢首屏。命名空间让你把翻译按路由或功能拆开,配合按需加载,首屏只下发真正用到的那一小部分。
某跨境电商实测数据:把单文件 380KB 的 zh.json 拆成 12 个命名空间后,首屏只加载 common + home 两个命名空间共 42KB,翻译文件对首屏体积的贡献从 380KB 降到 42KB,降幅 89%。
// 目录结构
// public/locales/
// ├── en/
// │ ├── common.json 通用文案:按钮、导航、错误
// │ ├── home.json 首页专属
// │ ├── checkout.json 结算流程专属
// │ └── account.json 账户中心专属
// └── zh/
// ├── common.json
// ├── home.json
// ├── checkout.json
// └── account.json
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import HttpBackend from 'i18next-http-backend';
i18n
.use(HttpBackend)
.use(initReactI18next)
.init({
fallbackLng: 'en',
ns: ['common'], // 初始只加载 common
defaultNS: 'common',
fallbackNS: 'common', // 某命名空间缺 key 时回退到 common
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
});在组件里按需请求命名空间,i18next 会自动异步加载对应文件:
import { useTranslation } from 'react-i18next';
// 结算页:声明它需要 checkout 命名空间
function CheckoutPage() {
// 第一个参数可以传数组,同时加载多个命名空间
const { t } = useTranslation(['checkout', 'common']);
return (
<div>
{/* 默认从第一个命名空间 checkout 取 */}
<h1>{t('checkout:title')}</h1>
{/* 显式指定 common 命名空间 */}
<button>{t('common:actions.confirm')}</button>
{/* 不写前缀时用默认命名空间 checkout */}
<p>{t('shipping.estimate')}</p>
</div>
);
}key 命名约定:推荐用 "命名空间:分组.具体key" 的三段式。命名空间对应功能模块,分组对应页面区块或语义类别(如 actions、errors、labels),具体 key 用小驼峰。避免把整句英文当 key(如 t('Please enter your email')),因为文案一改 key 就失效,且不利于多语言维护。
context:同一 key 的场景化变体
概念:context 让同一个逻辑 key 根据上下文参数产生不同翻译。最典型的是性别区分——很多语言里"朋友"这个词男女形式不同。
// de/common.json(德语,朋友分阳性/阴性)
{
"friend": "Freund",
"friend_male": "Freund",
"friend_female": "Freundin"
}t('friend'); // "Freund"(无 context 时用基础形式)
t('friend', { context: 'male' }); // "Freund"
t('friend', { context: 'female' }); // "Freundin"context 还能和复数组合,i18next 会拼出 "key_context_复数类别" 的完整 key:
{
"message_male_one": "他给你发了 {{count}} 条消息",
"message_male_other": "他给你发了 {{count}} 条消息",
"message_female_one": "她给你发了 {{count}} 条消息",
"message_female_other": "她给你发了 {{count}} 条消息"
}t('message', { context: 'female', count: 3 });
// "她给你发了 3 条消息"嵌套(nesting)与引用
概念:嵌套允许一条翻译引用另一条翻译,用 `$t(key)` 语法。适合复用品牌名、产品名等重复片段。
{
"appName": "极光云",
"welcome": "欢迎使用 $t(appName)",
"footer": "$t(appName) 版权所有 © 2026",
"nested": {
"deep": "这是 $t(appName) 的深层引用"
}
}t('welcome'); // "欢迎使用 极光云"
t('footer'); // "极光云 版权所有 © 2026"嵌套还能传参给被引用的 key:
{
"count_one": "{{count}} 项",
"count_other": "{{count}} 项",
"summary": "共 $t(count, {\"count\": {{total}} })"
}复数进阶:i18next 的复数机制
i18next v21 起使用 Intl.PluralRules 的 CLDR 复数类别(zero/one/two/few/many/other)。key 的后缀直接对应类别名,而不再是老版本的 _plural:
// en:英语只有 one / other
{
"cart_one": "购物车里有 {{count}} 件商品",
"cart_other": "购物车里有 {{count}} 件商品"
}
// ar:阿拉伯语有全部 6 种类别
{
"cart_zero": "السلة فارغة",
"cart_one": "سلعة واحدة في السلة",
"cart_two": "سلعتان في السلة",
"cart_few": "{{count}} سلع في السلة",
"cart_many": "{{count}} سلعة في السلة",
"cart_other": "{{count}} سلعة في السلة"
}t('cart', { count: 0 }); // ar → "السلة فارغة"
t('cart', { count: 1 }); // ar → "سلعة واحدة في السلة"
t('cart', { count: 2 }); // ar → "سلعتان في السلة"
t('cart', { count: 5 }); // ar → few 分支
t('cart', { count: 15 }); // ar → many 分支常见坑:中文、日文、韩文没有复数变化,只有 other 一个类别。给这些语言写 _one/_other 是无害的(都指向 other),但不要以为写了 _one 中文就会用——它永远走 other。
ICU 插件:i18next-icu
概念:i18next 原生插值语法({{var}}、_one/_other 后缀)比较简单,处理复杂的嵌套复数、select、内联格式化时不如 ICU MessageFormat 强大。i18next-icu 插件让 i18next 直接支持 ICU 语法,与 react-intl 的消息格式打通。
import i18n from 'i18next';
import ICU from 'i18next-icu';
import { initReactI18next } from 'react-i18next';
i18n
.use(ICU) // 启用 ICU 解析
.use(initReactI18next)
.init({
fallbackLng: 'en',
});启用后翻译文件可以直接写 ICU 语法:
{
"notification": "{count, plural, =0 {没有新通知} one {有 # 条新通知} other {有 # 条新通知}}",
"invite": "{gender, select, male {他邀请了你} female {她邀请了你} other {对方邀请了你}}",
"price": "价格:{amount, number, ::currency/CNY}"
}t('notification', { count: 0 }); // "没有新通知"
t('notification', { count: 5 }); // "有 5 条新通知"
t('invite', { gender: 'female' }); // "她邀请了你"
t('price', { amount: 99.9 }); // "价格:¥99.90"Trans 组件:处理富文本与嵌套标签
概念:当翻译内容里包含 HTML 标签或 React 组件(如链接、加粗、图标)时,纯 t() 无法安全地插入组件。react-i18next 的 Trans 组件专门解决这个问题——它把翻译字符串里的占位标签映射回真实 React 元素,避免字符串拼接和 dangerouslySetInnerHTML。
为什么重要:新手常写 ` {t('agree')} {t('terms')}
// en
{
"agreement": "我已阅读并同意 <1>服务条款</1> 和 <3>隐私政策</3>",
"welcome": "欢迎,<bold>{{name}}</bold>!你有 <link>3 条未读消息</link>"
}import { Trans } from 'react-i18next';
function TermsCheckbox() {
return (
<label>
<input type="checkbox" />
{/* 数字索引对应子元素顺序:<1> 是第 2 个子节点 <a> */}
<Trans i18nKey="agreement">
我已阅读并同意 <a href="/terms">服务条款</a> 和 <a href="/privacy">隐私政策</a>
</Trans>
</label>
);
}
// 也可以用具名标签(更可读),通过 components 映射
function Welcome({ name }: { name: string }) {
return (
<Trans
i18nKey="welcome"
values={{ name }}
components={{
bold: <strong className="font-bold" />,
link: <a href="/messages" className="text-blue-500" />,
}}
/>
);
}Trans 的坑:i18nKey 对应的翻译里,标签索引必须和 JSX 子节点顺序严格一致。若用自闭合组件(如 `
`)也会占一个索引位。推荐优先用具名标签 + components 映射,比数字索引更抗重构。
interpolation 格式化:内联 formatter
概念:插值时可以对变量做格式化,语法是 `{{value, formatName}}`。i18next v21+ 内置基于 Intl 的 number、currency、datetime、relativetime、list 等格式化器。
{
"stat": "总销售额 {{amount, currency}},共 {{orders, number}} 单",
"signup": "注册于 {{date, datetime}}",
"tags": "标签:{{items, list}}"
}i18n.init({
fallbackLng: 'zh',
interpolation: {
escapeValue: false,
},
});
t('stat', {
amount: 128900,
orders: 342,
formatParams: {
amount: { currency: 'CNY' }, // 传给 Intl.NumberFormat 的选项
},
});
// "总销售额 ¥128,900.00,共 342 单"
t('signup', {
date: new Date('2026-08-03'),
formatParams: {
date: { year: 'numeric', month: 'long', day: 'numeric' },
},
});
// "注册于 2026年8月3日"也可以注册自定义 formatter,处理项目特有的格式(如手机号打码):
i18n.services.formatter.add('maskPhone', (value) => {
return String(value).replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
});
// 翻译文件:{ "profile": "手机号:{{phone, maskPhone}}" }
t('profile', { phone: '13812345678' });
// "手机号:138****5678"react-intl 深入
react-intl 属于 FormatJS 生态,核心理念是"消息即数据"——所有文案通过 defineMessages 声明成带 id/defaultMessage 的对象,配合编译期提取工具形成完整工作流。它对 ICU MessageFormat 的支持是一等公民,适合格式化需求重的应用。
defineMessages 与消息定义
概念:defineMessages 把散落在组件里的文案集中声明为消息描述符对象。它本身不做翻译,但为提取工具(@formatjs/cli)提供了可静态分析的锚点。
import { defineMessages } from 'react-intl';
// 推荐把消息定义放在组件文件顶部或单独的 messages.ts
export const messages = defineMessages({
cartTitle: {
id: 'cart.title',
defaultMessage: '购物车',
description: '购物车页面标题,给译员的说明', // description 只给译员看
},
itemCount: {
id: 'cart.itemCount',
defaultMessage: '{count, plural, one {# 件商品} other {# 件商品}}',
},
checkout: {
id: 'cart.checkout',
defaultMessage: '去结算',
},
});useIntl 与 intl.formatMessage
概念:useIntl 是访问 intl 对象的 Hook,intl.formatMessage 是核心翻译函数。相比组件式的 FormattedMessage,命令式 API 更适合在属性、aria-label、title 等非 JSX 子节点位置使用。
import { useIntl } from 'react-intl';
import { messages } from './messages';
function CartHeader({ count }: { count: number }) {
const intl = useIntl();
return (
<header>
{/* 命令式:适合放进 title/placeholder/aria-label */}
<h1 title={intl.formatMessage(messages.cartTitle)}>
{intl.formatMessage(messages.cartTitle)}
</h1>
{/* 带参数的复数消息 */}
<span aria-label={intl.formatMessage(messages.itemCount, { count })}>
{intl.formatMessage(messages.itemCount, { count })}
</span>
<button aria-label={intl.formatMessage(messages.checkout)}>
{intl.formatMessage(messages.checkout)}
</button>
</header>
);
}组件式写法(等价,适合直接渲染的场景):
import { FormattedMessage } from 'react-intl';
function CartHeaderJSX({ count }: { count: number }) {
return (
<header>
<h1><FormattedMessage {...messages.cartTitle} /></h1>
<FormattedMessage {...messages.itemCount} values={{ count }} />
</header>
);
}FormattedList 与 FormattedPlural
FormattedList 基于 Intl.ListFormat,正确处理不同语言的列表连接词(英语的 "A, B, and C"、中文的顿号):
import { FormattedList, FormattedPlural } from 'react-intl';
function TagList({ tags }: { tags: string[] }) {
return (
<FormattedList type="conjunction" value={tags} />
);
}
// en: "React, Vue, and Angular"
// zh: "React、Vue和Angular"
// type="disjunction" 表示"或"关系
// en: "React, Vue, or Angular"
function Notifications({ count }: { count: number }) {
return (
<span>
你有 <FormattedPlural
value={count}
one="1 条通知"
other={`${count} 条通知`}
/>
</span>
);
}消息提取:@formatjs/cli
概念:@formatjs/cli 扫描源码里的 defineMessages / FormattedMessage,提取出所有 id + defaultMessage,生成待翻译的 JSON。这是 react-intl 工作流的关键——文案在代码里就是源,提取后交给译员,翻译回来再编译。
# 提取所有消息到 lang/en.json
npx formatjs extract 'src/**/*.{ts,tsx}' \
--out-file lang/en.json \
--id-interpolation-pattern '[sha512:contenthash:base64:6]'
# 提取结果(en.json)
# {
# "cart.title": { "defaultMessage": "购物车", "description": "购物车页面标题" },
# "cart.itemCount": { "defaultMessage": "{count, plural, ...}" }
# }编译预处理:提升运行时性能
概念:ICU 消息字符串在运行时解析成 AST 有开销。@formatjs/cli 的 compile 命令把消息预编译成 AST 格式,运行时直接消费,跳过解析步骤。
# 把译员翻译好的 zh.json 编译成运行时格式
npx formatjs compile lang/zh.json --out-file compiled/zh.json
# 配合 babel-plugin-formatjs 在构建时移除 defaultMessage,
# 减小包体积(生产环境只保留 id)// babel.config.js
module.exports = {
plugins: [
['formatjs', {
idInterpolationPattern: '[sha512:contenthash:base64:6]',
ast: true, // 编译成 AST,运行时零解析开销
}],
],
};某金融 SaaS 实测:启用 ast: true 编译后,含 1200 条 ICU 消息的应用,消息格式化的运行时耗时从首次渲染 38ms 降到 11ms,降幅 71%,因为跳过了运行时的 ICU 词法/语法分析。
Next.js 国际化
Next.js App Router 的国际化和纯客户端 SPA 完全不同。翻译需要在服务端组件(RSC)里可用、要处理 SSR/SSG 的语言选择、要做 SEO 层面的 hreflang 和 metadata 本地化。next-intl 是目前 App Router 下最成熟的方案。
App Router 的 [locale] 动态段
概念:App Router 用文件系统路由。国际化的标准做法是把整个 app 目录套进一个 [locale] 动态段,让语言成为 URL 路径的第一段(/en/about、/zh/about),这对 SEO 和分享链接最友好。
// 目录结构
// app/
// ├── [locale]/
// │ ├── layout.tsx 根布局,设置 lang 和 dir
// │ ├── page.tsx 首页
// │ ├── about/page.tsx
// │ └── checkout/page.tsx
// ├── i18n/
// │ ├── routing.ts 路由配置
// │ └── request.ts 每请求的翻译加载
// └── middleware.ts 语言检测与重定向
// app/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'zh', 'ja', 'ar'],
defaultLocale: 'en',
// 默认语言是否也带前缀。'as-needed' 表示默认语言不带前缀(/about),
// 其他语言带前缀(/zh/about)
localePrefix: 'as-needed',
});middleware 语言重定向
概念:middleware 在请求到达页面前运行,负责根据 URL、cookie、Accept-Language 头决定用户语言,并在必要时重定向到带正确 locale 前缀的路径。
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './app/i18n/routing';
export default createMiddleware(routing);
export const config = {
// 匹配所有路径,排除 api、静态资源、_next
matcher: ['/((?!api|_next|_vercel|.*\..*).*)'],
};
// 用户访问 / 且浏览器语言是 zh-CN → 302 重定向到 /zh
// 用户访问 /about 且带 cookie NEXT_LOCALE=ja → 重定向到 /ja/aboutnext-intl 完整用法
概念:next-intl 提供 getTranslations(服务端)和 useTranslations(客户端)两套 API,让翻译在 RSC 和客户端组件里都能用,且服务端只序列化真正用到的消息,避免把整包翻译塞进客户端 bundle。
// app/i18n/request.ts —— 每个请求加载对应语言的消息
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
// 只加载该语言的消息文件
messages: (await import(`../../messages/${locale}.json`)).default,
};
});// app/[locale]/layout.tsx —— 根布局,注入 Provider
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '../i18n/routing';
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!routing.locales.includes(locale as any)) notFound();
// 服务端拿到该请求的全部消息,传给客户端 Provider
const messages = await getMessages();
const dir = ['ar', 'he', 'fa'].includes(locale) ? 'rtl' : 'ltr';
return (
<html lang={locale} dir={dir}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}SSR/SSG/RSC 下的翻译
服务端组件用 getTranslations,它是 async 的,翻译在服务端完成,HTML 直出,无客户端闪烁:
// app/[locale]/about/page.tsx —— 服务端组件(默认)
import { getTranslations } from 'next-intl/server';
export default async function AboutPage() {
// 服务端翻译,这段组件的 JS 不会进入客户端 bundle
const t = await getTranslations('about');
return (
<main>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
</main>
);
}客户端组件用 useTranslations Hook(需要 'use client'):
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
function Counter() {
const t = useTranslations('counter');
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((c) => c + 1)}>
{t('clicked', { count })}
</button>
);
}静态生成(SSG):为每个 locale 预生成静态页面,用 generateStaticParams:
import { routing } from '../i18n/routing';
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
// 构建时生成 /en、/zh、/ja、/ar 四套静态 HTMLSEO:hreflang 与 metadata 本地化
概念:多语言站点必须告诉搜索引擎"同一内容有哪些语言版本",用 hreflang 标签。否则 Google 可能把不同语言页面判定为重复内容,或给用户推错语言版本。metadata(title、description、og)也要按语言翻译。
// app/[locale]/about/page.tsx
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'about.meta' });
return {
title: t('title'),
description: t('description'),
alternates: {
canonical: `https://example.com/${locale}/about`,
// hreflang:列出所有语言版本
languages: {
en: 'https://example.com/en/about',
zh: 'https://example.com/zh/about',
ja: 'https://example.com/ja/about',
ar: 'https://example.com/ar/about',
'x-default': 'https://example.com/en/about',
},
},
openGraph: {
title: t('title'),
description: t('description'),
locale,
},
};
}某内容站点复盘:补齐 hreflang 后,非英语版本页面在对应语言区域的自然搜索曝光量 3 个月内提升 2.4 倍,因为 Google 不再把 /zh 判为 /en 的重复页而降权。
复数规则深入
复数是国际化最容易踩坑的地方,因为开发者往往以"英语只有单复数两种"的直觉去写,而世界上的语言复数规则千差万别。
CLDR 复数类别
概念:Unicode CLDR 定义了 6 个抽象复数类别:zero、one、two、few、many、other。每种语言把具体数字映射到这些类别的规则不同。other 是必选的兜底类别,其他都是可选的。
| 语言 | 拥有的复数类别 | 示例(n=商品数) |
| --- | --- | --- |
| 中文/日文/韩文 | other | 所有数字都用 other,无变化 |
| 英文 | one, other | 1 用 one,其余用 other |
| 法文 | one, many, other | 0 和 1 用 one |
| 俄文 | one, few, many, other | 复杂:看个位和十位 |
| 波兰文 | one, few, many, other | 规则又和俄文不同 |
| 阿拉伯文 | zero, one, two, few, many, other | 全部 6 种都用 |
| 威尔士文 | zero, one, two, few, many, other | 全部 6 种 |
Intl.PluralRules:判断数字属于哪个类别
概念:Intl.PluralRules 是浏览器内置 API,输入语言和数字,输出 CLDR 类别名。所有主流 i18n 库底层都用它。
// 英语
const en = new Intl.PluralRules('en');
en.select(0); // "other"
en.select(1); // "one"
en.select(2); // "other"
// 俄语的复杂性
const ru = new Intl.PluralRules('ru');
ru.select(1); // "one" (1, 21, 31...)
ru.select(2); // "few" (2-4, 22-24...)
ru.select(5); // "many" (5-20, 25-30...)
ru.select(11); // "many"
ru.select(21); // "one"
// 阿拉伯语
const ar = new Intl.PluralRules('ar');
ar.select(0); // "zero"
ar.select(1); // "one"
ar.select(2); // "two"
ar.select(3); // "few"
ar.select(11); // "many"
ar.select(100); // "other"
// 序数词(第 1、第 2)用 type: 'ordinal'
const enOrd = new Intl.PluralRules('en', { type: 'ordinal' });
enOrd.select(1); // "one" → 1st
enOrd.select(2); // "two" → 2nd
enOrd.select(3); // "few" → 3rd
enOrd.select(4); // "other" → 4th
enOrd.select(21); // "one" → 21st俄语/波兰语/阿拉伯语复数实战
俄语商品数的四类别完整写法:
// ru.json(ICU 语法)
{
"products": "{count, plural, one {# товар} few {# товара} many {# товаров} other {# товара}}"
}formatMessage(messages.products, { count: 1 }); // "1 товар"
formatMessage(messages.products, { count: 3 }); // "3 товара"
formatMessage(messages.products, { count: 5 }); // "5 товаров"
formatMessage(messages.products, { count: 21 }); // "21 товар"(21 走 one)关键教训:给俄语只写 one/other 两个分支是错误的——21 个商品会显示成 "21 товаров"(错,应为 "21 товар"),因为你没写 one 分支或漏了 few/many。翻译文件的复数分支必须由目标语言的母语译员按 CLDR 规则填全。
ICU MessageFormat 完整语法
概念:ICU MessageFormat 是一种在单条字符串里表达复数、性别、嵌套、格式化的 DSL,由 Unicode 组织制定。react-intl 原生支持,i18next 通过 i18next-icu 支持。掌握它就掌握了跨库通用的消息写法。
基础插值与选择(select)
简单插值: 你好,{name}!
select 性别: {gender, select, male {他} female {她} other {对方}}关注了你select 用于枚举分支(性别、类型、状态),other 必填:
{
"activity": "{type, select, like {点赞了} comment {评论了} share {分享了} other {操作了}}你的动态"
}plural 与 # 占位符
plural 分支里的 `#` 代表当前数字(会自动本地化格式)。`=数字` 可精确匹配特定值,优先于类别:
{
"likes": "{count, plural, =0 {还没有人点赞} =1 {你的好友点赞了} one {# 次点赞} other {# 次点赞}}"
}// count=0 → "还没有人点赞"(精确匹配 =0,不走 other)
// count=1 → "你的好友点赞了"(精确匹配 =1,不走 one)
// count=1000 → "1,000 次点赞"(# 自动加千分位)selectordinal:序数词
{
"rank": "你排名第 {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}"
}// place=1 → "第 1st",place=2 → "第 2nd",place=3 → "第 3rd",place=4 → "第 4th"嵌套:plural 里套 select
概念:真实文案常需要复数和性别同时变化。ICU 允许分支里嵌套另一个 select/plural,这是纯 i18next 后缀语法做不到的。
{
"invites": "{gender, select, male {他} female {她} other {他们}}邀请了 {count, plural, one {# 位朋友} other {# 位朋友}}"
}formatMessage(messages.invites, { gender: 'female', count: 3 });
// "她邀请了 3 位朋友"内联格式化:number、date、time
ICU 消息里可直接调用格式化器,语法 `{var, number, style}`:
{
"order": "订单 {id},金额 {total, number, ::currency/CNY},下单于 {date, date, long}",
"progress": "完成度 {ratio, number, ::percent}"
}formatMessage(messages.order, {
id: 'A1001',
total: 1299.5,
date: new Date('2026-08-03'),
});
// "订单 A1001,金额 ¥1,299.50,下单于 2026年8月3日"
formatMessage(messages.progress, { ratio: 0.856 });
// "完成度 85.6%"`::` 开头的是 ICU 的 skeleton 语法(number skeleton),比老式的参数写法更简洁强大,是目前推荐写法。
日期时间深入
日期时间是国际化里 bug 最多的领域,核心难点是时区和格式的双重变化。
时区处理的核心原则
原则:服务端和数据库统一用 UTC 存储,展示时才转成用户本地时区。绝不要在业务逻辑里用本地时间做计算。
// 后端返回 ISO 8601 带时区的字符串(永远带 Z 或偏移量)
const serverTime = '2026-08-03T14:30:00Z'; // UTC
// 展示时指定用户时区,Intl 自动换算
const formatInTimezone = (iso: string, locale: string, timeZone: string) => {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'full',
timeStyle: 'short',
timeZone,
}).format(new Date(iso));
};
formatInTimezone('2026-08-03T14:30:00Z', 'zh-CN', 'Asia/Shanghai');
// "2026年8月3日星期一 22:30"(UTC+8)
formatInTimezone('2026-08-03T14:30:00Z', 'en-US', 'America/New_York');
// "Monday, August 3, 2026 at 10:30 AM"(UTC-4,夏令时)Intl.DateTimeFormat 全部选项
const date = new Date('2026-08-03T14:30:45Z');
// 预设风格(推荐,自动适配语言习惯)
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full' }).format(date);
// "2026年8月3日星期一"
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'long' }).format(date);
// "2026年8月3日"
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium' }).format(date);
// "2026年8月3日"
new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short' }).format(date);
// "2026/8/3"
// 细粒度选项组合
new Intl.DateTimeFormat('en-US', {
weekday: 'long', // long | short | narrow
year: 'numeric', // numeric | 2-digit
month: 'long', // numeric | 2-digit | long | short | narrow
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true, // 12 小时制 vs 24 小时制
timeZoneName: 'short',// 显示时区缩写
timeZone: 'Asia/Tokyo',
}).format(date);
// 只要月和年(如账单周期)
new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: 'long' }).format(date);
// "2026年8月"
// 非公历:伊斯兰历、佛历、和历
new Intl.DateTimeFormat('zh-CN-u-ca-chinese').format(date); // 农历
new Intl.DateTimeFormat('ja-JP-u-ca-japanese').format(date); // 和历(令和)
new Intl.DateTimeFormat('ar-SA-u-ca-islamic').format(date); // 伊斯兰历Intl.RelativeTimeFormat:相对时间
概念:相对时间("3 天前"、"in 2 hours")的格式因语言而异,Intl.RelativeTimeFormat 内置处理。numeric: 'auto' 会在合适时用"昨天/明天"等自然词。
const rtf = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' });
rtf.format(-1, 'day'); // "昨天"(numeric auto)
rtf.format(-2, 'day'); // "前天"
rtf.format(1, 'day'); // "明天"
rtf.format(-3, 'hour'); // "3 小时前"
rtf.format(2, 'week'); // "2 周后"
const rtfEn = new Intl.RelativeTimeFormat('en', { numeric: 'always' });
rtfEn.format(-1, 'day'); // "1 day ago"(always 强制数字)
// 封装成"多久前"工具
function timeAgo(date: Date, locale: string): string {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const diffSec = (date.getTime() - Date.now()) / 1000;
const units: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 31536000], ['month', 2592000], ['day', 86400],
['hour', 3600], ['minute', 60], ['second', 1],
];
for (const [unit, sec] of units) {
if (Math.abs(diffSec) >= sec || unit === 'second') {
return rtf.format(Math.round(diffSec / sec), unit);
}
}
return '';
}Temporal API 展望
概念:Temporal 是 JS 新一代日期时间 API(已进入 Stage 3,2026 年多数环境需 polyfill),彻底解决了 Date 的时区混乱、可变性等历史问题。它区分 Instant(时间点)、PlainDate(无时区日期)、ZonedDateTime(带时区)等清晰类型。
// 需 @js-temporal/polyfill
import { Temporal } from '@js-temporal/polyfill';
// 明确的带时区时间
const zoned = Temporal.ZonedDateTime.from({
year: 2026, month: 8, day: 3, hour: 22, minute: 30,
timeZone: 'Asia/Shanghai',
});
zoned.toString(); // "2026-08-03T22:30:00+08:00[Asia/Shanghai]"
// 时区换算无歧义
const inTokyo = zoned.withTimeZone('Asia/Tokyo');
inTokyo.hour; // 23(东京比上海快 1 小时)
// 日期运算不可变、清晰
const nextWeek = Temporal.PlainDate.from('2026-08-03').add({ days: 7 });
nextWeek.toString(); // "2026-08-10"
// 与 Intl 配合
zoned.toLocaleString('zh-CN', { dateStyle: 'full', timeStyle: 'short' });Luxon 与 date-fns 本地化
在 Temporal 普及前,Luxon 和 date-fns 是主流选择:
// Luxon:内置时区和 i18n,API 现代
import { DateTime } from 'luxon';
DateTime.fromISO('2026-08-03T14:30:00Z')
.setZone('Asia/Shanghai')
.setLocale('zh')
.toLocaleString(DateTime.DATETIME_FULL);
// "2026年8月3日 GMT+8 22:30"
DateTime.now().setLocale('fr').toRelative(); // "il y a 2 heures"
// date-fns:函数式、可 tree-shaking,locale 需显式导入
import { format, formatDistance } from 'date-fns';
import { zhCN, enUS } from 'date-fns/locale';
format(new Date('2026-08-03'), 'PPPP', { locale: zhCN });
// "2026年8月3日星期一"
formatDistance(new Date('2026-08-01'), new Date('2026-08-03'), { locale: zhCN });
// "2 天"选型建议:新项目若目标环境支持,直接上 Temporal + Intl,零额外体积。需要广泛兼容且要时区计算选 Luxon(约 70KB)。只做格式化、极度在意体积选 date-fns(按需引入,单函数几 KB)。
数字、货币、单位、列表格式化
Intl.NumberFormat 的进阶用法
// 紧凑记数(notation: compact)——社交产品的点赞数
new Intl.NumberFormat('zh-CN', { notation: 'compact' }).format(12800);
// "1.3万"
new Intl.NumberFormat('en-US', { notation: 'compact' }).format(12800);
// "13K"
new Intl.NumberFormat('en-US', { notation: 'compact', compactDisplay: 'long' })
.format(1200000);
// "1.2 million"
// 单位(style: unit)
new Intl.NumberFormat('zh-CN', { style: 'unit', unit: 'kilometer-per-hour' })
.format(120);
// "120 公里/小时"
new Intl.NumberFormat('en-US', { style: 'unit', unit: 'megabyte', unitDisplay: 'narrow' })
.format(256);
// "256MB"
// 百分比
new Intl.NumberFormat('zh-CN', { style: 'percent', minimumFractionDigits: 1 })
.format(0.8567);
// "85.7%"
// 科学计数、工程计数
new Intl.NumberFormat('en', { notation: 'scientific' }).format(123456);
// "1.235E5"
new Intl.NumberFormat('en', { notation: 'engineering' }).format(123456);
// "123.456E3"
// 精度与符号控制
new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
signDisplay: 'exceptZero', // 正数也带 +
}).format(3.5);
// "+3.50"货币格式化的细节
// 不同货币的小数位由 ISO 4217 决定,Intl 自动处理
new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(1235);
// "¥1,235"(日元无小数)
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5);
// "$1,234.50"
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5);
// "1.234,50 €"(欧元符号在后,德语用点分组、逗号小数)
// 货币符号显示方式
new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'CNY', currencyDisplay: 'name',
}).format(100);
// "100.00 Chinese yuan"
new Intl.NumberFormat('en-US', {
style: 'currency', currency: 'CNY', currencyDisplay: 'code',
}).format(100);
// "CNY 100.00"关键坑:货币金额绝不能用浮点数做运算后直接格式化。0.1 + 0.2 在 JS 里等于 0.30000000000000004。金额应以最小单位(分)的整数存储和计算,展示时才除以 100 格式化,或用 decimal.js 等库。
Intl.ListFormat:列表连接
const items = ['苹果', '香蕉', '橙子'];
new Intl.ListFormat('zh-CN', { type: 'conjunction' }).format(items);
// "苹果、香蕉和橙子"
new Intl.ListFormat('en-US', { type: 'conjunction' }).format(items);
// "苹果, 香蕉, and 橙子"(连接词 and)
new Intl.ListFormat('en-US', { type: 'disjunction' }).format(['a', 'b', 'c']);
// "a, b, or c"(或 关系)
new Intl.ListFormat('en-US', { type: 'unit', style: 'narrow' }).format(['5h', '30m']);
// "5h 30m"Intl.Collator:本地化排序
概念:不同语言的字符排序规则不同(德语 ä 的位置、中文按拼音还是笔画)。直接用 Array.sort 的默认字典序会得到错误结果。Intl.Collator 提供符合语言习惯的比较。
// 默认字典序把中文按 Unicode 码点排,结果混乱
['张三', '李四', '王五'].sort(); // 按码点,非拼音序
// 用 Collator 按拼音排
const collator = new Intl.Collator('zh-CN');
['张三', '李四', '王五'].sort(collator.compare);
// ["李四", "王五", "张三"](按拼音 L < W < Z)
// 数字感知排序(file2 排在 file10 前)
const natural = new Intl.Collator('en', { numeric: true });
['file10', 'file2', 'file1'].sort(natural.compare);
// ["file1", "file2", "file10"]
// 忽略大小写和音标
new Intl.Collator('en', { sensitivity: 'base' }).compare('café', 'CAFE'); // 0(相等)Intl.Segmenter:分词
概念:中文、日文、泰文没有空格分词。Intl.Segmenter 按语言规则切分字素、词、句子,用于字数统计、截断、搜索高亮。
// 按词切分中文
const seg = new Intl.Segmenter('zh-CN', { granularity: 'word' });
const segments = [...seg.segment('我爱自然语言处理')];
segments.filter((s) => s.isWordLike).map((s) => s.segment);
// ["我", "爱", "自然", "语言", "处理"]
// 按字素切分(正确处理 emoji、组合字符)
const graphemeSeg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...graphemeSeg.segment('👨👩👧')].length; // 1(一个家庭 emoji 算一个字素)
'👨👩👧'.length; // 8(用 .length 会数错)RTL(从右到左)深入
阿拉伯语、希伯来语、波斯语、乌尔都语是 RTL 语言。全球 RTL 用户超过 5 亿。RTL 支持远不止 dir="rtl" 一行。
双向文本(bidi)
概念:RTL 语境里嵌入 LTR 内容(如英文单词、数字、URL)会产生双向文本,浏览器按 Unicode Bidirectional Algorithm 处理。多数情况自动正确,但用户生成内容拼接时可能错乱,需要用隔离字符或 CSS。
// 用户名可能是任意语言,嵌入 RTL 句子时用 dir="auto" 隔离
function Comment({ username, text }: { username: string; text: string }) {
return (
<p>
{/* dir="auto" 让浏览器自动检测每段方向,避免标点错位 */}
<bdi>{username}</bdi>:<span dir="auto">{text}</span>
</p>
);
}
// <bdi> 元素天然隔离双向文本,比手动插入 U+2068/U+2069 更简洁CSS 逻辑属性完整清单
概念:物理属性(left/right/top/bottom)在 RTL 下不会自动翻转。逻辑属性用 inline(行内,随文字方向)和 block(块向)替代,浏览器按 dir 自动映射,一套 CSS 同时适配 LTR 和 RTL。
| 物理属性 | 逻辑属性 | 说明 |
| --- | --- | --- |
| margin-left | margin-inline-start | 行首外边距 |
| margin-right | margin-inline-end | 行尾外边距 |
| padding-left | padding-inline-start | 行首内边距 |
| padding-right | padding-inline-end | 行尾内边距 |
| left | inset-inline-start | 行首定位 |
| right | inset-inline-end | 行尾定位 |
| text-align: left | text-align: start | 文本靠行首 |
| border-left | border-inline-start | 行首边框 |
| top-left 圆角 | border-start-start-radius | 块首行首圆角 |
| width | inline-size | 行向尺寸 |
| height | block-size | 块向尺寸 |
/* 一套代码同时适配 LTR 和 RTL */
.card {
padding-inline-start: 16px; /* LTR 左内边距 / RTL 右内边距 */
padding-inline-end: 8px;
margin-inline-start: auto; /* 靠行尾对齐 */
border-inline-start: 3px solid #3b82f6;
text-align: start;
border-start-start-radius: 8px;
}
/* 只在特定方向生效用 :dir() 伪类 */
.icon:dir(rtl) {
transform: scaleX(-1); /* RTL 下镜像图标 */
}图标镜像
概念:方向性图标(箭头、返回、进度、聊天气泡)在 RTL 下需要水平镜像;非方向性图标(搜索、设置、头像)不镜像。图标(勾选、加号)也不镜像。
/* 需要镜像的方向性图标 */
[dir='rtl'] .icon-back,
[dir='rtl'] .icon-arrow-right,
[dir='rtl'] .icon-send {
transform: scaleX(-1);
}
/* 数字、logo、播放键等绝不镜像 */方向切换实践与 Tailwind RTL
// Next.js 根布局按 locale 设置 dir
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'];
function getDir(locale: string): 'rtl' | 'ltr' {
return RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';
}
// <html lang={locale} dir={getDir(locale)}>Tailwind CSS v3.3+ 内置逻辑属性工具类和 rtl:/ltr: 变体:
<!-- ps-4 = padding-inline-start,me-2 = margin-inline-end,自动适配方向 -->
<div class="ps-4 pe-2 ms-auto text-start">
<!-- rtl: 变体只在 RTL 下应用 -->
<svg class="rtl:-scale-x-100" />
<span class="ms-2">继续</span>
</div>某中东市场 App 复盘:改造前用了 300+ 处 ml-/mr-/left-/right- 物理类,切阿拉伯语时布局全错。全量替换成 ms-/me-/ps-/pe- 逻辑类并给方向图标加 rtl:-scale-x-100 后,同一套组件同时支持 LTR/RTL,RTL 相关 bug 从 40+ 降到 3 个。
翻译工作流与协作
代码里写完 t('key') 只是开始。真正的挑战是如何让几十上百个 key、几种语言、多个译员协作起来不出错。
翻译管理平台:Crowdin / Lokalise / Tolgee
概念:翻译管理系统(TMS)提供在线编辑界面、翻译记忆库、术语库、进度看板,让译员不碰代码就能翻译,并通过 API/CLI 与代码仓库双向同步。
| 平台 | 定位 | 特点 |
| --- | --- | --- |
| Crowdin | 老牌全能 | 生态广、集成多、开源项目免费 |
| Lokalise | 商业化强 | UI 现代、协作与 QA 检查完善 |
| Tolgee | 开发者友好 | 开源可自托管、支持应用内可视化编辑 |
# crowdin.yml —— 声明源文件和译文路径映射
project_id: '123456'
api_token_env: CROWDIN_TOKEN
files:
- source: /messages/en.json
translation: /messages/%two_letters_code%.json
# en.json 为源,翻译回来自动落到 zh.json/ja.json/ar.jsoncrowdin upload sources # 上传源文案给译员
crowdin download # 拉回已翻译内容Tolgee 的应用内编辑(开发时按住 Alt 点文案即可就地编辑)对快速迭代特别高效。
机器翻译预填与翻译记忆库
概念:新增文案先用机器翻译(DeepL/Google)预填,译员在此基础上校对,比从零翻快很多。翻译记忆库(TM)存储历史译文,遇到相同或相似句子自动复用,保证术语一致并降低成本。
某 SaaS 数据:接入 DeepL 预填 + TM 复用后,2000 条新文案的翻译交付周期从平均 9 天缩短到 3 天,译员成本下降约 55%,因为约 40% 句子命中了记忆库或只需轻微校对。
CI 校验翻译完整性与缺失 key 检测
概念:合并代码前用 CI 自动检查——所有语言的 key 是否和源语言对齐、有没有漏翻、有没有多余的废弃 key、ICU 语法是否合法。
// scripts/check-i18n.ts —— 校验所有语言与基准 en 对齐
import en from '../messages/en.json';
import zh from '../messages/zh.json';
import ar from '../messages/ar.json';
function flatten(obj: any, prefix = ''): string[] {
return Object.entries(obj).flatMap(([k, v]) =>
typeof v === 'object' && v !== null
? flatten(v, `${prefix}${k}.`)
: [`${prefix}${k}`]
);
}
const base = new Set(flatten(en));
const locales = { zh, ar };
let hasError = false;
for (const [lng, msgs] of Object.entries(locales)) {
const keys = new Set(flatten(msgs));
const missing = [...base].filter((k) => !keys.has(k));
const extra = [...keys].filter((k) => !base.has(k));
if (missing.length) {
console.error(`[${lng}] 缺失 ${missing.length} 个 key:`, missing);
hasError = true;
}
if (extra.length) {
console.warn(`[${lng}] 多余 ${extra.length} 个废弃 key:`, extra);
}
}
process.exit(hasError ? 1 : 0);# .github/workflows/i18n.yml
- name: Check i18n completeness
run: npx tsx scripts/check-i18n.ts
# 有语言缺 key 就红灯挡住合并伪本地化(pseudo-localization)测试
概念:伪本地化在开发阶段把文案自动转成加长、加重音符号的"假语言",无需真正翻译就能暴露三类问题:硬编码文案(不会被转换,一眼看出)、文本溢出(转换后变长,布局撑破)、字符编码问题。
// 伪本地化转换:加重音 + 加长 30% + 包裹方括号
function pseudoLocalize(text: string): string {
const map: Record<string, string> = {
a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú',
A: 'Á', E: 'É', I: 'Í', O: 'Ó', U: 'Ú',
};
const accented = text.replace(/[aeiouAEIOU]/g, (c) => map[c] || c);
const padding = '~'.repeat(Math.ceil(text.length * 0.3));
return `[${accented}${padding}]`;
}
pseudoLocalize('Save Changes');
// "[Sávé Chángés~~~]" —— 变长了,若按钮此时溢出说明布局不够弹性
// 若界面上还有纯英文没被方括号包住,那就是硬编码漏翻微软、Netflix 等都在 CI 里跑伪本地化视觉回归。某团队引入后,上线前发现了 60+ 处硬编码文案和 15 处德语(普遍比英语长 30%)会溢出的按钮。
性能深入
翻译资源的加载策略直接影响首屏和交互体验。
bundle 拆分与按路由懒加载命名空间
概念:把翻译按命名空间拆分,配合路由级代码分割,只在进入某页面时加载它的命名空间。避免把全站文案打进主 bundle。
// next-intl 里按 namespace 拆分消息文件,动态导入
export default getRequestConfig(async ({ requestLocale }) => {
const locale = (await requestLocale) ?? 'en';
return {
locale,
// 只导入当前请求需要的命名空间,而非整包
messages: {
common: (await import(`../messages/${locale}/common.json`)).default,
},
};
});服务端只下发所需语言
概念:SPA 常犯的错误是把所有语言的 JSON 都打进 bundle,用户只用一种语言却下载了全部。正确做法是服务端根据用户语言只下发那一种。
// 反例:所有语言静态 import,全部进包
import en from './en.json';
import zh from './zh.json';
import ja from './ja.json'; // 用户是中文用户也白下载了 en/ja
// 正例:动态 import,只加载当前语言
async function loadLocale(lng: string) {
return (await import(`./locales/${lng}.json`)).default;
}翻译文件体积对比与首屏 FOUC 处理
FOUC/语言闪烁:SSR 应用若服务端渲染了默认语言、客户端再切成用户语言,用户会看到文案先闪一下英文再变中文(Flash Of Untranslated Content)。解决办法是服务端就确定并渲染正确语言(App Router + next-intl 天然做到),SPA 则在渲染前同步读取语言偏好、必要时用 Suspense 挡住直到翻译就绪。
| 加载策略 | 首屏 JS 中翻译体积 | 首屏时间影响 | 适用场景 |
| --- | --- | --- | --- |
| 全语言全量打包 | 380KB(8 语言) | 慢,阻塞 | 反模式,避免 |
| 单语言全量 | 48KB | 中等 | 小型应用 |
| 单语言 + 命名空间懒加载 | 8KB(仅首屏 ns) | 快 | 中大型应用推荐 |
| RSC 服务端翻译 | 0KB(不进客户端) | 最快 | Next.js App Router |
某电商实测:从"单语言全量 48KB"改为"命名空间懒加载首屏仅 8KB",移动端首屏 TTI 改善约 180ms。进一步把静态文案迁到 RSC 服务端渲染后,这部分翻译完全不进客户端 bundle,交互相关 JS 再降 22KB。
类型安全 i18n
概念:默认 t('some.key') 里的 key 是普通字符串,写错、拼错、用了已删除的 key 都要运行时才暴露。类型安全 i18n 用 TypeScript 让 key 变成有约束的字面量类型,写错立刻编译报错,还能自动补全。
i18next 类型增强(声明合并)
// types/i18next.d.ts —— 用声明合并把翻译资源类型注入 i18next
import 'i18next';
import type common from '../public/locales/en/common.json';
import type home from '../public/locales/en/home.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: {
common: typeof common;
home: typeof home;
};
}
}const { t } = useTranslation('common');
t('navigation.home'); // OK,且有自动补全
t('navigation.hom'); // 编译错误:不存在的 key
t('home:hero.title'); // OK,跨命名空间也有类型检查next-intl 与 typesafe-i18n
next-intl 支持通过全局声明约束 messages 类型:
// global.d.ts
import type messages from './messages/en.json';
declare global {
interface IntlMessages extends Messages {}
type Messages = typeof messages;
}
// 之后 useTranslations('...') 的 key 都受类型检查typesafe-i18n 更进一步,从翻译文件生成完整的类型化 API,连插值参数的类型都检查:
// 若 key 定义为 "greeting": "你好 {name:string}!"
// 生成的 API 会强制要求传 name 且必须是 string
LL.greeting({ name: 'Alice' }); // OK
LL.greeting({}); // 编译错误:缺少参数 name
LL.greeting({ name: 123 }); // 编译错误:类型不匹配类型安全的收益随规模放大:某 5 万行、含 1800 个翻译 key 的项目引入 i18next 类型增强后,重构删改 key 时编译期就能定位全部引用点,"key 拼错/漏改导致线上显示原始 key"的线上事故从每季度 3-4 起降到 0。
真实案例复盘
案例一:跨境电商 8 语言上线
某跨境电商从单语言扩展到 8 语言(en/zh/ja/ko/de/fr/es/ar),技术栈 Next.js App Router + next-intl。
| 指标 | 数据 |
| --- | --- |
| 支持语言 | 8 种(含 RTL 阿拉伯语) |
| 翻译 key 规模 | 约 2400 条,拆 14 个命名空间 |
| 翻译文件总体积 | 单语言约 96KB,8 语言共 760KB |
| 首屏客户端翻译体积 | 从全量 96KB 降到懒加载 11KB |
| 首屏 TTI 改善 | 约 210ms |
| 上线周期 | 翻译 + 联调 6 周,其中 RTL 适配占 2 周 |
关键决策:静态文案走 RSC 服务端渲染彻底移出客户端 bundle;商品详情等动态内容按命名空间懒加载;RTL 用 CSS 逻辑属性 + Tailwind rtl: 变体统一处理;CI 加翻译完整性校验和伪本地化视觉回归。
案例二:B2B SaaS 类型安全改造
某 B2B SaaS(React SPA + i18next,4 语言,1800 key)线上频繁出现"界面显示原始 key"的问题,根因是重构时改了 key 名但漏改引用。
引入 i18next 声明合并类型增强 + CI 完整性校验后:编译期即可拦截错误 key;重构可安全批量改名;缺 key 在 CI 红灯拦截。改造后一个季度内相关线上事故从 3-4 起降到 0,翻译相关的 code review 返工也明显减少。
常见坑汇总
| 坑 | 现象 | 正确做法 |
| --- | --- | --- |
| 硬编码文案 | 切语言后部分文字不变 | 所有用户可见文案走 t(),用伪本地化扫出漏网之鱼 |
| 字符串拼接 | 语序错乱、无法翻译整句 | 用带占位符的完整句子或 Trans 组件,绝不拆句拼接 |
| 复数误用 | 俄语/阿语数字后名词形式错 | 按 CLDR 类别填全复数分支,交母语译员校对 |
| 日期时区错误 | 用户看到的时间差几小时 | UTC 存储,展示时按用户时区用 Intl 换算 |
| 货币浮点运算 | 金额出现 0.30000004 | 以分为单位整数存储运算,展示才格式化 |
| RTL 遗漏 | 阿语布局镜像错乱 | 用 CSS 逻辑属性、方向图标镜像、dir 自动检测 |
| 动态拼接 key | 提取工具扫不到、打包丢文案 | key 用静态字面量,动态映射用显式白名单对象 |
| SSR 语言闪烁 | 先闪英文再变中文 | 服务端确定语言并直出,RSC 优先 |
| 翻译缺失无兜底 | 显示原始 key 给用户 | 配 fallbackLng + saveMissing 收集缺失 key |
动态 key 的正确写法:
// 反例:提取工具无法静态分析,打包可能丢失
t(`status.${order.status}`);
// 正例:用显式映射,key 全部是静态字面量可被提取
const STATUS_KEYS = {
pending: 'status.pending',
shipped: 'status.shipped',
delivered: 'status.delivered',
} as const;
t(STATUS_KEYS[order.status]);主流库选型对比
| 维度 | i18next | react-intl | next-intl | Lingui |
| --- | --- | --- | --- | --- |
| 生态定位 | 全框架通用、插件最多 | FormatJS、格式化强 | Next.js 专用、App Router 一等公民 | 编译优先、体积小 |
| 运行时体积 | 约 40KB(含 react 绑定) | 约 50KB | 约 15KB | 约 5KB(编译后) |
| ICU 支持 | 需 i18next-icu 插件 | 原生一等公民 | 原生支持 | 原生支持 |
| 复数/性别 | 后缀语法或 ICU | ICU 完整 | ICU 完整 | ICU 完整 |
| SSR/RSC | 支持,配置略繁 | 支持 | 最佳,专为 App Router 设计 | 支持 |
| 类型安全 | 声明合并增强 | 中等 | 内置全局类型 | 编译期生成类型 |
| 消息提取 | i18next-scanner | @formatjs/cli | 复用 FormatJS 工具 | @lingui/cli 内置 |
| 学习曲线 | 中等 | 中等偏高 | 低(Next.js 用户) | 中等 |
| 适用场景 | 通用、多框架、插件需求多 | 格式化重、跨端 | Next.js 项目首选 | 极致体积、编译工作流 |
选型建议一句话:Next.js App Router 项目直接用 next-intl;多框架或需要丰富插件(后端加载、语言检测、缓存)选 i18next;格式化需求极重且跨 React Native 选 react-intl;极度在意包体积、能接受编译工作流选 Lingui。
总结
| 主题 | 核心要点 | 关键工具/API |
| --- | --- | --- |
| 库选型 | 按框架和需求选,Next.js 优先 next-intl | i18next / react-intl / next-intl / Lingui |
| 命名空间 | 按模块拆分 + 按需加载降首屏体积 | i18next ns、动态 import |
| 消息格式 | 复杂场景用 ICU,跨库通用 | ICU MessageFormat、i18next-icu |
| 富文本 | 整句翻译,标签映射回组件 | Trans 组件、components 映射 |
| 复数 | 按 CLDR 6 类别填全,母语校对 | Intl.PluralRules、ICU plural |
| 日期时间 | UTC 存储,按时区展示 | Intl.DateTimeFormat、RelativeTimeFormat、Temporal |
| 数字货币 | 金额用整数运算,Intl 格式化 | Intl.NumberFormat、notation compact/unit |
| 排序分词 | 按语言规则,不用默认字典序 | Intl.Collator、Intl.Segmenter、Intl.ListFormat |
| RTL | 逻辑属性 + 图标镜像 + dir 自动 | CSS logical props、Tailwind rtl: |
| 工作流 | TMS 协作 + CI 校验 + 伪本地化 | Crowdin/Lokalise/Tolgee、pseudo-localization |
| 性能 | 只下发所需语言、RSC 服务端翻译 | 命名空间懒加载、动态 import |
| 类型安全 | key 字面量类型化,编译期拦错 | 声明合并、typesafe-i18n |
| 常见坑 | 硬编码、拼接、时区、RTL、动态 key | 静态 key 映射、fallback、saveMissing |
国际化不是"翻译几句话"的附加功能,而是贯穿架构、组件、构建、协作全流程的系统工程。做好它的关键在于:文案与代码分离且可静态提取、格式化交给 Intl 而非手写、复数与方向交给规则而非直觉、协作与校验交给工具而非人工。前期把这套基础设施搭对,后续新增一种语言就只是提交一份翻译文件的成本;反之,把国际化当补丁事后打,则每加一种语言都是一次痛苦的重构。
无障碍与国际化的交叉
概念:国际化和无障碍(a11y)高度相关但常被割裂对待。屏幕阅读器依赖正确的 lang 属性来选择发音引擎,方向属性影响朗读顺序,aria 标签同样需要翻译。
lang 属性必须准确
为什么重要:如果页面 lang="en" 但内容是中文,屏幕阅读器会用英文发音引擎读中文,结果是完全无法理解的乱码音。混合语言页面还需要在局部标注 lang。
// 根元素设置主语言(Next.js 已在 layout 处理)
// <html lang="zh" dir="ltr">
// 页面内嵌入其他语言片段时局部标注
function Quote() {
return (
<blockquote>
<p>如乔布斯所说:</p>
{/* 这段英文需要标注 lang,否则屏幕阅读器用中文引擎读英文 */}
<p lang="en">Stay hungry, stay foolish.</p>
</blockquote>
);
}aria 标签也要翻译
常见坑:开发者常翻译了可见文案,却漏了 aria-label、aria-description、alt 等辅助文本。视力正常用户看不到问题,屏幕阅读器用户却听到的是硬编码英文。
import { useTranslation } from 'react-i18next';
function IconButton() {
const { t } = useTranslation();
return (
<button
// aria-label 必须走翻译,不能硬编码
aria-label={t('actions.close')}
// 图片 alt 同样要翻译
>
<img src="/close.svg" alt={t('actions.close')} />
</button>
);
}动态内容变化的朗读
带 aria-live 的区域在语言切换或数据更新时会被朗读,翻译要保证语义完整,避免只朗读一个数字而无上下文:
function LiveCartCount({ count }: { count: number }) {
const { t } = useTranslation();
return (
<div aria-live="polite" aria-atomic="true">
{/* 朗读完整句"购物车有 3 件商品"而非仅"3" */}
{t('cart.liveCount', { count })}
</div>
);
}语言持久化与 URL 策略深入
语言选择的存储位置和 URL 设计直接影响 SEO、分享体验和用户预期。
三种 URL 策略对比
| 策略 | 示例 | SEO | 优点 | 缺点 |
| --- | --- | --- | --- | --- |
| 路径前缀 | example.com/zh/about | 最佳 | 每语言独立 URL、可被索引、易分享 | 需要路由改造 |
| 子域名 | zh.example.com | 好 | 可独立部署、地域 CDN | 运维成本高、cookie 隔离 |
| 查询参数 | example.com/about?lang=zh | 较差 | 实现简单 | 易被判重复内容、分享易丢参数 |
结论:面向公开搜索的内容站/电商优先用路径前缀(配合 hreflang)。纯内部后台、无 SEO 需求的应用可用查询参数或纯 cookie/localStorage 存储。
持久化的优先级链
概念:确定用户语言时应按优先级依次尝试多个来源,找到即用。推荐链路:URL 显式指定 > 用户已保存偏好 > 浏览器 Accept-Language > 站点默认。
function resolveLocale(
urlLocale: string | null,
savedLocale: string | null,
acceptLanguage: string | null,
supported: string[],
fallback: string
): string {
// 1. URL 显式指定优先级最高(用户主动选择或分享链接)
if (urlLocale && supported.includes(urlLocale)) return urlLocale;
// 2. 用户此前保存的偏好
if (savedLocale && supported.includes(savedLocale)) return savedLocale;
// 3. 浏览器语言,取第一个受支持的
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')
.map((s) => s.split(';')[0].trim().split('-')[0]);
const match = preferred.find((l) => supported.includes(l));
if (match) return match;
}
// 4. 站点默认
return fallback;
}关键坑:用户手动切换语言后必须持久化并让后续访问尊重该选择——不要每次都用浏览器语言覆盖用户的显式选择。next-intl 的 middleware 会把用户选择写入 NEXT_LOCALE cookie,后续请求优先读它。
国际化测试进阶
除了前面的单元测试,成熟项目还需要覆盖视觉回归和端到端多语言测试。
参数化测试所有语言
import { render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';
const LOCALES = ['en', 'zh', 'ja', 'ar'];
describe.each(LOCALES)('Navigation in %s', (locale) => {
beforeEach(() => i18n.changeLanguage(locale));
it('renders without missing keys', () => {
render(
<I18nextProvider i18n={i18n}>
<Navigation />
</I18nextProvider>
);
// 断言不出现原始 key(说明有漏翻)
expect(screen.queryByText(/navigation\./)).not.toBeInTheDocument();
});
});用 Playwright 做多语言 E2E 与视觉回归
import { test, expect } from '@playwright/test';
const locales = ['en', 'zh', 'ar'];
for (const locale of locales) {
test(`homepage renders correctly in ${locale}`, async ({ page }) => {
await page.goto(`/${locale}`);
// 断言 html lang 与 dir 正确
await expect(page.locator('html')).toHaveAttribute('lang', locale);
const expectedDir = locale === 'ar' ? 'rtl' : 'ltr';
await expect(page.locator('html')).toHaveAttribute('dir', expectedDir);
// 视觉回归:与基准截图比对,发现文本溢出、RTL 错乱
await expect(page).toHaveScreenshot(`home-${locale}.png`, {
maxDiffPixelRatio: 0.01,
});
});
}缺失 key 的运行时收集
// 开发/预发环境开启 saveMissing,把缺失 key 上报
i18n.init({
saveMissing: true,
missingKeyHandler: (lngs, ns, key) => {
// 上报到日志系统,便于补翻,绝不让用户看到原始 key
console.warn(`[i18n] missing: ${lngs.join(',')}/${ns}:${key}`);
fetch('/api/i18n-missing', {
method: 'POST',
body: JSON.stringify({ lngs, ns, key }),
});
},
});把这几层测试接入 CI 后,多语言项目在合并前就能拦住漏翻、文本溢出、方向错乱三类高频缺陷,把国际化质量从"上线后被用户发现"提前到"提交时被流水线拦截"。
国际化不是"翻译几句话"的附加功能,而是贯穿架构、组件、构建、协作全流程的系统工程。前期把基础设施搭对,后续新增语言只是提交一份翻译文件的成本。