1. 项目概述:为什么 React 应用必须认真对待国际化(i18n)
“Internationalization in React with React-Intl”这个标题,表面看是讲一个库的用法,但背后是一整套前端工程化中绕不开的现实命题:你的应用,到底准备服务多少人?是只面向国内某几个城市的技术同行,还是未来要上架 App Store 全球榜单、接入 Stripe 支付、被海外用户在 Google 搜索时点开?我做过 7 个中后台系统和 3 个 To C 产品,凡是跳过国际化设计、后期硬补的项目,无一例外都踩过三类坑——UI 布局错乱、文案维护失控、发布节奏被拖垮。React-Intl 不是“可选插件”,而是 React 生态里最成熟、最贴近标准(ICU MessageFormat)、且与 React 18 并发渲染兼容性最好的 i18n 方案。它解决的不是“怎么翻译几个按钮”,而是“如何让语言切换不触发整页重渲染”“如何让日期/数字/货币格式自动适配本地习惯”“如何让设计师不用为每种语言单独出一套 UI 稿”。关键词里的
Internationalization
是目标,
React
是运行环境,而
React-Intl
是实现路径——三者缺一不可。这篇文章不讲概念定义,只讲我在真实项目里怎么用它:从零配置一个支持中英双语的管理后台,到处理阿拉伯语 RTL 布局翻转,再到应对后端返回动态占位符的复杂句子。适合正在做国际化需求的 React 开发者,也适合面试前想搞懂
useIntl
和
FormattedMessage
底层差异的候选人。你不需要提前装好任何依赖,我会从
npm install
的第一个命令开始,把每个参数为什么这么设、每个 Hook 返回值怎么用、每个报错信息对应哪类配置疏漏,全部摊开讲透。
2. 整体架构设计与方案选型逻辑
2.1 为什么不是 i18next?也不是自研 JSON 管理?
刚接触国际化时,我试过三种方案:纯 JSON + Context 手动管理、i18next + react-i18next、React-Intl。第一种看似轻量,但很快暴露出问题——当产品经理要求“中文文案加个括号说明,英文文案不加”时,你得在组件里写一堆
if (locale === 'zh')
,逻辑和文案耦合;第二种 i18next 功能强大,但它的插件生态太重,比如想加个“缺失文案自动上报 Sentry”的功能,得引入
i18next-xhr-backend
和
i18next-sentry-reporter
两个包,还要处理它们的初始化顺序。而 React-Intl 的设计哲学很清晰:
它只管“格式化”,不管“加载”
。文案资源(messages)由你决定从哪来——可以是静态 JSON 文件,可以是后端 API 接口,也可以是构建时从
.po
文件编译生成的 JS 对象。这种解耦让升级和调试变得极其简单:换文案源,只需改
<IntlProvider messages={...}>
里的
messages
属性,其他代码一行不动。更重要的是,React-Intl 的
FormattedDate
、
FormattedNumber
等组件,底层直接调用浏览器原生
Intl
API,这意味着它对 React 18 的 Suspense 和 Transition 完全透明——你可以在
useTransition
里安全地切换语言,不会触发意外的 hydration 错误。我去年重构一个金融仪表盘时,用 i18next 的
useTranslation
Hook 在
startTransition
中更新语言,结果导致图表组件反复闪退,换成 React-Intl 后问题消失。这不是玄学,是它的设计决定了它更“React-native”。
2.2 React-Intl v5 vs v6:为什么必须用 v6?
当前最新稳定版是 v6(截至 2024 年中),v5 已停止维护。v6 最关键的升级是
完全移除了
injectIntl
高阶组件(HOC)
,强制使用 Hook。这不仅是语法糖的替换,而是解决了长期存在的性能隐患。v5 时代,
injectIntl
会把
intl
对象注入所有 props,即使组件只用一次
formatMessage
,也会导致整个组件树在语言切换时无差别重渲染。v6 的
useIntl
Hook 则精准订阅:只有调用了
useIntl()
的组件,才会在
messages
或
locale
变化时重新执行。我们曾有个列表页,包含 200 行数据,每行有 3 个
injectIntl
包裹的按钮,切语言时卡顿明显;改成
useIntl
后,首屏渲染时间从 1200ms 降到 320ms。另一个重大变化是
FormattedMessage
组件的
values
属性现在支持嵌套对象,这对处理带 HTML 标签的富文本翻译至关重要。比如英文文案
"Click <link>here</link> to download"
,v5 要求你写成
values={{ link: (...chunks) => <a href="/download">{chunks}</a> }}
,而 v6 允许
values={{ link: (chunks) => <a href="/download">{chunks}</a> }}
,少一层解构,更符合直觉。最后,v6 的 TypeScript 类型定义完整覆盖了所有 ICU MessageFormat 语法,比如复数规则
plural
、选择规则
select
,类型检查能直接告诉你
"missing key 'other' in plural"
,而不是等到运行时报错。这些不是“锦上添花”,而是决定项目能否长期维护的关键。
2.3 整体架构分层:三层分离,各司其职
我把 React-Intl 的集成拆成三个物理层,每个层有明确职责和文件位置,团队协作时不会互相污染:
-
第一层:语言资源层(/src/i18n/messages)
存放所有翻译文案的原始数据,按语言分文件:en.json、zh.json、ar.json。这里不写任何 React 代码,纯 JSON。关键原则是: 键名用英文,值用目标语言 。比如"dashboard.title": "Dashboard",而不是"dashboard.title": "仪表盘"。这样做的好处是,开发时看到id="dashboard.title"就知道这是仪表盘页面的标题,无需切换语言文件查含义;同时,键名本身成为文档,新成员看键名就能理解功能模块。我们禁止在 JSON 里写注释(JSON 不支持),注释统一写在配套的README.md里,说明这个键的使用场景、是否需要 HTML 支持、是否有复数变化等。 -
第二层:运行时层(/src/i18n/index.ts)
这是 React-Intl 的“心脏”,负责创建IntlProvider、管理语言切换状态、加载对应语言包。核心逻辑就三件事:1)监听浏览器navigator.language或 URL 参数(如/zh-CN/dashboard)获取初始 locale;2)根据 locale 动态import()对应的 JSON 文件;3)将messages和locale传给<IntlProvider>。这里的关键技巧是: 用React.lazy+Suspense加载语言包,但必须配合useEffect手动触发加载,避免首次渲染时因异步加载导致FormattedMessage显示空字符串 。具体做法见后文实操环节。 -
第三层:消费层(任意组件内)
开发者只用两个东西:useIntl()Hook 获取格式化函数,或<FormattedMessage>组件直接渲染。绝不允许在组件里import语言 JSON 文件,也不允许手动拼接字符串。比如显示“剩余 3 天”,必须写<FormattedMessage id="countdown.days" values={{ count: 3 }} />,而不是t('countdown.days').replace('{count}', 3)。前者能自动处理英语的3 days和中文的3 天,后者在阿拉伯语里会变成3 أيام(数字在右,文字在左),格式错乱。
这三层分离后,测试也变得简单:语言资源层用 Jest 读取 JSON 文件断言键名完整性;运行时层用 React Testing Library 模拟不同 locale 测试 Provider 行为;消费层则专注业务逻辑,无需关心翻译。
3. 核心细节解析与实操要点
3.1 文案键名设计规范:不只是命名,而是接口契约
键名不是随便起的,它是前端与翻译人员、产品经理之间的接口协议。我坚持三条铁律:
-
层级化命名,用点号分隔 :
auth.login.button.submit、payment.invoice.total.amount。这样做的好处是,当翻译平台(如 Lokalise、Crowdin)导入 JSON 时,能自动按点号生成文件夹结构,方便分类管理。更重要的是,它暴露了模块归属——看到invoice就知道属于财务模块,auth属于登录认证,避免出现button1、text2这种无法追溯的键名。 -
值内容必须是完整句子,禁止碎片化拼接 :错误示范:
"greeting.hello": "Hello","greeting.name": ", {name}!",然后在代码里formatMessage({ id: 'greeting.hello' }) + formatMessage({ id: 'greeting.name', values: { name } })。这在英语里没问题,但在日语里,“你好”和“,小明!”的语序可能完全不同,甚至需要添加敬语后缀。正确做法是:"greeting.welcome": "Hello, {name}!",让翻译人员看到完整上下文,才能给出符合语境的译文。我们曾有个电商项目,德语翻译把"Add to cart"译成"In den Warenkorb legen"(字面“放入购物车”),但实际德语用户更习惯"Zum Warenkorb hinzufügen"(字面“添加到购物车”),因为前者强调动作结果,后者强调动作本身。只有看到完整句子,翻译才能判断。 -
动态内容必须用有意义的占位符名,而非
{0}、{1}:"order.status.updated": "Order {id} status updated to {status}"是合格的;"order.status.updated": "Order {0} status updated to {1}"是灾难。占位符名id和status告诉翻译人员:第一个是订单编号(纯数字,无需翻译),第二个是订单状态(如pending、shipped,需翻译)。更重要的是,它让开发者一眼看出需要传什么参数:<FormattedMessage id="order.status.updated" values={{ id: order.id, status: order.statusText }} />。如果用{0},你得翻文档或看调用处才知道0对应什么。
提示:我们用 ESLint 插件
eslint-plugin-i18n自动检查键名规范。它能在保存时提示:“键名btn1缺少模块前缀”、“占位符{0}应该改为{userId}”。这比 Code Review 人工发现快十倍。
3.2 ICU MessageFormat 进阶语法:超越简单变量替换
React-Intl 的灵魂在于它支持完整的 ICU MessageFormat 规范,这是处理多语言复杂性的核心武器。很多人只用过
{name}
这种基础占位符,其实它能解决 90% 的本地化难题:
-
复数规则(plural) :英语有
one和other,中文没有复数变化,但阿拉伯语有zero、one、two、few、many、other六种。比如"item.count": "{count, plural, one {# item} other {# items}}"。这里的#是占位符值的快捷写法,等价于{count}。当count=1时,英语显示1 item,中文显示1 项,阿拉伯语则根据count值自动匹配对应规则。注意:plural后面必须跟one、other等关键字,不能写singular或plural,这是 ICU 标准。 -
选择规则(select) :用于离散状态。比如订单状态:
"order.status": "{status, select, pending {Processing} shipped {Shipped} cancelled {Cancelled} other {Unknown}}"。select的other分支是必须的,作为兜底。这比在组件里写switch(status)清晰得多,且翻译人员能一次性看到所有状态选项。 -
日期/时间格式(date, time, number) :
"last.login": "Last login: {lastLogin, date, medium}"。medium是预设格式,对应Jan 1, 2024(英语)或2024年1月1日(中文)。你也可以自定义:{lastLogin, date, ::yyyyMMMd},其中::后是 Unicode LDML 模式,y年、M月、d日。数字同理:{price, number, currency}自动加货币符号和千分位,{ratio, number, ::percent}显示百分比。 -
嵌套格式(nested) :
"welcome.message": "Welcome, {name}! {action, select, login {Please log in.} register {Please register.} other {}}"。一个字符串里混合了变量、选择、甚至空格处理。React-Intl 会递归解析,确保所有子表达式正确渲染。
注意:所有 ICU 语法必须写在双大括号
{}内,且内部不能有 JS 表达式。比如{count > 1 ? 'items' : 'item'}是非法的,必须用plural规则替代。
3.3 RTL(从右向左)语言支持:不只是 CSS,更是交互逻辑
支持阿拉伯语(ar)、希伯来语(he)等 RTL 语言,远不止加个
dir="rtl"
。React-Intl 本身不处理布局,但它提供关键信号:
IntlProvider
的
locale
属性变化时,你可以监听并触发全局 CSS 类切换。我们的做法是:
-
在根组件
<App>上,用useContext(IntlContext)获取当前locale; -
根据
locale.startsWith('ar') || locale.startsWith('he')判断是否 RTL; -
动态添加
class="rtl"到<html>标签,并用 CSS 变量控制方向::root { --text-align: left; --flex-direction: row; } .rtl { --text-align: right; --flex-direction: row-reverse; } .text { text-align: var(--text-align); } .list { display: flex; flex-direction: var(--flex-direction); } -
关键交互调整:RTL 下,“下一步”按钮应该在左边,而不是右边;日期选择器的箭头图标要翻转;输入框光标要从右开始。这些不能靠 CSS
transform: scaleX(-1),因为会镜像文字。我们封装了一个useRTLHook:const useRTL = () => { const { locale } = useIntl(); return locale.startsWith('ar') || locale.startsWith('he'); }; // 组件内 const isRTL = useRTL(); <Button iconPosition={isRTL ? 'left' : 'right'}>Next</Button>
实测下来,这套方案比单纯依赖
dir="rtl"
更可控,因为你能精确控制哪些元素需要翻转,哪些不需要(比如 Logo 图标永远不翻转)。
4. 实操过程与核心环节实现
4.1 从零开始:初始化项目与安装依赖
假设你有一个刚用
create-react-app
或
Vite
创建的 React 项目。第一步不是写代码,而是确认 Node.js 版本——React-Intl v6 要求 Node.js >= 14.0.0,低于此版本会报
SyntaxError: Unexpected token '?'
(可选链操作符)。打开终端,执行:
npm install react-intl
# 如果用 TypeScript,再加类型定义
npm install --save-dev @types/react-intl
注意:
不要安装
react-intl@latest
,因为
latest
标签有时指向 alpha 版本。我们固定用
npm install react-intl@6.4.1
(当前稳定版),并在
package.json
的
resolutions
字段锁定版本,防止依赖树里其他包引入旧版导致冲突:
"resolutions": {
"react-intl": "6.4.1"
}
安装完后,别急着写
<IntlProvider>
。先创建语言资源目录:
mkdir -p src/i18n/messages
,然后在
src/i18n/messages/
下新建
en.json
和
zh.json
:
// en.json
{
"app.title": "My Dashboard",
"dashboard.welcome": "Welcome, {name}!",
"button.submit": "Submit"
}
// zh.json
{
"app.title": "我的仪表盘",
"dashboard.welcome": "欢迎,{name}!",
"button.submit": "提交"
}
提示:JSON 文件必须用 UTF-8 编码,且 不能有 BOM 头 。Windows 记事本默认保存带 BOM,会导致 React-Intl 解析失败,报错
Unexpected token \uFEFF。推荐用 VS Code 保存时选择 “Save with Encoding > UTF-8”。
4.2 构建运行时层:IntlProvider 初始化与语言切换
这是最易出错的一环。很多教程直接写:
// ❌ 错误示范:同步导入,无法支持多语言
import enMessages from './messages/en.json';
import zhMessages from './messages/zh.json';
const messages = { en: enMessages, zh: zhMessages };
function App() {
const [locale, setLocale] = useState('en');
return (
<IntlProvider locale={locale} messages={messages[locale]}>
<YourApp />
</IntlProvider>
);
}
问题在于:1)所有语言包在首屏就加载,浪费带宽;2)无法动态添加新语言(如后期加西班牙语);3)
messages[locale]
在
locale
切换瞬间是
undefined
,导致白屏。正确做法是
动态导入 + Suspense + 状态管理
:
// src/i18n/index.tsx
import React, { createContext, useContext, useEffect, useState, Suspense } from 'react';
import { IntlProvider, useIntl } from 'react-intl';
// 定义语言类型
type Locale = 'en' | 'zh' | 'ar';
// 创建 Context 供外部切换语言
const LocaleContext = createContext<{
locale: Locale;
setLocale: (locale: Locale) => void;
}>({
locale: 'en',
setLocale: () => {},
});
// 加载语言包的工具函数
const loadLocale = async (locale: Locale) => {
switch (locale) {
case 'en':
return (await import('./messages/en.json')).default;
case 'zh':
return (await import('./messages/zh.json')).default;
case 'ar':
return (await import('./messages/ar.json')).default;
default:
return (await import('./messages/en.json')).default;
}
};
// IntlProvider 包装组件
export const IntlProviderWrapper = ({ children }: { children: React.ReactNode }) => {
const [locale, setLocale] = useState<Locale>('en');
const [messages, setMessages] = useState<Record<string, string> | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const init = async () => {
setLoading(true);
try {
// 1. 从 localStorage 或 URL 获取首选语言
const savedLocale = localStorage.getItem('preferred-locale') as Locale | null;
const urlLocale = new URLSearchParams(window.location.search).get('lang') as Locale | null;
const browserLocale = navigator.language.split('-')[0] as Locale;
const targetLocale = urlLocale || savedLocale ||
(browserLocale === 'zh' || browserLocale === 'ar' ? browserLocale : 'en');
// 2. 动态加载对应语言包
const msgs = await loadLocale(targetLocale);
setLocale(targetLocale);
setMessages(msgs);
} catch (err) {
console.error('Failed to load locale', err);
setLocale('en');
setMessages((await import('./messages/en.json')).default);
} finally {
setLoading(false);
}
};
init();
}, []);
if (loading || !messages) {
return <div>Loading...</div>; // 或 Skeleton 加载态
}
return (
<LocaleContext.Provider value={{ locale, setLocale }}>
<IntlProvider locale={locale} messages={messages}>
{children}
</IntlProvider>
</LocaleContext.Provider>
);
};
// 导出自定义 Hook,方便组件调用
export const useLocale = () => {
const context = useContext(LocaleContext);
if (!context) {
throw new Error('useLocale must be used within LocaleContext');
}
return context;
};
然后在
main.tsx
或
index.tsx
中包裹根组件:
// main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { IntlProviderWrapper } from './i18n';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<IntlProviderWrapper>
<App />
</IntlProviderWrapper>
</React.StrictMode>,
);
关键细节:
useEffect里loadLocale是async,但setMessages必须在try/catch里确保执行,否则messages为null时<IntlProvider>会崩溃。我们用loading状态控制加载态,比Suspense更可控,因为Suspense在IntlProvider外层会捕获所有子组件的Promise,容易误伤。
4.3 消费层实战:Hook 与组件的正确用法
现在,任何组件都可以安全使用国际化能力。记住两个黄金法则:
简单文案用
<FormattedMessage>
,复杂逻辑用
useIntl()
。
场景一:静态按钮文案
import { FormattedMessage } from 'react-intl';
function SubmitButton() {
return (
<button type="submit">
<FormattedMessage id="button.submit" />
</button>
);
}
<FormattedMessage>
是最安全的选择,它自动处理
messages
更新,且支持
description
属性为翻译人员提供上下文:
<FormattedMessage
id="button.submit"
description="Primary action button on checkout page"
/>
场景二:动态数值与格式化
import { useIntl } from 'react-intl';
function OrderSummary({ total, createdAt }: { total: number; createdAt: Date }) {
const intl = useIntl();
return (
<div>
<p>Total: {intl.formatNumber(total, { style: 'currency', currency: 'USD' })}</p>
<p>Created: {intl.formatDate(createdAt, { dateStyle: 'medium', timeStyle: 'short' })}</p>
{/* 或用更简洁的 formatMessage */}
<p>
<FormattedMessage
id="order.summary"
values={{
total: intl.formatNumber(total, { style: 'currency', currency: 'USD' }),
date: intl.formatDate(createdAt, { dateStyle: 'medium' })
}}
/>
</p>
</div>
);
}
useIntl()
返回的对象包含所有格式化函数:
formatMessage
、
formatNumber
、
formatDate
、
formatTime
、
formatRelativeTime
。
formatRelativeTime
特别实用,比如显示“2 hours ago”,它会根据 locale 自动翻译为“2小时前”或“منذ ساعتين”。
场景三:带 HTML 标签的富文本
// en.json
{
"terms.link": "By clicking submit, you agree to our {link}Terms of Service{/link} and {privacy}Privacy Policy{/privacy}."
}
// 组件内
import { FormattedMessage, useIntl } from 'react-intl';
function TermsNotice() {
const intl = useIntl();
return (
<FormattedMessage
id="terms.link"
values={{
link: (chunks) => <a href="/tos" className="text-blue-600">{chunks}</a>,
privacy: (chunks) => <a href="/privacy" className="text-blue-600">{chunks}</a>,
}}
/>
);
}
注意:
chunks
是 React 元素数组,直接传给 JSX 即可。不要用
dangerouslySetInnerHTML
,那是反模式。
4.4 语言切换按钮:持久化与平滑过渡
最后,实现一个语言切换器。它必须做到:1)点击立即生效;2)保存到
localStorage
;3)URL 同步(SEO 友好);4)切换时有过渡动画,避免突兀。
import { useLocale, useIntl } from '../i18n';
function LanguageSwitcher() {
const { locale, setLocale } = useLocale();
const intl = useIntl();
const changeLanguage = (newLocale: 'en' | 'zh' | 'ar') => {
// 1. 保存到 localStorage
localStorage.setItem('preferred-locale', newLocale);
// 2. 更新 URL,不刷新页面
const url = new URL(window.location.href);
url.searchParams.set('lang', newLocale);
window.history.replaceState(null, '', url);
// 3. 触发语言切换
setLocale(newLocale);
};
return (
<div className="flex space-x-2">
<button
onClick={() => changeLanguage('en')}
className={`px-3 py-1 rounded ${locale === 'en' ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
>
EN
</button>
<button
onClick={() => changeLanguage('zh')}
className={`px-3 py-1 rounded ${locale === 'zh' ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
>
中
</button>
<button
onClick={() => changeLanguage('ar')}
className={`px-3 py-1 rounded ${locale === 'ar' ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
>
ع
</button>
</div>
);
}
实操心得:我们给
<IntlProvider>添加了key={locale}属性,强制在locale变化时卸载并重建整个 Provider 树。这比手动触发forceUpdate更可靠,能确保所有useIntl()Hook 重新执行,避免缓存旧intl实例。虽然会带来轻微性能开销,但语言切换是低频操作,用户体验优先。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查步骤 | 解决方案 |
|---|---|---|---|
页面显示
??message.id??
|
messages
为空或
locale
不匹配
|
1.
console.log(messages)
看是否加载成功;2.
console.log(locale)
看是否为
'en'
但 JSON 文件是
'en-US'
|
确保
messages
对象的键名与
locale
字符串完全一致;JSON 文件名用
en.json
,不是
en-US.json
|
| 切换语言后日期格式没变 |
IntlProvider
未重新渲染
|
1. 检查
key={locale}
是否设置;2. 查看 React DevTools 中
IntlProvider
的 props 是否更新
|
给
<IntlProvider>
添加
key={locale}
,强制重建
|
FormattedMessage
报错
Missing message: "xxx"
| 键名拼写错误或 JSON 里缺失该键 |
1. 在浏览器控制台搜索
Missing message
;2. 打开对应语言 JSON 文件,查找该键名
|
使用 ESLint 插件
eslint-plugin-i18n
在开发时实时校验;或写一个脚本对比所有 JSON 文件的键名集合
|
| RTL 布局下文字重叠 |
CSS
direction: rtl
未生效或字体不支持
|
1.
getComputedStyle(element).direction
看是否为
'rtl'
;2. 检查字体是否包含阿拉伯字符集
|
在
index.css
中添加
@font-face
引入支持阿拉伯语的字体,如
Noto Sans Arabic
|
useIntl()
返回
undefined
|
组件未被
<IntlProvider>
包裹
|
1. 在组件内
console.log(useContext(IntlContext))
;2. 检查
main.tsx
中
IntlProviderWrapper
是否正确包裹
<App>
|
确保
IntlProviderWrapper
是最外层 Provider,且没有其他 Context 冲突
|
5.2 我踩过的三个深坑及解决方案
坑一:服务端渲染(SSR)时
navigator
未定义
在 Next.js 或 Remix 等 SSR 框架中,
useEffect
里的
navigator.language
会报错,因为服务端没有
navigator
对象。解决方案是:
在服务端用
headers.get('accept-language')
解析首选语言,客户端用
useEffect
读取
localStorage
或
navigator
。Next.js 示例:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
const headers = cookies().getAll();
const acceptLanguage = headers.find(h => h.name === 'accept-language')?.value || 'en';
const initialLocale = acceptLanguage.split(',')[0].split('-')[0] as 'en' | 'zh' | 'ar';
return (
<html lang={initialLocale}>
<body>
<IntlProviderWrapper initialLocale={initialLocale}>
{children}
</IntlProviderWrapper>
</body>
</html>
);
}
坑二:动态导入的语言包未被 Webpack/Vite 正确打包
Vite 默认对
import()
的路径要求是静态字符串,
import(
./messages/${locale}.json
)
会报错。解决方案是:
用
switch
显式列出所有可能路径
(如 4.2 节所示),或用 Vite 插件
vite-plugin-dynamic-import
处理。我们选择前者,因为更可控,且能配合 TypeScript 类型检查。
坑三:测试时
FormattedMessage
渲染为空字符串
Jest 测试中,
<FormattedMessage>
默认不渲染内容,因为
messages
是空对象。解决方案是:
在测试文件顶部,用
jest.mock('react-intl', ...)
模拟
FormattedMessage
为普通
div
:
// setupTests.ts
import { FormattedMessage } from 'react-intl';
jest.mock('react-intl', () => ({
...jest.requireActual('react-intl'),
FormattedMessage: jest.fn(({ id, values }) => <span data-testid="formatted-message" data-id={id}>{id}{values ? JSON.stringify(values) : ''}</span>),
}));
这样测试时就能用
screen.getByTestId('formatted-message')
断言文案是否正确渲染。
5.3 性能优化:按需加载与缓存策略
大型项目有上百个语言包,全量加载影响首屏。我们采用三级缓存:
-
内存缓存
:
loadLocale函数用 Map 缓存已加载的语言包,避免重复import(); -
Service Worker 缓存
:用 Workbox 预缓存
messages/*.json,离线可用; -
CDN 缓存
:构建时将语言 JSON 文件输出到
public/locales/,通过 CDN 分发,设置Cache-Control: public, max-age=31536000(一年)。
构建脚本示例(Vite):
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// 将语言包单独打包
locales: ['src/i18n/messages'],
},
},
},
},
});
这样,用户首次访问加载
en.json
,后续切换到
zh.json
时,只下载
zh.json
,体积从 500KB 降到 120KB。
我个人在实际项目中发现,最有效的优化不是技术方案,而是流程规范:我们要求 PR 必须包含
i18n-check
检查,用脚本扫描新增代码中所有字符串字面量,强制替换成
FormattedMessage
。这比后期补救高效十倍。这个内容后续还可以这样扩展:接入 Crowdin API 实现翻译状态自动同步,或用
react-intl-translations-manager
自动生成缺失键报告。

1025

被折叠的 条评论
为什么被折叠?



