WR Blog 加载中...
返回文章
React前端国际化i18n

React 国际化:消息目录、Locale、日期数字、路由和回退

React 国际化:消息目录、Locale、日期数字、路由和回退封面

React 基础体系 · 第 57/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 国际化:消息目录、Locale、日期数字、路由和回退

国际化(internationalization,通常简称 i18n)不是把中文字符串替换成英文字符串,而是让同一套 React 代码能够根据用户的语言、地区、数字系统、时区和路由上下文,生成正确的界面。

一个完整的国际化系统至少包含五个相互关联的部分:

  1. 消息目录(message catalog):保存可翻译的界面文本。
  2. Locale:描述语言、地区以及可选的数字系统、日历等规则。
  3. 日期和数字格式化:依据 Locale 生成日期、时间、货币、百分比等文本。
  4. 路由中的语言标识:让语言选择可分享、可恢复、可被服务端识别。
  5. 回退(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_CNenglishcn 等值不是可靠的 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>

这样做的因果关系是:

  1. 组件只依赖 home.title 这个语义标识;
  2. 翻译人员可以修改目录,不需要修改 JSX;
  3. 同一个消息可以在多个组件中复用;
  4. 可以通过静态检查或测试发现缺失键;
  5. 语言切换只需要更新 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 的确定:优先级必须明确

一个请求可能同时提供多个语言来源:

  1. URL 中的语言,例如 /en-US/products
  2. 用户账户偏好;
  3. Cookie;
  4. Accept-Language 请求头;
  5. 浏览器的 navigator.languages
  6. 应用默认 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) 表示 Locale l 下键 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 的时区策略由调用方传入;
  • 所有目录被静态打包,目录很大时会增加首屏体积;
  • numberdate 的缓存只在当前 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 决定。应用不应该把所有语言都压缩成 oneother 两类。

成熟的 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>
  );
}

关键路径是:

  1. 从 URL 读取字符串;
  2. 校验它是否属于支持集合;
  3. 非法值重定向或返回 404;
  4. 合法值传入 Provider;
  5. 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
  • 用户会话。

然后在服务端完成:

  1. Locale 校验;
  2. 消息目录加载;
  3. 页面初始渲染;
  4. 将 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.DateTimeFormatIntl.NumberFormat 只提供运行环境的格式化能力;它们不会知道你的 home.title,也不会从翻译平台读取目录。

误解五:把用户浏览器语言作为唯一来源

错误。浏览器语言是环境偏好,不一定等于账户偏好,也不一定适合当前链接。URL Locale、账户设置和 Cookie 必须按照产品规则建立优先级。


十四、一个可验证的最小验收流程

以支持 zh-CNen-US 的应用为例,可以按以下顺序验证:

  1. 访问 /zh-CN/products
  2. 确认消息显示中文;
  3. 确认价格使用 CNYzh-CN 格式;
  4. 确认日期指定了业务要求的时区;
  5. 切换到英文;
  6. 确认地址变为 /en-US/products,业务路径仍为 products
  7. 暂时删除英文目录中的一个键;
  8. 确认开发环境出现 ⟦missing:key⟧ 或测试失败;
  9. 用无效路径 /xx/products
  10. 确认系统重定向、404 或使用明确默认 Locale;
  11. 在服务端渲染和客户端 hydration 中使用同一个 Locale;
  12. 快速连续切换两种语言,确认旧请求不会覆盖新选择。

这套验证同时检查了消息目录、Locale、日期数字、路由和回退之间的因果链,而不是只验证某个按钮是否出现英文。

国际化的核心不是在组件外包一层 t(),而是建立一套一致的数据流:路由或请求确定 Locale,Locale 决定目录和格式化规则,Provider 向 React 树提供能力,缺失资源沿明确链路回退,异步加载和服务端渲染保持状态一致。只要这条链路清楚,后续替换翻译库、增加语言或接入框架的服务端能力,都只是实现层变化,而不会破坏应用的基本模型。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
WR Blog 加载中...
返回文章
React前端国际化i18n

React 国际化:消息目录、Locale、日期数字、路由和回退

React 国际化:消息目录、Locale、日期数字、路由和回退封面

React 基础体系 · 第 57/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 国际化:消息目录、Locale、日期数字、路由和回退

国际化(internationalization,通常简称 i18n)不是把中文字符串替换成英文字符串,而是让同一套 React 代码能够根据用户的语言、地区、数字系统、时区和路由上下文,生成正确的界面。

一个完整的国际化系统至少包含五个相互关联的部分:

  1. 消息目录(message catalog):保存可翻译的界面文本。
  2. Locale:描述语言、地区以及可选的数字系统、日历等规则。
  3. 日期和数字格式化:依据 Locale 生成日期、时间、货币、百分比等文本。
  4. 路由中的语言标识:让语言选择可分享、可恢复、可被服务端识别。
  5. 回退(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_CNenglishcn 等值不是可靠的 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>

这样做的因果关系是:

  1. 组件只依赖 home.title 这个语义标识;
  2. 翻译人员可以修改目录,不需要修改 JSX;
  3. 同一个消息可以在多个组件中复用;
  4. 可以通过静态检查或测试发现缺失键;
  5. 语言切换只需要更新 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 的确定:优先级必须明确

一个请求可能同时提供多个语言来源:

  1. URL 中的语言,例如 /en-US/products
  2. 用户账户偏好;
  3. Cookie;
  4. Accept-Language 请求头;
  5. 浏览器的 navigator.languages
  6. 应用默认 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) 表示 Locale l 下键 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 的时区策略由调用方传入;
  • 所有目录被静态打包,目录很大时会增加首屏体积;
  • numberdate 的缓存只在当前 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 决定。应用不应该把所有语言都压缩成 oneother 两类。

成熟的 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>
  );
}

关键路径是:

  1. 从 URL 读取字符串;
  2. 校验它是否属于支持集合;
  3. 非法值重定向或返回 404;
  4. 合法值传入 Provider;
  5. 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
  • 用户会话。

然后在服务端完成:

  1. Locale 校验;
  2. 消息目录加载;
  3. 页面初始渲染;
  4. 将 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.DateTimeFormatIntl.NumberFormat 只提供运行环境的格式化能力;它们不会知道你的 home.title,也不会从翻译平台读取目录。

误解五:把用户浏览器语言作为唯一来源

错误。浏览器语言是环境偏好,不一定等于账户偏好,也不一定适合当前链接。URL Locale、账户设置和 Cookie 必须按照产品规则建立优先级。


十四、一个可验证的最小验收流程

以支持 zh-CNen-US 的应用为例,可以按以下顺序验证:

  1. 访问 /zh-CN/products
  2. 确认消息显示中文;
  3. 确认价格使用 CNYzh-CN 格式;
  4. 确认日期指定了业务要求的时区;
  5. 切换到英文;
  6. 确认地址变为 /en-US/products,业务路径仍为 products
  7. 暂时删除英文目录中的一个键;
  8. 确认开发环境出现 ⟦missing:key⟧ 或测试失败;
  9. 用无效路径 /xx/products
  10. 确认系统重定向、404 或使用明确默认 Locale;
  11. 在服务端渲染和客户端 hydration 中使用同一个 Locale;
  12. 快速连续切换两种语言,确认旧请求不会覆盖新选择。

这套验证同时检查了消息目录、Locale、日期数字、路由和回退之间的因果链,而不是只验证某个按钮是否出现英文。

国际化的核心不是在组件外包一层 t(),而是建立一套一致的数据流:路由或请求确定 Locale,Locale 决定目录和格式化规则,Provider 向 React 树提供能力,缺失资源沿明确链路回退,异步加载和服务端渲染保持状态一致。只要这条链路清楚,后续替换翻译库、增加语言或接入框架的服务端能力,都只是实现层变化,而不会破坏应用的基本模型。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
可能表示 USD、CAD、AUD 等多种货币,数据模型应保存 ISO 4217 代码,例如:\n\n```ts\ntype Money = {\n amount: number;\n currency: \"USD\" | \"CNY\" | \"EUR\";\n};\n```\n\n### 浮点数不是金额模型\n\n如果金额来自整数分:\n\n```ts\nconst cents = 123456;\nconst amount = cents / 100;\n\nnew Intl.NumberFormat(\"en-US\", {\n style: \"currency\",\n currency: \"USD\",\n}).format(amount);\n// \"$1,234.56\"\n```\n\n但不能因此认为 JavaScript 浮点数能精确表示所有金额运算:\n\n```ts\n0.1 + 0.2 === 0.3;\n// false\n```\n\n金额的加减乘除应根据业务精度使用整数最小单位或十进制定点库,最后再交给 `Intl.NumberFormat` 展示。\n\n---\n\n## 八、复数、选择和富文本消息\n\n### 使用 `Intl.PluralRules` 不能只判断是否等于 1\n\n错误实现:\n\n```ts\nconst text = count === 1 ? \"item\" : \"items\";\n```\n\n这只适用于非常有限的英语场景。可以先观察 `Intl.PluralRules` 的结果:\n\n```ts\nnew Intl.PluralRules(\"en\").select(1);\n// \"one\"\n\nnew Intl.PluralRules(\"en\").select(2);\n// \"other\"\n\nnew Intl.PluralRules(\"ar\").select(2);\n// \"two\"\n```\n\n正确的复数类别由 Locale 决定。应用不应该把所有语言都压缩成 `one` 和 `other` 两类。\n\n成熟的 ICU 消息示例:\n\n```text\n{count, plural,\n =0 {购物车为空}\n one {购物车中有 # 件商品}\n other {购物车中有 # 件商品}\n}\n```\n\n注意 `#` 的格式也应使用当前 Locale 的数字规则,而不是直接调用 `String(count)`。\n\n### 富文本不要拼接 HTML\n\n不要把翻译内容直接作为 HTML:\n\n```tsx\n\u003cdiv dangerouslySetInnerHTML={{ __html: translatedText }} />\n```\n\n翻译文件通常来自仓库或外部平台,直接插入 HTML 会扩大 XSS 风险,也会让 React 的元素边界丢失。\n\n更安全的模型是让消息库接收组件回调,例如概念上:\n\n```tsx\n\u003cTrans\n id=\"terms.accept\"\n values={{ product: \"WR BLOG\" }}\n components={{\n link: \u003ca href=\"/terms\" />,\n }}\n/>\n```\n\n实际库的 API 不完全相同,不能把此段当作任意库都可直接复制的代码。核心约束是:链接和强调元素应作为受控 React 节点生成,而不是把未验证的 HTML 字符串注入 DOM。\n\n---\n\n## 九、路由中的 Locale:语言选择应成为可恢复的应用状态\n\n### 为什么把 Locale 放进 URL\n\n例如:\n\n```text\n/zh-CN/products\n/en-US/products\n```\n\n这样有几个直接结果:\n\n- 页面可分享;\n- 刷新后语言不会丢失;\n- 服务端可以在渲染前确定 Locale;\n- 搜索引擎可以区分不同语言页面;\n- 浏览器前进、后退会恢复语言状态。\n\n如果只把语言放在 React Context 或 `localStorage` 中,用户复制链接给别人时,链接本身并不包含语言信息。\n\n### 路由参数必须校验\n\n以 React Router 风格的配置为例:\n\n```tsx\nimport { Navigate, useParams } from \"react-router\";\n\nconst supportedLocales = [\"zh-CN\", \"en-US\"] as const;\ntype SupportedLocale = (typeof supportedLocales)[number];\n\nfunction isSupportedLocale(\n value: string | undefined,\n): value is SupportedLocale {\n return (\n value !== undefined &&\n (supportedLocales as readonly string[]).includes(value)\n );\n}\n\nfunction LocaleRoute() {\n const { locale } = useParams();\n\n if (!isSupportedLocale(locale)) {\n return \u003cNavigate to=\"/en-US\" replace />;\n }\n\n return (\n \u003cI18nProvider locale={locale}>\n \u003cCartSummary />\n \u003c/I18nProvider>\n );\n}\n```\n\n关键路径是:\n\n1. 从 URL 读取字符串;\n2. 校验它是否属于支持集合;\n3. 非法值重定向或返回 404;\n4. 合法值传入 Provider;\n5. Provider 向所有后代组件提供同一个 Locale。\n\n不要直接把任意 URL 参数传给 `Intl.DateTimeFormat` 或动态文件路径。非法 Locale 可能抛出 `RangeError`,动态目录路径还可能造成资源探测或错误加载。\n\n### 切换语言时保留业务路径\n\n当前地址:\n\n```text\n/zh-CN/products/42?tab=reviews\n```\n\n切换到英文后,理想结果是:\n\n```text\n/en-US/products/42?tab=reviews\n```\n\n只替换 Locale 段,不要跳回首页。可以先拆分路径,再使用路由库的导航 API;不要用简单的全局字符串替换,因为业务路径中可能也出现类似文本。\n\n语言切换通常是导航行为,而不只是状态更新:\n\n```tsx\nfunction LanguageSwitcher() {\n const { locale } = useI18n();\n\n function changeLocale(nextLocale: SupportedLocale) {\n const path = window.location.pathname;\n const segments = path.split(\"/\");\n\n segments[1] = nextLocale;\n const nextPath = segments.join(\"/\") + window.location.search;\n\n window.history.pushState({}, \"\", nextPath);\n window.location.reload();\n }\n\n return (\n \u003cselect\n value={locale}\n onChange={(event) =>\n changeLocale(event.target.value as SupportedLocale)\n }\n >\n \u003coption value=\"zh-CN\">简体中文\u003c/option>\n \u003coption value=\"en-US\">English\u003c/option>\n \u003c/select>\n );\n}\n```\n\n这段代码通过刷新重新建立 Provider,逻辑简单但体验一般。客户端应用可以改为使用路由库的导航方法并异步加载目录,然后在加载完成后更新 Provider。无论采用哪种方式,URL 都应是语言状态的来源之一。\n\n---\n\n## 十、客户端与服务端边界\n\n### 服务端必须根据请求确定 Locale\n\n服务端不能读取:\n\n```ts\nnavigator.language\nlocalStorage\n```\n\n因为这些对象只存在于浏览器。服务端应从请求中读取:\n\n- URL 参数;\n- Cookie;\n- `Accept-Language`;\n- 用户会话。\n\n然后在服务端完成:\n\n1. Locale 校验;\n2. 消息目录加载;\n3. 页面初始渲染;\n4. 将 Locale 和必要目录传给客户端边界。\n\n伪代码如下:\n\n```ts\nasync function renderRequest(request: Request) {\n const url = new URL(request.url);\n const requestedLocale = url.pathname.split(\"/\")[1];\n\n const locale = isSupportedLocale(requestedLocale)\n ? requestedLocale\n : \"en-US\";\n\n const catalog = await loadCatalog(locale);\n\n return renderApp({\n locale,\n catalog,\n });\n}\n```\n\n这里的 `loadCatalog` 可以读取服务端文件、数据库或构建产物。它不是 React API,而是应用或框架的资源加载层。\n\n### 避免 hydration 不匹配\n\n如果服务端用 `zh-CN` 渲染:\n\n```text\n2025年3月8日\n```\n\n而客户端首次渲染时因为 `navigator.language` 改用 `en-US`:\n\n```text\nMar 8, 2025\n```\n\n就会出现服务端 HTML 和客户端首次输出不一致。React 的 hydration 可能报告警告,某些节点还会被重新生成。\n\n因此,首屏的 Locale、时区和关键格式化输入必须一致。常见做法是:\n\n```text\n服务端决定 Locale\n ↓\n将 Locale 注入初始数据\n ↓\n客户端 Provider 使用同一个 Locale\n ↓\nhydration 完成后再允许用户切换\n```\n\n如果服务端不知道用户时区,不要在首屏把时间格式化成依赖浏览器时区的文本。可以:\n\n- 指定固定时区;\n- 先显示 UTC;\n- 在客户端 hydration 后再按用户时区更新;\n- 或在用户资料中保存时区。\n\n### React Server Components 的边界\n\n在支持 React Server Components 的框架中,服务端组件可以直接读取请求上下文并生成翻译后的文本,但客户端组件不能直接访问服务端模块或请求对象。\n\n边界通常是:\n\n```text\n服务端:\n 读取 Locale\n 加载目录\n 传递初始 Locale/数据\n\n客户端:\n 处理交互\n 执行语言切换\n 使用客户端可访问的消息目录或翻译 Provider\n```\n\n带有 `useState`、事件处理器或浏览器 API 的组件仍然需要位于客户端边界内。不要因为使用了 React,就假设所有国际化逻辑都能在客户端运行。\n\n---\n\n## 十一、目录加载、代码分割和并发状态\n\n目录较大时,通常不希望把所有语言都打进首屏:\n\n```ts\nasync function loadCatalog(locale: SupportedLocale) {\n switch (locale) {\n case \"zh-CN\":\n return (await import(\"./locales/zh-CN\")).default;\n case \"en-US\":\n return (await import(\"./locales/en-US\")).default;\n }\n}\n```\n\n语言切换的状态至少包括:\n\n```text\n当前 Locale\n目标 Locale\n目录加载中\n加载成功\n加载失败\n```\n\n一个可靠的数据流是:\n\n```text\n用户选择 en-US\n ↓\n开始加载 en-US 目录\n ↓\n加载期间继续显示旧目录或显示过渡状态\n ↓\n成功后原子地更新 locale + catalog\n ↓\n失败则保留旧语言并显示错误\n```\n\n不要先把 `locale` 改成 `en-US`,再过一段时间才设置目录。这样会产生短暂状态:\n\n```text\nlocale = en-US\ncatalog = zh-CN\n```\n\n组件可能用英文规则格式化数字,却显示中文消息,或者出现大量缺失键。\n\n可用一个联合类型表达状态:\n\n```ts\ntype I18nState =\n | { status: \"ready\"; locale: SupportedLocale; catalog: Catalog }\n | { status: \"loading\"; locale: SupportedLocale; catalog: Catalog }\n | { status: \"error\"; locale: SupportedLocale; catalog: Catalog; error: Error };\n```\n\n其中 `loading` 状态保留旧目录,可以避免整个页面变成空白。React 19 的 `Suspense`、异步资源和框架路由可以改善加载体验,但它们不会自动解决“Locale 与目录必须同时切换”的一致性问题。\n\n并发请求还会带来竞态:\n\n```text\n用户先选择 ja-JP\n → 请求 A 开始\n\n用户马上选择 en-US\n → 请求 B 开始\n → B 先完成,页面显示英文\n → A 后完成,错误地把页面改回日文\n```\n\n解决方法是使用请求序号或 `AbortController`:\n\n```ts\nlet requestId = 0;\n\nasync function changeLocale(locale: SupportedLocale) {\n const id = ++requestId;\n const catalog = await loadCatalog(locale);\n\n if (id !== requestId) {\n return; // 过期响应,不再提交\n }\n\n commitLocaleAndCatalog(locale, catalog);\n}\n```\n\n这不是 React 特有的问题,而是异步状态提交顺序的问题。\n\n---\n\n## 十二、错误处理和诊断\n\n### 缺失消息的不同表现\n\n常见失败表现包括:\n\n```text\n页面显示空白\n页面出现 message.key\n页面混杂两种语言\n服务端和客户端首屏不同\n日期在用户之间差一天\n数字显示成 NaN\n```\n\n它们对应的原因不同:\n\n| 表现 | 常见原因 |\n|---|---|\n| 显示空白 | 缺失键被返回空字符串 |\n| 显示错误键 | 目录缺失或键名拼写错误 |\n| 混杂语言 | 单键回退或目录加载不完整 |\n| hydration 警告 | 服务端和客户端 Locale/时区不同 |\n| 日期差一天 | 时间点和显示时区未明确 |\n| `NaN` | 数字输入没有经过校验或解析失败 |\n\n### 开发环境应尽早失败\n\n可以在构建或测试阶段比较默认目录和其他目录:\n\n```ts\nfunction findMissingKeys(\n base: Catalog,\n target: Catalog,\n): string[] {\n return Object.keys(base).filter((key) => !(key in target));\n}\n```\n\n但“目标目录比默认目录多了键”也值得检查,因为这可能表示键名拼写错误。生产环境不应把翻译平台的缺失问题留给用户首次点击页面时发现。\n\n测试至少应覆盖:\n\n```text\n支持的 Locale 能加载目录\n非法 Locale 会重定向或使用默认值\n默认目录中的键在其他语言中有对应项\n插值参数缺失时有诊断标记\n日期格式化指定了预期时区\n货币代码与金额单位一致\n语言切换保留当前业务路径\n服务端与客户端首屏 Locale 一致\n```\n\n### 不要把“缺少翻译”和“翻译文本为空”混为一谈\n\n有些产品确实需要空消息,例如某个可选副标题。此时目录模型应显式表示:\n\n```ts\ntype MessageEntry =\n | { kind: \"text\"; value: string }\n | { kind: \"intentionally-empty\" };\n```\n\n否则把 `\"\"` 统一当成缺失,会覆盖业务上合法的空内容。\n\n---\n\n## 十三、常见误解和边界\n\n### 误解一:只翻译可见文本就完成国际化\n\n错误。占位符、错误信息、无障碍标签、按钮 `aria-label`、文档标题、通知、邮件模板、服务端错误页都可能包含用户可见文本。\n\n```tsx\n\u003cbutton aria-label={t(\"cart.remove\", { name })}>\n \u003cTrashIcon />\n\u003c/button>\n```\n\n### 误解二:Locale 只影响翻译\n\n错误。日期、数字、货币、复数、排序和文本方向同样依赖 Locale。只替换消息目录而继续使用 `toFixed()`、手工日期格式和固定货币符号,仍然不是完整国际化。\n\n### 误解三:回退到默认语言就不会出错\n\n错误。回退会隐藏目录质量问题。用户看到一段英文并不一定能知道某个中文翻译缺失,开发团队也可能因此不修复。回退应伴随日志、监控或构建检查。\n\n### 误解四:`Intl` 会自动加载应用翻译\n\n错误。`Intl.DateTimeFormat` 和 `Intl.NumberFormat` 只提供运行环境的格式化能力;它们不会知道你的 `home.title`,也不会从翻译平台读取目录。\n\n### 误解五:把用户浏览器语言作为唯一来源\n\n错误。浏览器语言是环境偏好,不一定等于账户偏好,也不一定适合当前链接。URL Locale、账户设置和 Cookie 必须按照产品规则建立优先级。\n\n---\n\n## 十四、一个可验证的最小验收流程\n\n以支持 `zh-CN` 和 `en-US` 的应用为例,可以按以下顺序验证:\n\n1. 访问 `/zh-CN/products`;\n2. 确认消息显示中文;\n3. 确认价格使用 `CNY` 和 `zh-CN` 格式;\n4. 确认日期指定了业务要求的时区;\n5. 切换到英文;\n6. 确认地址变为 `/en-US/products`,业务路径仍为 `products`;\n7. 暂时删除英文目录中的一个键;\n8. 确认开发环境出现 `⟦missing:key⟧` 或测试失败;\n9. 用无效路径 `/xx/products`;\n10. 确认系统重定向、404 或使用明确默认 Locale;\n11. 在服务端渲染和客户端 hydration 中使用同一个 Locale;\n12. 快速连续切换两种语言,确认旧请求不会覆盖新选择。\n\n这套验证同时检查了消息目录、Locale、日期数字、路由和回退之间的因果链,而不是只验证某个按钮是否出现英文。\n\n国际化的核心不是在组件外包一层 `t()`,而是建立一套一致的数据流:**路由或请求确定 Locale,Locale 决定目录和格式化规则,Provider 向 React 树提供能力,缺失资源沿明确链路回退,异步加载和服务端渲染保持状态一致**。只要这条链路清楚,后续替换翻译库、增加语言或接入框架的服务端能力,都只是实现层变化,而不会破坏应用的基本模型。\n\n---\n\n## 系列导航与关联阅读\n\n- 系列入口:[React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构](https://wrblog.cn/articles/be139e9f-2a67-5fa2-91c0-e30edb8bf97c)\n- 上一篇:[React PWA:Service Worker、缓存更新、离线和安装体验](https://wrblog.cn/articles/b6382ba0-7701-5dc1-b31e-c3e2130570c1)\n- 下一篇:[React Monorepo:Workspace、共享包、构建图、版本和边界](https://wrblog.cn/articles/9cbc508f-aef4-5232-a25b-f9aadf13741b)\n\n## 官方资料\n\n- [React Documentation](https://react.dev/)\n- [React API Reference](https://react.dev/reference/react)\n\n> 本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。\n","tags":["React","前端","国际化","i18n"],"likeCount":0,"commentCount":0,"createdByUserId":"10000000000","createdByDisplayName":"小郝","createdByAvatar":"/public/profile/10000000000/avatar/2026/08/04/db02b81c-42f2-441b-8a80-61370cdbb581.webp","publishTime":"2026-09-01 13:42:21","updateTime":"2026-09-01 13:42:21"}},"status":200,"locale":"zh-CN","theme":"light"}