陌上人如玉
公子世无双

10.7 国际化与主题

10.7 国际化与主题

一句话:国际化(i18n)的核心是「文案与代码分离 + 运行时切换」(用 react-i18next / next-intl,配合 Intl 做日期数字格式化);主题的核心是「CSS 变量 + data-theme 属性」(零运行时、无闪烁)。

一、i18n 方案

方案 特点 适合
react-i18next 生态最成熟、功能全(复数、插值、命名空间、懒加载) 大多数 React 项目
FormatJS(react-intl) 基于 ICU 标准、能力严谨 需要严格 ICU 支持的场景
next-intl 为 Next.js App Router 设计(Server Component 友好) Next.js 项目
自研(简单 Map) 无依赖、可控 文案极少、无复数需求
Lingui / Paraglide 编译时提取文案(零运行时) 追求体积与性能
⭐ 选型建议:
① Next.js App Router → next-intl(与 RSC/SSR 集成最好)
② 普通 SPA → react-i18next(生态最全)
③ 需要严格 ICU(复数/选择器复杂) → FormatJS
④ 只要几段文案 → 自研 Map 就够(别上框架)

二、文案的组织

src/
├── locales/
│   ├── zh-CN/
│   │   ├── common.json          通用文案(按钮、状态)
│   │   ├── auth.json            登录/注册
│   │   └── orders.json          订单模块
│   └── en-US/
│       ├── common.json
│       ├── auth.json
│       └── orders.json
// locales/zh-CN/auth.json
{
  "login": {
    "title": "登录",
    "email": "邮箱",
    "password": "密码",
    "submit": "登录",
    "errors": {
      "invalidEmail": "邮箱格式不正确",
      "wrongCredentials": "邮箱或密码错误"
    }
  },
  "welcome": "欢迎回来,{{name}}",
  "itemCount": "共 {{count}} 项",
  "itemCount_plural": "共 {{count}} 项"
}
⭐ 文案组织的三条原则:
① 【按「命名空间」拆文件】——common / auth / orders,避免一个巨型 JSON
   → 好处:可懒加载(进入某页才加载对应文案)
② 【用「语义化 key」而不是「原文」】
   ❌ { "登录": "Login" }        (key 是中文,改文案要改 key)
   ✅ { "login.submit": "Login" }(key 稳定,改文案只改 value)
③ 【不要拼接句子】——用「插值」而不是字符串相加
   ❌ t('hello') + name + t('suffix')     (其它语言的语序可能不同!)
   ✅ t('welcome', { name })
⚠️ 「不要拼接」是 i18n 最容易被忽略的坑:
   中文:「我买了 3 个苹果」
   英文:「I bought 3 apples」——语序、复数、冠词都不同
   如果把句子拆成「我买了」+「3」+「个苹果」再拼,
   英文就无法正确组合(甚至日语/阿拉伯语的语序完全不同)。
   → 必须用「带插值的完整句子」。

三、使用方式

// i18n.ts —— 初始化
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';

i18n
  .use(Backend)                 // 从 /locales/{{lng}}/{{ns}}.json 加载(支持懒加载)
  .use(LanguageDetector)        // 自动检测浏览器语言
  .use(initReactI18next)
  .init({
    fallbackLng: 'zh-CN',
    ns: ['common'],
    defaultNS: 'common',
    interpolation: { escapeValue: false },   // React 已经做了 XSS 转义
  });

export default i18n;
// ✅ 在组件里使用
import { useTranslation } from 'react-i18next';

function LoginForm() {
  const { t } = useTranslation(['auth', 'common']);      // ⭐ 指定命名空间

  return (
    <form>
      <h1>{t('auth:login.title')}</h1>                   {/* ⭐ ns:key 语法 */}
      <label htmlFor="email">{t('login.email')}</label>   {/* 默认命名空间可省略 */}
      <input id="email" />

      <button>{t('common:button.submit')}</button>

      {/* 插值 */}
      <p>{t('auth:welcome', { name: user.name })}</p>

      {/* 复数(i18next 约定:count 会选 itemCount_one / _other 等) */}
      <p>{t('auth:itemCount', { count: items.length })}</p>

      {/* 带格式的插值(React 元素) */}
      <p>{t('auth:agreement', { terms: <a href="/terms">条款</a> })}</p>
    </form>
  );
}
// 复数与插值的 JSON 结构(i18next 约定)
{
  "itemCount_one": "共 {{count}} 项",
  "itemCount_other": "共 {{count}} 项"
}
// 英文的复数(英文有单复数两种形式)
{
  "itemCount_one": "{{count}} item",
  "itemCount_other": "{{count}} items"
}
// ✅ 在「非组件」里使用(如请求层、工具函数)
import i18n from '@/i18n';
toast.error(i18n.t('common:error.network'));

// ✅ 类型安全(增强 i18next 的类型,让 key 有提示)
// src/types/i18next.d.ts
import 'i18next';
import common from '@/locales/zh-CN/common.json';

declare module 'i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common';
    resources: { common: typeof common };
  }
}

四、语言切换与持久化

// ✅ 语言切换组件
import { useTranslation } from 'react-i18next';

const LANGUAGES = [
  { code: 'zh-CN', label: '简体中文' },
  { code: 'en-US', label: 'English' },
  { code: 'ja-JP', label: '日本語' },
];

function LanguageSwitcher() {
  const { i18n } = useTranslation();

  const change = (code: string) => {
    i18n.changeLanguage(code);                  // ⭐ 切换(会触发所有使用 t 的组件重渲染)
    localStorage.setItem('lang', code);          // 持久化
    document.documentElement.lang = code;        // 更新 <html lang>(利于 a11y 与 SEO)
  };

  return (
    <select value={i18n.language} onChange={(e) => change(e.target.value)}>
      {LANGUAGES.map((l) => (
        <option key={l.code} value={l.code}>{l.label}</option>
      ))}
    </select>
  );
}
⭐ 语言状态的「四个存放位置」(要一致):
① 【内存】i18n 实例的当前语言
② 【持久化】localStorage / cookie(记住用户选择)
③ 【DOM】`<html lang="zh-CN">`(无障碍与 SEO)
④ 【URL】(如果是「多语言站点」)——见下一节

⚠️ 如果 SSR:
   · 要从「cookie 或 URL」读语言(服务端无法读 localStorage)
   · 服务端渲染的文案必须与客户端一致(否则 hydration mismatch,见 9.4)

五、日期、数字、货币格式化

// ✅ 用「Intl」(浏览器原生,不需要额外库)
function Formatters({ amount, date }: { amount: number; date: string }) {
  const { i18n } = useTranslation();
  const locale = i18n.language;

  // 日期
  const formattedDate = new Intl.DateTimeFormat(locale, {
    year: 'numeric', month: 'long', day: 'numeric',
  }).format(new Date(date));

  // 相对时间
  const relative = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });

  // 数字(千分位)
  const formattedNumber = new Intl.NumberFormat(locale).format(1234567.89);

  // 货币
  const formattedCurrency = new Intl.NumberFormat(locale, {
    style: 'currency', currency: 'CNY',
  }).format(amount);

  // 百分比
  const formattedPercent = new Intl.NumberFormat(locale, {
    style: 'percent', minimumFractionDigits: 1,
  }).format(0.856);

  return (
    <ul>
      <li>{formattedDate}</li>
      <li>{relative.format(-3, 'day')}</li>
      <li>{formattedNumber}</li>
      <li>{formattedCurrency}</li>
      <li>{formattedPercent}</li>
    </ul>
  );
}
⭐ 「格式化」的两个重点:
① 【用 Intl 而不是自己拼】——它自带「各语言的正确格式与规则」
   (如「千分位符号」「日期顺序」「货币符号位置」在不同语言下都不同)
② 【SSR 要显式指定时区与 locale】——否则服务端(UTC)与客户端(本地时区)
   产生不同结果 → hydration mismatch(见 9.4)
// ⚠️ SSR 场景:显式指定时区
new Intl.DateTimeFormat(locale, { timeZone: 'Asia/Shanghai', ... }).format(date);
// 或统一用「UTC + 固定格式」,避免因「服务器时区」不同而变

六、多语言站点的路由(URL 与 SEO)

三种 URL 策略:

① 子路径(推荐):example.com/zh-CN/blog、example.com/en-US/blog
   · 优点:SEO 友好(每种语言有独立 URL)、便于 CDN 缓存
  ② 子域名:zh.example.com、en.example.com
  ③ 查询参数:example.com/blog?lang=zh(❌ 不推荐,SEO 差)

⭐ 关键 SEO 要素:
① 每种语言一个独立 URL(爬虫能分别索引)
② `<html lang="zh-CN">`(当前页面语言)
③ `hreflang` 标签(告诉搜索引擎「这些 URL 是同一内容的不同语言版本」)
④ canonical 指向「自己语言的版本」(不是指向默认语言)
// ✅ 在 <head> 里声明 hreflang
// Next.js 的 metadata API
export const metadata: Metadata = {
  alternates: {
    canonical: 'https://example.com/zh-CN/blog',
    languages: {
      'zh-CN': 'https://example.com/zh-CN/blog',
      'en-US': 'https://example.com/en-US/blog',
      'x-default': 'https://example.com/blog',       // ⭐ 未匹配语言时的默认页
    },
  },
};
// next-intl 的 App Router 用法(结构示意)
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';

const locales = ['zh-CN', 'en-US'] as const;

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  if (!locales.includes(locale as (typeof locales)[number])) notFound();

  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
      </body>
    </html>
  );
}
// ⭐ 语言作为「路由参数」→ 服务端与客户端都从这里读 → 天然一致(无水合问题)

七、主题切换(无闪烁实现)

/* ① 用 CSS 变量定义主题 */
:root {
  --color-bg: #ffffff;
  --color-fg: #111827;
  --color-brand: #2563eb;
  --color-border: #e5e7eb;
}

[data-theme='dark'] {
  --color-bg: #0f172a;
  --color-fg: #e5e7eb;
  --color-brand: #60a5fa;
  --color-border: #334155;
}

body {
  background: var(--color-bg);
  color: var(--color-fg);
  transition: background-color 0.2s, color 0.2s;   /* ⚠️ 可选:过渡会有「渐变动画」 */
}
<!-- ② 在 index.html 的 <head> 里「渲染前」确定主题(避免 FOUC) -->
<script>
  (function () {
    try {
      var saved = localStorage.getItem('theme');
      var system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
      var theme = saved || system;
      document.documentElement.dataset.theme = theme;
    } catch (e) {}
  })();
</script>
<!-- ⭐ 这段必须「内联且同步」(在 CSS/内容之前执行),否则会闪 -->
// ③ React 里的主题管理
type Theme = 'light' | 'dark' | 'system';

function useTheme() {
  const [theme, setTheme] = useState<Theme>(() => {
    // ⚠️ SSR 时不能读 localStorage → 用「默认值」保证两端一致
    if (typeof window === 'undefined') return 'system';
    return (localStorage.getItem('theme') as Theme | null) ?? 'system';
  });

  useEffect(() => {
    const root = document.documentElement;

    const apply = () => {
      const resolved =
        theme === 'system'
          ? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
          : theme;
      root.dataset.theme = resolved;
    };

    apply();
    localStorage.setItem('theme', theme);

    // 跟随系统变化
    if (theme === 'system') {
      const mql = window.matchMedia('(prefers-color-scheme: dark)');
      const onChange = () => apply();
      mql.addEventListener('change', onChange);
      return () => mql.removeEventListener('change', onChange);
    }
  }, [theme]);

  return { theme, setTheme };
}
// ④ 用 Context 提供给全局(避免每个组件都读 localStorage)
const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const value = useTheme();
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useThemeContext() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useThemeContext 必须在 ThemeProvider 内使用');
  return ctx;
}
⭐ 主题切换的三个要点:
① 【CSS 变量 + data-theme】——切换只改「一个属性」,不重新生成样式(零运行时)
② 【首屏无闪烁(无 FOUC)】——在 HTML 里内联脚本「渲染前」设置主题
   (或用 SSR:服务端从 cookie 读主题并注入 <html data-theme>)
③ 【三种模式】light / dark / system——system 模式要监听 `prefers-color-scheme`
⚠️ SSR 场景下的主题(Next.js):
   localStorage 在服务端读不到 → 用「cookie」:
   ① 用户切换主题时同时写 cookie
   ② 根布局 `cookies().get('theme')` → 设置 `<html data-theme={theme}>`
   → 服务端输出的 HTML 就带正确主题 → 客户端水合一致(无 mismatch、无闪烁)

八、与 SSR 的配合(避免 mismatch)

⚠️ i18n + SSR 的三个坑:

① 【文案不一致】
   服务端用「探测到的语言」,客户端用「localStorage 里的语言」→ 不一致
   ✅ 统一来源:都用「URL 参数 / cookie」(服务端能读到的)

② 【日期/数字格式不一致】
   服务端 UTC、客户端本地时区 → 格式化结果不同
   ✅ 显式指定 timeZone 与 locale

③ 【语言检测闪烁】
   服务端输出 default 语言,客户端立刻切成用户语言 → 内容闪变
   ✅ 让「第一步的语言判定在服务端完成」(URL/cookie)
// ✅ 语言作为「路由参数」是最稳的做法(服务端与客户端天然一致)
// app/[locale]/page.tsx
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
  const { locale } = await params;
  const t = await getTranslations({ locale });       // 服务端取翻译
  return <h1>{t('home.title')}</h1>;                 // ⭐ 服务端已按 locale 渲染
}

九、常见坑

// ① 用「拼接」组装句子(其它语言语序/复数不对)
t('iBought') + count + t('apples')                   // ❌
t('boughtApples', { count })                          // ✅

// ② 用「中文当 key」(改文案要改 key,且无法复用)
{ "登录": "Login" }                                   // ❌
{ "login.submit": "Login" }                           // ✅

// ③ 把文案「硬编码」在组件里(漏掉的地方永远不翻译)
<button>提交</button>                                  // ⚠️ 需要 t('submit')
// ④ 忘记「翻译缺失」时的兜底(显示 key 或空白)
fallbackLng: 'zh-CN', saveMissing: true               // ✅
// ⑤ 复数只写单一形式(英文的 item/items 不分)
// ⑥ 日期用「手写格式」(英文/日文的日期顺序不同)
new Date().toISOString().slice(0, 10)                 // ⚠️ 只适合固定格式
new Intl.DateTimeFormat(locale).format(date)          // ✅
// ⑦ SSR 下「服务端与客户端语言不一致」→ mismatch(见 9.4)
// ⑧ 主题「首屏闪烁」(没有内联脚本在渲染前设置)
// ⑨ 主题用「重新生成样式」(CSS-in-JS 切换主题)而不是 CSS 变量
// ⑩ 忘记「跟随系统主题」(prefers-color-scheme)
// ⑪ 主题切换「不持久化」(刷新后回到默认)
// ⑫ `<html lang>` 没更新(a11y 与 SEO 都会受影响)
// ⑬ 多语言站点「URL 不变」(SEO 差、无法分享特定语言)
// ⑭ 忘记 hreflang(搜索引擎不知道各语言版本的对应关系)
// ⑮ 文案文件「全量加载」(首屏加载所有语言的文案)→ 按需加载
// ⑯ 用「图片里的文字」做多语言(无法翻译)
// ⑰ 忽略「文本长度差异」(德语/俄语往往比英文长 30%+ → 布局溢出)
// ⑱ 忽略「RTL 语言」(阿拉伯语/希伯来语要从右往左布局)
⭐ 「RTL」的处理(如果支持阿拉伯语/希伯来语):
① 用逻辑属性(`margin-inline-start` 而不是 `margin-left`)
② `<html dir="rtl">`
③ 用 `dir="auto"` 让浏览器根据内容自动判断
④ 图标/箭头要「镜像」(用 CSS 的 `transform: scaleX(-1)` 或逻辑图标)

十、面试延伸

  1. 「前端国际化怎么做?」
    三个层面:① 文案——用 react-i18next / next-intl,把文案抽到「按命名空间拆分的 JSON」里,用 t('ns:key', { 变量 }) 取用(支持插值与复数);② 格式化——用浏览器的 Intl 做「日期、数字、货币、相对时间、百分比」的本地化(不要手写格式);③ 语言状态——要在「内存(i18n 实例)+ 持久化(localStorage/cookie)+ DOM(<html lang>)+ URL(多语言站点)」四处保持一致。最关键的细节是「不要拼接句子」(用插值,否则语序与复数会出错)。

  2. 「为什么 i18n 不能用字符串拼接?」
    因为「不同语言的语序、复数、冠词、性别规则完全不同」。例如中文「我买了 3 个苹果」,英文是「I bought 3 apples」(语序相同但复数变化),日语是「りんごを 3 つ買いました」(语序不同),阿拉伯语的数字与名词形式更复杂。如果把句子拆成「我买了」+「3」+「个苹果」再拼,其它语言根本无法正确组合(甚至无法翻译)。所以必须用「带插值的完整句子」:t('boughtApples', { count })。

  3. 「i18n 的 key 为什么不用原文(中文)?」
    虽然「用原文当 key」({"登录": "Login"})写起来快,但有两个致命问题:① 改文案就要改 key(中文 key 变了,所有代码引用都要改),而「语义化 key」(login.submit)改文案只改 value;② 无法复用——「提交」在按钮、工具栏、弹窗里可能对应不同的英文(Submit / Apply / OK),用原文当 key 就区分不了。所以推荐「语义化 key + 命名空间」。

  4. 「复数怎么处理?」
    不要自己写 count === 1 ? 'item' : 'items'——因为不同语言的复数规则不同(英语 2 种、俄语 3 种、阿拉伯语 6 种、中文 1 种)。正确做法是用「支持 ICU 复数规则」的方案:i18next 的约定是 itemCount_one / itemCount_other(用 t('itemCount', { count }) 会自动选),FormatJS/react-intl 用 ICU MessageFormat 语法({count, plural, one {...} other {...}})。让库去处理规则,你只提供各形式的文案。

  5. 「主题切换怎么做到『无闪烁』?」
    关键是「在页面渲染前就确定了主题」。三种做法:① 内联脚本(最简单)——在 index.html 的 <head> 里内联一段同步脚本,在 CSS 与内容之前读取 localStorage 并设置 document.documentElement.dataset.theme;② 服务端注入(SSR)——服务端从 cookie 读主题并设置 <html data-theme="dark">(客户端水合一致,既无闪烁也无 mismatch);③ 用框架的主题库(next-themes 等,它内部就是这么做的)。核心是「不能让客户端 JS 在首屏渲染后才改主题」。

  6. 「主题切换为什么推荐 CSS 变量而不是 CSS-in-JS?」
    因为 CSS 变量切换主题只需「改一个属性」(document.documentElement.dataset.theme = 'dark')——CSS 会自动重新解析变量,不需要重新生成或加载样式(零运行时)。而 CSS-in-JS 的「主题切换」通常要「重新计算并插入大量样式」(有运行时开销,还可能出现「闪一下」)。此外 CSS 变量的方案天然支持「服务端预渲染正确主题」(服务端输出带 data-theme 的 HTML)。所以「CSS 变量 + data-* 属性」是现代主题方案的主流。

  7. 「多语言站点的 URL 怎么设计?」
    推荐「子路径」(example.com/zh-CN/blog、example.com/en-US/blog):① SEO 友好——每种语言有独立 URL,爬虫能分别索引;② 便于 CDN 缓存与分享(分享链接自带语言);③ 服务端能从 URL 读到语言(SSR 与客户端天然一致,避免 mismatch)。配套的 SEO 要素是:<html lang>、hreflang 标签(告诉搜索引擎「这些 URL 是同一内容的不同语言版本」,含 x-default)、以及「canonical 指向自己语言的版本」。不推荐查询参数(?lang=zh,SEO 差)。

  8. 「i18n 有哪些容易忽略的细节?」
    七个:① 文本长度差异——德语/俄语往往比英文长 30%+,布局要留余量(别用固定宽度的按钮);② RTL 语言(阿拉伯语/希伯来语)需要 dir="rtl" + 逻辑属性(margin-inline-start 而不是 margin-left);③ 日期/数字格式必须用 Intl(不同语言的分隔符、日期顺序、货币符号位置都不同);④ SSR 的时区与 locale 要显式指定(否则 hydration mismatch);⑤ 翻译缺失的兜底(fallbackLng + saveMissing);⑥ 文案的懒加载(不要首屏加载所有语言);⑦ 不要用图片承载文案(无法翻译、也不利于 SEO 与无障碍)。

一句话速记

i18n:选 react-i18next(SPA)/ next-intl(Next.js);文案按「命名空间拆 JSON」+「语义化 key(不用中文当 key)」组织,取用用 t('ns:key', { 变量, count });绝不要拼接句子(语序/复数会错),复数交给库(_one/_other 或 ICU);日期数字货币用 Intl(SSR 要指定 timeZone 与 locale);语言状态要在「内存 + 持久化 + <html lang> + URL」四处一致,多语言站点用「子路径 + hreflang」。主题:用「CSS 变量 + data-theme 属性」(零运行时、切换只改一个属性);无闪烁的关键是「渲染前确定主题」(内联脚本读 localStorage,或 SSR 从 cookie 注入 <html data-theme>);支持「light/dark/system」三模式并监听 prefers-color-scheme;RTL 要用逻辑属性;注意文本长度差异(德语/俄语更长)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.7 国际化与主题

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫