React 基础体系 · 第 57/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 国际化:消息目录、Locale、日期数字、路由和回退
国际化(internationalization,通常简称 i18n)不是把中文字符串替换成英文字符串,而是让同一套 React 代码能够根据用户的语言、地区、数字系统、时区和路由上下文,生成正确的界面。
一个完整的国际化系统至少包含五个相互关联的部分:
- 消息目录(message catalog):保存可翻译的界面文本。
- Locale:描述语言、地区以及可选的数字系统、日历等规则。
- 日期和数字格式化:依据 Locale 生成日期、时间、货币、百分比等文本。
- 路由中的语言标识:让语言选择可分享、可恢复、可被服务端识别。
- 回退(fallback):在 Locale、目录或消息缺失时,确定下一步使用什么。
React 本身只负责组件渲染和状态更新,并没有内置消息目录、翻译语法或 Locale Provider。生产项目通常会使用 Intl、FormatJS、i18next、Lingui 或框架提供的服务端能力。本文先建立这些机制的基础,再给出不依赖特定翻译库的最小实现。
一、先区分语言、地区和 Locale
1. 语言不等于 Locale
“中文”是语言,“中国大陆中文”是语言和地区的组合。两者在日期、数字和货币表示上可能不同:
zh-CN 中国大陆中文
zh-TW 中国台湾中文
en-US 美国英语
en-GB 英国英语
fr-FR 法国法语
Locale 通常使用 BCP 47 语言标签。它可以包含:
语言-地区-脚本-扩展
例如:
zh-Hans-CN
en-US
ar-EG
en-US-u-nu-arab
其中:
zh是语言;Hans是简体汉字脚本;CN是地区;u-nu-arab是 Unicode 扩展,表示使用阿拉伯数字系统。
实际项目中,最常用的是 语言-地区 形式,但不能把 Locale 当成任意字符串。zh_CN、english、cn 等值不是可靠的 BCP 47 Locale。
Locale 会影响什么
Locale 不仅决定消息目录,还影响:
- 日期顺序和分隔符;
- 周起始日等日历规则;
- 小数点和千位分隔符;
- 货币符号及其位置;
- 复数规则;
- 文本排序;
- 从左到右或从右到左的排版方向。
例如:
new Intl.NumberFormat("en-US").format(1234567.89);
// "1,234,567.89"
new Intl.NumberFormat("de-DE").format(1234567.89);
// "1.234.567,89"
new Intl.NumberFormat("zh-CN", {
style: "currency",
currency: "CNY",
}).format(1234.5);
// "¥1,234.50"
所以,国际化代码不能只写:
<span>{price} 元</span>
因为这同时把数字格式、货币单位和语言固定成了中文语境。
二、消息目录:不要把可翻译文本散落在组件中
消息目录的职责
消息目录是“消息键”到“消息模板”的映射:
export const messages = {
"zh-CN": {
"home.title": "欢迎回来,{name}",
"cart.itemCount": "购物车中有 {count} 件商品",
"checkout.total": "合计:{amount}",
},
"en-US": {
"home.title": "Welcome back, {name}",
"cart.itemCount": "{count} items in your cart",
"checkout.total": "Total: {amount}",
},
} as const;
组件使用稳定的键,而不是直接使用某种语言的文本:
<h1>{t("home.title", { name: "Ada" })}</h1>
这样做的因果关系是:
- 组件只依赖
home.title这个语义标识; - 翻译人员可以修改目录,不需要修改 JSX;
- 同一个消息可以在多个组件中复用;
- 可以通过静态检查或测试发现缺失键;
- 语言切换只需要更新 Locale 和消息解析结果。
消息键应该表达语义
较好的键:
account.delete.confirm
checkout.payment.failed
profile.edit.save
较差的键:
redButtonText
text1
中文提示
键名不是给用户看的翻译内容,而是程序和翻译团队之间的稳定接口。不要用原文作为键,因为原文修改后会同时破坏引用关系和翻译映射。
消息不是普通字符串替换
最简单的插值可以写成:
"Welcome back, {name}"
但实际消息通常还需要:
- 数字格式化;
- 日期格式化;
- 复数;
- 性别或其他选择分支;
- 富文本链接;
- React 元素插入。
例如,下面这句话不能只依赖字符串拼接:
You have 1 item
You have 2 items
英语至少需要区分单数和复数,而中文通常不需要改变“件”的词形。更复杂的语言可能有零、单数、双数、少数和多数等多个复数类别。
生产项目通常使用 ICU MessageFormat,例如:
{count, plural,
=0 {No items}
one {# item}
other {# items}
}
React 中应使用支持 ICU 的库解析这种消息,而不是自己用正则表达式实现完整语法。正则表达式无法可靠处理嵌套选择、转义、数字格式和不同语言的复数规则。
三、Locale 的确定:优先级必须明确
一个请求可能同时提供多个语言来源:
- URL 中的语言,例如
/en-US/products; - 用户账户偏好;
- Cookie;
Accept-Language请求头;- 浏览器的
navigator.languages; - 应用默认 Locale。
必须规定优先级,否则用户会遇到“URL 是英文但页面显示中文”的不一致。
一种常见的服务端优先级是:
合法的 URL Locale
> 已登录用户的语言偏好
> Cookie
> Accept-Language
> 默认 Locale
客户端应用中,若路由没有携带 Locale,则常见顺序是:
持久化用户选择
> navigator.languages
> 默认 Locale
这里的关键是:Locale 选择应该是一个可复现的解析过程,而不是在多个组件中各自猜测。
使用 Intl.Locale 做规范化
const supportedLocales = ["zh-CN", "en-US", "ja-JP"] as const;
type SupportedLocale = (typeof supportedLocales)[number];
function normalizeLocale(input: string): string {
try {
return new Intl.Locale(input).toString();
} catch {
return "";
}
}
function findSupportedLocale(input: string): SupportedLocale | undefined {
const normalized = normalizeLocale(input);
if (!normalized) return undefined;
if ((supportedLocales as readonly string[]).includes(normalized)) {
return normalized as SupportedLocale;
}
const language = new Intl.Locale(normalized).language;
return supportedLocales.find(
(locale) => new Intl.Locale(locale).language === language,
);
}
需要注意,zh 并不天然等于 zh-CN。如果应用支持多个中文地区,直接按语言匹配会造成错误;这时必须要求明确地区,或者制定清晰的默认映射。
使用 Intl.LocaleMatcher 的常见方式
浏览器和服务端通常可以用:
const choice = Intl.ListFormat
? Intl.NumberFormat.supportedLocalesOf(
["zh-CN", "en-US"],
{ localeMatcher: "best fit" },
)
: [];
不过 supportedLocalesOf 只表示运行环境是否支持某些 Locale,并不等于你的应用有对应消息目录。应用仍然必须单独检查:
const catalogExists = Object.hasOwn(messages, locale);
“运行时支持这个 Locale”和“应用翻译了这个 Locale”是两件不同的事。
四、回退算法:Locale 回退和消息回退不是一回事
回退是国际化系统的故障处理路径。至少要区分两层:
1. Locale 回退
用户请求:
fr-CA
但应用只提供:
fr-FR
en-US
可以先尝试:
fr-CA
fr
en-US
如果没有 fr 目录,最终使用默认 Locale。
2. 消息键回退
用户使用 fr-CA,目录存在,但其中缺少:
checkout.payment.failed
此时可以按以下顺序查找:
fr-CA.checkout.payment.failed
fr.checkout.payment.failed
en-US.checkout.payment.failed
消息键本身
两层回退不能混为一谈。Locale 目录可能存在,但目录内部仍然不完整。
一个明确的形式化模型
设:
R是请求的 Locale;S是支持的 Locale 集合;D是默认 Locale;k是消息键;C(l, k)表示 Localel下键k的消息。
先构造 Locale 候选序列:
L(R) = [R, 去掉地区后的语言, D]
然后过滤掉不受支持的 Locale,并去重。消息查找结果是:
find(R, k) =
第一个满足 C(l, k) 存在且非空的 l
如果不存在,则返回明确的 missing-message 标记
例如:
R = "en-GB"
D = "en-US"
目录只有 en-US
k = "home.title"
查找过程:
1. en-GB:不存在
2. en:不存在
3. en-US:存在,返回 en-US 的消息
如果 en-US 也缺少该键,不能静默返回空字符串。空字符串会让页面看起来像“设计上没有内容”,不利于诊断。开发环境应该返回明显标记:
⟦missing:home.title⟧
生产环境可以选择默认语言、隐藏错误标记或上报监控,但必须保留可观测性。
一个最小的消息解析器
下面的代码只实现简单插值,适合说明数据流,不是完整 ICU 实现:
type MessageValues = Record<string, string | number | Date>;
function interpolate(
template: string,
values: MessageValues = {},
): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = values[key];
if (value === undefined) {
return `⟦missing-value:${key}⟧`;
}
return String(value);
});
}
type Catalog = Record<string, string>;
function getMessage(
locale: string,
key: string,
catalogs: Record<string, Catalog>,
defaultLocale: string,
): string {
const candidates = [
locale,
locale.split("-")[0],
defaultLocale,
];
for (const candidate of candidates) {
const value = catalogs[candidate]?.[key];
if (typeof value === "string" && value.length > 0) {
return value;
}
}
return `⟦missing:${key}⟧`;
}
这里的每一步都有明确含义:
locale:先尝试用户当前 Locale;locale.split("-")[0]:再尝试语言级目录;defaultLocale:最后尝试默认语言;- 找到非空字符串才算成功;
- 所有目录都缺少时返回诊断标记。
真实项目还需要处理目录加载失败、缓存、版本不一致和 ICU 解析错误。
五、把消息和格式化能力放进 React Context
Locale 改变后,依赖它的组件必须重新渲染。最简单的状态模型是:
locale state
↓
Intl 格式化器和消息函数
↓
Context value
↓
useI18n()
↓
组件重新渲染
可以用 Mermaid 表示:
flowchart TD
A[URL 或用户选择] --> B[解析并校验 Locale]
B --> C[加载消息目录]
C --> D[I18n Provider]
D --> E[useI18n]
E --> F[消息 t]
E --> G[日期 date]
E --> H[数字 number]
F --> I[React 组件]
G --> I
H --> I
下面是一个可运行思路完整的 Provider:
import {
createContext,
useContext,
useMemo,
type ReactNode,
} from "react";
type SupportedLocale = "zh-CN" | "en-US";
const catalogs: Record<SupportedLocale, Record<string, string>> = {
"zh-CN": {
"home.title": "欢迎回来,{name}",
"cart.itemCount": "购物车中有 {count} 件商品",
},
"en-US": {
"home.title": "Welcome back, {name}",
"cart.itemCount": "{count} items in your cart",
},
};
type I18nContextValue = {
locale: SupportedLocale;
t: (key: string, values?: Record<string, string | number>) => string;
number: (
value: number,
options?: Intl.NumberFormatOptions,
) => string;
date: (
value: Date | number,
options?: Intl.DateTimeFormatOptions,
) => string;
};
const I18nContext = createContext<I18nContextValue | null>(null);
function resolveMessage(
locale: SupportedLocale,
key: string,
): string {
return (
catalogs[locale]?.[key] ??
catalogs["en-US"]?.[key] ??
`⟦missing:${key}⟧`
);
}
export function I18nProvider({
locale,
children,
}: {
locale: SupportedLocale;
children: ReactNode;
}) {
const value = useMemo<I18nContextValue>(() => {
const numberFormatterCache = new Map<
string,
Intl.NumberFormat
>();
const dateFormatterCache = new Map<
string,
Intl.DateTimeFormat
>();
return {
locale,
t(key, values = {}) {
const template = resolveMessage(locale, key);
return interpolate(template, values);
},
number(value, options = {}) {
const cacheKey = JSON.stringify(options);
let formatter = numberFormatterCache.get(cacheKey);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, options);
numberFormatterCache.set(cacheKey, formatter);
}
return formatter.format(value);
},
date(value, options = {}) {
const cacheKey = JSON.stringify(options);
let formatter = dateFormatterCache.get(cacheKey);
if (!formatter) {
formatter = new Intl.DateTimeFormat(locale, options);
dateFormatterCache.set(cacheKey, formatter);
}
return formatter.format(value);
},
};
}, [locale]);
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
export function useI18n(): I18nContextValue {
const value = useContext(I18nContext);
if (!value) {
throw new Error("useI18n must be used inside I18nProvider");
}
return value;
}
function interpolate(
template: string,
values: Record<string, string | number>,
): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = values[key];
return value === undefined
? `⟦missing-value:${key}⟧`
: String(value);
});
}
组件使用:
function CartSummary() {
const { t, number, date } = useI18n();
const total = 12345.6;
const updatedAt = new Date("2025-01-02T03:04:05Z");
return (
<section>
<h1>{t("home.title", { name: "Ada" })}</h1>
<p>{t("cart.itemCount", { count: 2 })}</p>
<p>{number(total, { style: "currency", currency: "USD" })}</p>
<time dateTime={updatedAt.toISOString()}>
{date(updatedAt, {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
})}
</time>
</section>
);
}
这个实现的限制必须明确:
- 它没有实现复数规则;
- 它不能安全地生成带链接、粗体等 React 节点的富文本消息;
Date的时区策略由调用方传入;- 所有目录被静态打包,目录很大时会增加首屏体积;
number和date的缓存只在当前 Provider 生命周期内有效。
它适合学习基本机制。生产项目应使用成熟消息格式库,尤其是在支持多种语言时。
六、日期格式化:时间点、时区和显示 Locale 必须分开
不要手工拼接日期
错误示例:
<span>
{date.getFullYear()}-{date.getMonth() + 1}-{date.getDate()}
</span>
问题包括:
getMonth()从 0 开始;- 固定了年-月-日顺序;
- 没有处理 Locale;
- 没有处理时区;
- 可能把用户本地时间和服务端时间混在一起。
使用 Intl.DateTimeFormat:
const instant = new Date("2025-03-08T16:30:00Z");
new Intl.DateTimeFormat("zh-CN", {
dateStyle: "long",
timeZone: "Asia/Shanghai",
}).format(instant);
// "2025年3月9日"
new Intl.DateTimeFormat("en-US", {
dateStyle: "long",
timeZone: "America/Los_Angeles",
}).format(instant);
// "March 8, 2025"
同一个时间点在两个时区可能是不同日期。这里:
2025-03-08T16:30:00Z
在上海已经是 3 月 9 日,而在洛杉矶仍是 3 月 8 日。日期显示因此需要三个明确变量:
- 时间点:例如 ISO 字符串或毫秒时间戳;
- 显示时区:例如
Asia/Shanghai; - 显示 Locale:例如
zh-CN。
日期字符串的陷阱
new Date("2025-03-08")
这种无时区日期字符串容易在不同语境下引发误解。业务数据应区分:
- 时间点:如订单创建时间,保存为 UTC 时间戳或带
Z的 ISO 时间; - 日历日期:如生日、账单日,不应该自动当成某个时区的午夜;
- 本地日期时间:如门店营业时间,需要明确所属时区。
Intl.DateTimeFormat 只负责格式化,不能替你决定数据的语义。
七、数字、货币和百分比格式化
数字格式由 Locale 决定
const value = 0.4567;
new Intl.NumberFormat("zh-CN", {
style: "percent",
maximumFractionDigits: 1,
}).format(value);
// "45.7%"
new Intl.NumberFormat("en-US", {
style: "percent",
maximumFractionDigits: 1,
}).format(value);
// "45.7%"
货币格式还需要明确货币代码:
new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(1234.5);
// "1.234,50 €"
不要根据货币符号判断货币。$ 可能表示 USD、CAD、AUD 等多种货币,数据模型应保存 ISO 4217 代码,例如:
type Money = {
amount: number;
currency: "USD" | "CNY" | "EUR";
};
浮点数不是金额模型
如果金额来自整数分:
const cents = 123456;
const amount = cents / 100;
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
// "$1,234.56"
但不能因此认为 JavaScript 浮点数能精确表示所有金额运算:
0.1 + 0.2 === 0.3;
// false
金额的加减乘除应根据业务精度使用整数最小单位或十进制定点库,最后再交给 Intl.NumberFormat 展示。
八、复数、选择和富文本消息
使用 Intl.PluralRules 不能只判断是否等于 1
错误实现:
const text = count === 1 ? "item" : "items";
这只适用于非常有限的英语场景。可以先观察 Intl.PluralRules 的结果:
new Intl.PluralRules("en").select(1);
// "one"
new Intl.PluralRules("en").select(2);
// "other"
new Intl.PluralRules("ar").select(2);
// "two"
正确的复数类别由 Locale 决定。应用不应该把所有语言都压缩成 one 和 other 两类。
成熟的 ICU 消息示例:
{count, plural,
=0 {购物车为空}
one {购物车中有 # 件商品}
other {购物车中有 # 件商品}
}
注意 # 的格式也应使用当前 Locale 的数字规则,而不是直接调用 String(count)。
富文本不要拼接 HTML
不要把翻译内容直接作为 HTML:
<div dangerouslySetInnerHTML={{ __html: translatedText }} />
翻译文件通常来自仓库或外部平台,直接插入 HTML 会扩大 XSS 风险,也会让 React 的元素边界丢失。
更安全的模型是让消息库接收组件回调,例如概念上:
<Trans
id="terms.accept"
values={{ product: "WR BLOG" }}
components={{
link: <a href="/terms" />,
}}
/>
实际库的 API 不完全相同,不能把此段当作任意库都可直接复制的代码。核心约束是:链接和强调元素应作为受控 React 节点生成,而不是把未验证的 HTML 字符串注入 DOM。
九、路由中的 Locale:语言选择应成为可恢复的应用状态
为什么把 Locale 放进 URL
例如:
/zh-CN/products
/en-US/products
这样有几个直接结果:
- 页面可分享;
- 刷新后语言不会丢失;
- 服务端可以在渲染前确定 Locale;
- 搜索引擎可以区分不同语言页面;
- 浏览器前进、后退会恢复语言状态。
如果只把语言放在 React Context 或 localStorage 中,用户复制链接给别人时,链接本身并不包含语言信息。
路由参数必须校验
以 React Router 风格的配置为例:
import { Navigate, useParams } from "react-router";
const supportedLocales = ["zh-CN", "en-US"] as const;
type SupportedLocale = (typeof supportedLocales)[number];
function isSupportedLocale(
value: string | undefined,
): value is SupportedLocale {
return (
value !== undefined &&
(supportedLocales as readonly string[]).includes(value)
);
}
function LocaleRoute() {
const { locale } = useParams();
if (!isSupportedLocale(locale)) {
return <Navigate to="/en-US" replace />;
}
return (
<I18nProvider locale={locale}>
<CartSummary />
</I18nProvider>
);
}
关键路径是:
- 从 URL 读取字符串;
- 校验它是否属于支持集合;
- 非法值重定向或返回 404;
- 合法值传入 Provider;
- Provider 向所有后代组件提供同一个 Locale。
不要直接把任意 URL 参数传给 Intl.DateTimeFormat 或动态文件路径。非法 Locale 可能抛出 RangeError,动态目录路径还可能造成资源探测或错误加载。
切换语言时保留业务路径
当前地址:
/zh-CN/products/42?tab=reviews
切换到英文后,理想结果是:
/en-US/products/42?tab=reviews
只替换 Locale 段,不要跳回首页。可以先拆分路径,再使用路由库的导航 API;不要用简单的全局字符串替换,因为业务路径中可能也出现类似文本。
语言切换通常是导航行为,而不只是状态更新:
function LanguageSwitcher() {
const { locale } = useI18n();
function changeLocale(nextLocale: SupportedLocale) {
const path = window.location.pathname;
const segments = path.split("/");
segments[1] = nextLocale;
const nextPath = segments.join("/") + window.location.search;
window.history.pushState({}, "", nextPath);
window.location.reload();
}
return (
<select
value={locale}
onChange={(event) =>
changeLocale(event.target.value as SupportedLocale)
}
>
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
</select>
);
}
这段代码通过刷新重新建立 Provider,逻辑简单但体验一般。客户端应用可以改为使用路由库的导航方法并异步加载目录,然后在加载完成后更新 Provider。无论采用哪种方式,URL 都应是语言状态的来源之一。
十、客户端与服务端边界
服务端必须根据请求确定 Locale
服务端不能读取:
navigator.language
localStorage
因为这些对象只存在于浏览器。服务端应从请求中读取:
- URL 参数;
- Cookie;
Accept-Language;- 用户会话。
然后在服务端完成:
- Locale 校验;
- 消息目录加载;
- 页面初始渲染;
- 将 Locale 和必要目录传给客户端边界。
伪代码如下:
async function renderRequest(request: Request) {
const url = new URL(request.url);
const requestedLocale = url.pathname.split("/")[1];
const locale = isSupportedLocale(requestedLocale)
? requestedLocale
: "en-US";
const catalog = await loadCatalog(locale);
return renderApp({
locale,
catalog,
});
}
这里的 loadCatalog 可以读取服务端文件、数据库或构建产物。它不是 React API,而是应用或框架的资源加载层。
避免 hydration 不匹配
如果服务端用 zh-CN 渲染:
2025年3月8日
而客户端首次渲染时因为 navigator.language 改用 en-US:
Mar 8, 2025
就会出现服务端 HTML 和客户端首次输出不一致。React 的 hydration 可能报告警告,某些节点还会被重新生成。
因此,首屏的 Locale、时区和关键格式化输入必须一致。常见做法是:
服务端决定 Locale
↓
将 Locale 注入初始数据
↓
客户端 Provider 使用同一个 Locale
↓
hydration 完成后再允许用户切换
如果服务端不知道用户时区,不要在首屏把时间格式化成依赖浏览器时区的文本。可以:
- 指定固定时区;
- 先显示 UTC;
- 在客户端 hydration 后再按用户时区更新;
- 或在用户资料中保存时区。
React Server Components 的边界
在支持 React Server Components 的框架中,服务端组件可以直接读取请求上下文并生成翻译后的文本,但客户端组件不能直接访问服务端模块或请求对象。
边界通常是:
服务端:
读取 Locale
加载目录
传递初始 Locale/数据
客户端:
处理交互
执行语言切换
使用客户端可访问的消息目录或翻译 Provider
带有 useState、事件处理器或浏览器 API 的组件仍然需要位于客户端边界内。不要因为使用了 React,就假设所有国际化逻辑都能在客户端运行。
十一、目录加载、代码分割和并发状态
目录较大时,通常不希望把所有语言都打进首屏:
async function loadCatalog(locale: SupportedLocale) {
switch (locale) {
case "zh-CN":
return (await import("./locales/zh-CN")).default;
case "en-US":
return (await import("./locales/en-US")).default;
}
}
语言切换的状态至少包括:
当前 Locale
目标 Locale
目录加载中
加载成功
加载失败
一个可靠的数据流是:
用户选择 en-US
↓
开始加载 en-US 目录
↓
加载期间继续显示旧目录或显示过渡状态
↓
成功后原子地更新 locale + catalog
↓
失败则保留旧语言并显示错误
不要先把 locale 改成 en-US,再过一段时间才设置目录。这样会产生短暂状态:
locale = en-US
catalog = zh-CN
组件可能用英文规则格式化数字,却显示中文消息,或者出现大量缺失键。
可用一个联合类型表达状态:
type I18nState =
| { status: "ready"; locale: SupportedLocale; catalog: Catalog }
| { status: "loading"; locale: SupportedLocale; catalog: Catalog }
| { status: "error"; locale: SupportedLocale; catalog: Catalog; error: Error };
其中 loading 状态保留旧目录,可以避免整个页面变成空白。React 19 的 Suspense、异步资源和框架路由可以改善加载体验,但它们不会自动解决“Locale 与目录必须同时切换”的一致性问题。
并发请求还会带来竞态:
用户先选择 ja-JP
→ 请求 A 开始
用户马上选择 en-US
→ 请求 B 开始
→ B 先完成,页面显示英文
→ A 后完成,错误地把页面改回日文
解决方法是使用请求序号或 AbortController:
let requestId = 0;
async function changeLocale(locale: SupportedLocale) {
const id = ++requestId;
const catalog = await loadCatalog(locale);
if (id !== requestId) {
return; // 过期响应,不再提交
}
commitLocaleAndCatalog(locale, catalog);
}
这不是 React 特有的问题,而是异步状态提交顺序的问题。
十二、错误处理和诊断
缺失消息的不同表现
常见失败表现包括:
页面显示空白
页面出现 message.key
页面混杂两种语言
服务端和客户端首屏不同
日期在用户之间差一天
数字显示成 NaN
它们对应的原因不同:
| 表现 | 常见原因 |
|---|---|
| 显示空白 | 缺失键被返回空字符串 |
| 显示错误键 | 目录缺失或键名拼写错误 |
| 混杂语言 | 单键回退或目录加载不完整 |
| hydration 警告 | 服务端和客户端 Locale/时区不同 |
| 日期差一天 | 时间点和显示时区未明确 |
NaN |
数字输入没有经过校验或解析失败 |
开发环境应尽早失败
可以在构建或测试阶段比较默认目录和其他目录:
function findMissingKeys(
base: Catalog,
target: Catalog,
): string[] {
return Object.keys(base).filter((key) => !(key in target));
}
但“目标目录比默认目录多了键”也值得检查,因为这可能表示键名拼写错误。生产环境不应把翻译平台的缺失问题留给用户首次点击页面时发现。
测试至少应覆盖:
支持的 Locale 能加载目录
非法 Locale 会重定向或使用默认值
默认目录中的键在其他语言中有对应项
插值参数缺失时有诊断标记
日期格式化指定了预期时区
货币代码与金额单位一致
语言切换保留当前业务路径
服务端与客户端首屏 Locale 一致
不要把“缺少翻译”和“翻译文本为空”混为一谈
有些产品确实需要空消息,例如某个可选副标题。此时目录模型应显式表示:
type MessageEntry =
| { kind: "text"; value: string }
| { kind: "intentionally-empty" };
否则把 "" 统一当成缺失,会覆盖业务上合法的空内容。
十三、常见误解和边界
误解一:只翻译可见文本就完成国际化
错误。占位符、错误信息、无障碍标签、按钮 aria-label、文档标题、通知、邮件模板、服务端错误页都可能包含用户可见文本。
<button aria-label={t("cart.remove", { name })}>
<TrashIcon />
</button>
误解二:Locale 只影响翻译
错误。日期、数字、货币、复数、排序和文本方向同样依赖 Locale。只替换消息目录而继续使用 toFixed()、手工日期格式和固定货币符号,仍然不是完整国际化。
误解三:回退到默认语言就不会出错
错误。回退会隐藏目录质量问题。用户看到一段英文并不一定能知道某个中文翻译缺失,开发团队也可能因此不修复。回退应伴随日志、监控或构建检查。
误解四:Intl 会自动加载应用翻译
错误。Intl.DateTimeFormat 和 Intl.NumberFormat 只提供运行环境的格式化能力;它们不会知道你的 home.title,也不会从翻译平台读取目录。
误解五:把用户浏览器语言作为唯一来源
错误。浏览器语言是环境偏好,不一定等于账户偏好,也不一定适合当前链接。URL Locale、账户设置和 Cookie 必须按照产品规则建立优先级。
十四、一个可验证的最小验收流程
以支持 zh-CN 和 en-US 的应用为例,可以按以下顺序验证:
- 访问
/zh-CN/products; - 确认消息显示中文;
- 确认价格使用
CNY和zh-CN格式; - 确认日期指定了业务要求的时区;
- 切换到英文;
- 确认地址变为
/en-US/products,业务路径仍为products; - 暂时删除英文目录中的一个键;
- 确认开发环境出现
⟦missing:key⟧或测试失败; - 用无效路径
/xx/products; - 确认系统重定向、404 或使用明确默认 Locale;
- 在服务端渲染和客户端 hydration 中使用同一个 Locale;
- 快速连续切换两种语言,确认旧请求不会覆盖新选择。
这套验证同时检查了消息目录、Locale、日期数字、路由和回退之间的因果链,而不是只验证某个按钮是否出现英文。
国际化的核心不是在组件外包一层 t(),而是建立一套一致的数据流:路由或请求确定 Locale,Locale 决定目录和格式化规则,Provider 向 React 树提供能力,缺失资源沿明确链路回退,异步加载和服务端渲染保持状态一致。只要这条链路清楚,后续替换翻译库、增加语言或接入框架的服务端能力,都只是实现层变化,而不会破坏应用的基本模型。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React PWA:Service Worker、缓存更新、离线和安装体验
- 下一篇:React Monorepo:Workspace、共享包、构建图、版本和边界
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论