第 41 章 国际化 i18n 与字体加载

本章目标

  • 前端接入 react-i18next,运行时切换语言。
  • 文案放到 JSON,支持插值和复数。
  • 字体:西文 Inter、中文 Noto Sans SC 的加载与回退。
  • 系统语言自动检测。

一、依赖

pnpm add i18next react-i18next i18next-browser-languagedetector

二、资源结构

src/i18n/
├── index.ts
└── locales/
    ├── zh.json
    ├── en.json
    └── ja.json
// zh.json
{
  "common": { "play": "播放", "pause": "暂停", "search": "搜索", "settings": "设置" },
  "player": { "nowPlaying": "正在播放", "noSong": "未选择歌曲", "queueCount": "{{count}} 首歌" },
  "library": { "import": "导入音乐", "scanning": "扫描中…({{done}}/{{total}})" }
}

三、初始化

// src/i18n/index.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import zh from "./locales/zh.json";
import en from "./locales/en.json";
import ja from "./locales/ja.json";

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: { zh: { translation: zh }, en: { translation: en }, ja: { translation: ja } },
    fallbackLng: "en",
    interpolation: { escapeValue: false },
    detection: { order: ["localStorage","navigator"], caches: ["localStorage"] },
  });

export default i18n;

main.tsx 里 import "./i18n"。

四、使用

import { useTranslation } from "react-i18next";

function PlayButton() {
  const { t } = useTranslation();
  return <button>{t("common.play")}</button>;
}

function ScanStatus({ done, total }: { done: number; total: number }) {
  const { t } = useTranslation();
  return <div>{t("library.scanning", { done, total })}</div>;
}

五、运行时切换

import i18n from "@/i18n";
i18n.changeLanguage("en");

存偏好到 Rust 侧的 settings 表:

async function setLanguage(lang: string) {
  await i18n.changeLanguage(lang);
  await commands.settingsSet("lang", lang);
}

启动时读:

const lang = await commands.settingsGet("lang");
if (lang) i18n.changeLanguage(lang);

六、字体

包内字体

把 TTF/WOFF2 放到 public/fonts/。CSS:

@font-face {
  font-family: "Inter";
  src: url("/fonts/Inter-Variable.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-display: swap;
}
@font-face {
  font-family: "Noto Sans SC";
  src: url("/fonts/NotoSansSC-Regular.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}
body {
  font-family: "Inter", "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

font-display: swap 让系统字体先渲染,避免 FOIT(隐形文本闪烁)。

仅按需加载

10MB 的中文 WOFF2 很贵。可以只在用户选中文时加载:

async function ensureChineseFont() {
  if (document.fonts.check("16px 'Noto Sans SC'")) return;
  const font = new FontFace("Noto Sans SC", "url(/fonts/NotoSansSC-Regular.woff2)");
  await font.load();
  document.fonts.add(font);
}

七、Rust 侧错误信息

Rust 返回的错误要不要本地化?CloudTone 选不做:

  • 错误信息发给用户友好的封装层(前端 toast),英文原文保留在 log 供调试。
  • 前端根据 error.code 查 i18n 表。
#[derive(thiserror::Error, Debug, serde::Serialize, specta::Type)]
#[serde(tag = "code", rename_all = "camelCase")]
pub enum UserError {
    #[error("file not found")] FileNotFound,
    #[error("unsupported format")] UnsupportedFormat,
    #[error("network")] Network,
}

前端:

catch (e: any) {
  toast.error(t(`errors.${e.code ?? "unknown"}`));
}

八、系统语言检测

detection: { order: ["localStorage","navigator"], caches: ["localStorage"] }

navigator 会用 navigator.language。Tauri 上它等于 WebView 的语言(通常随系统)。想严格跟系统:

#[tauri::command] #[specta::specta]
pub fn system_locale() -> String {
    sys_locale::get_locale().unwrap_or("en-US".into())
}

前端调用 system_locale() 做兜底。

本章小结

  • i18next 的生态最成熟,React 集成优雅。
  • 字体按需加载节省安装包。
  • 错误码分离展示文案,扩展语言零成本。

动手时刻

  • 切换到 English,看界面变化。
  • 首次启动时自动跟随系统语言。

下一章:插件系统设计。