第二十二章:国际化与多语言支持

博客v1.0系列教程(Dioxus)博客 v1.0 系列教程 (Dioxus)

第二十二章:国际化与多语言支持

1. 国际化设计原则

┌──────────────────────────────────┐
│         应用代码                   │
│  t!("app.title")                  │
│  t!("article.count", n)           │
│  format_date(2024-01-15)          │
└──────────┬───────────────────────┘
           │ 通过 i18n 抽象层
           ▼
┌──────────────────────────────────┐
│        翻译数据源                  │
│  /locales/zh-CN.json              │
│  /locales/en-US.json              │
│  /locales/ja-JP.json              │
└──────────────────────────────────┘

核心设计:代码中不使用任何硬编码的展示文本,所有用户可见的字符串通过翻译函数提取。

2. 翻译文件组织

2.1 JSON 格式

// locales/zh-CN.json
{
  "app": {
    "title": "Blog SSR",
    "subtitle": "记录技术实践与项目心得"
  },
  "nav": {
    "home": "首页",
    "articles": "文章",
    "login": "登录",
    "logout": "退出"
  },
  "article": {
    "count": "共 {n} 篇文章",
    "reading_time": "阅读时间约 {min} 分钟",
    "published_at": "发布于 {date}",
    "tags": "标签",
    "share": "分享",
    "prev": "上一篇",
    "next": "下一篇"
  },
  "common": {
    "loading": "加载中...",
    "error": "出错了",
    "retry": "重试",
    "back_to_home": "返回首页",
    "theme_light": "浅色模式",
    "theme_dark": "深色模式"
  }
}
// locales/en-US.json
{
  "app": {
    "title": "Blog SSR",
    "subtitle": "Tech notes & project insights"
  },
  "nav": {
    "home": "Home",
    "articles": "Articles",
    "login": "Login",
    "logout": "Logout"
  },
  "article": {
    "count": "{n} articles in total",
    "reading_time": "{min} min read",
    "published_at": "Published on {date}",
    "tags": "Tags",
    "share": "Share",
    "prev": "Previous",
    "next": "Next"
  },
  "common": {
    "loading": "Loading...",
    "error": "Something went wrong",
    "retry": "Retry",
    "back_to_home": "Back to home",
    "theme_light": "Light mode",
    "theme_dark": "Dark mode"
  }
}

2.2 编译期嵌入翻译

// 将 JSON 翻译文件在编译时嵌入二进制
static ZH_CN: &str = include_str!("../locales/zh-CN.json");
static EN_US: &str = include_str!("../locales/en-US.json");

fn load_translations(lang: &str) -> serde_json::Value {
    match lang {
        "zh-CN" => serde_json::from_str(ZH_CN).unwrap(),
        "en-US" => serde_json::from_str(EN_US).unwrap(),
        _ => serde_json::from_str(ZH_CN).unwrap(), // 默认中文
    }
}

3. 实现翻译系统

3.1 核心翻译函数

use std::collections::HashMap;
use serde_json::Value;

#[derive(Clone, PartialEq)]
struct I18n {
    lang: String,
    translations: Value,
}

impl I18n {
    fn new(lang: &str) -> Self {
        Self {
            lang: lang.to_string(),
            translations: load_translations(lang),
        }
    }

    fn t(&self, key: &str, args: HashMap<&str, String>) -> String {
        // 通过点号路径查找翻译,如 "article.count"
        let parts: Vec<&str> = key.split('.').collect();
        let mut current = &self.translations;
        for part in &parts {
            match current.get(part) {
                Some(v) => current = v,
                None => return key.to_string(), // fallback: 返回 key 本身
            }
        }

        let template = match current.as_str() {
            Some(s) => s.to_string(),
            None => return key.to_string(),
        };

        // 替换占位符
        let mut result = template;
        for (k, v) in &args {
            result = result.replace(&format!("{{{k}}}"), v);
        }
        result
    }
}

// 宏封装,使用更简洁
macro_rules! t {
    ($i18n:expr, $key:expr) => {
        $i18n.t($key, HashMap::new())
    };
    ($i18n:expr, $key:expr, $($k:ident = $v:expr),*) => {
        $i18n.t($key, {
            let mut m = HashMap::new();
            $(m.insert(stringify!($k), $v.to_string());)*
            m
        })
    };
}

3.2 用 Context 提供全局翻译

#[component]
fn I18nProvider(children: Element) -> Element {
    // 从 localStorage 读取语言偏好,或从浏览器语言检测
    let lang = use_signal(|| {
        get_local_storage("blog-lang")
            .unwrap_or_else(|| detect_browser_lang())
    });

    // 构建 I18n 实例
    let i18n = use_memo(move || I18n::new(&lang()));

    // 注入到 Context
    use_context_provider(|| i18n);

    use_effect(move || {
        // 设置 HTML lang 属性
        set_html_attr("lang", &lang());
    });

    rsx! { {children} }
}

// 浏览器语言检测
fn detect_browser_lang() -> String {
    web_sys::window()
        .and_then(|w| w.navigator().language())
        .map(|l| {
            if l.starts_with("zh") { "zh-CN".to_string() }
            else if l.starts_with("ja") { "ja-JP".to_string() }
            else { "en-US".to_string() }
        })
        .unwrap_or_else(|| "zh-CN".to_string())
}

// 使用 Hook 获取翻译
fn use_translation() -> impl Fn(&str, HashMap<&str, String>) -> String {
    let i18n = use_context::<Signal<I18n>>();
    move |key, args| i18n.read().t(key, args)
}

4. 组件中使用翻译

#[component]
fn ArticleCard(summary: ArticleSummary) -> Element {
    let i18n = use_context::<Signal<I18n>>();

    rsx! {
        div { class: "article-card",
            h3 { "{summary.title}" }
            p { class: "text-sm",
                style: "color: var(--tertiary);",
                "{t!(i18n, "article.reading_time", min = 5)}"
            }
            // 或者直接用模式匹配
            span {
                match i18n.read().lang.as_str() {
                    "zh-CN" => "{summary.abstract_field}",
                    _ => "{summary.abstract_field_en}",
                }
            }
        }
    }
}

5. 语言切换

#[component]
fn LanguageSwitcher() -> Element {
    let i18n = use_context::<Signal<I18n>>();
    let mut open = use_signal(|| false);

    let languages = vec![
        ("zh-CN", "中文"),
        ("en-US", "English"),
        ("ja-JP", "日本語"),
    ];

    rsx! {
        div { class: "relative",
            button {
                class: "px-3 py-1.5 rounded-lg text-sm border transition-all",
                style: "background: var(--card); border-color: var(--border);",
                onclick: move |_| open.toggle(),
                "{languages.iter().find(|(c, _)| *c == i18n.read().lang).map(|(_, n)| *n).unwrap_or(\"🌐\")}"
            }
            if open() {
                div {
                    class: "absolute top-full right-0 mt-1 py-1 rounded-lg border shadow-lg z-50 min-w-[120px]",
                    style: "background: var(--card); border-color: var(--border);",
                    onclick: move |_| open.set(false),
                    for (code, name) in &languages {
                        button {
                            class: "w-full text-left px-3 py-1.5 text-sm transition-colors
                                    hover:bg-gray-100 dark:hover:bg-gray-700",
                            style: "color: var(--text);",
                            onclick: move |_| {
                                i18n.set(I18n::new(code));
                                set_local_storage("blog-lang", code);
                            },
                            "{name}"
                        }
                    }
                }
            }
        }
    }
}

6. 日期与数字格式化

不同语言和文化使用不同的日期和数字格式:

fn format_date(date: &str, lang: &str) -> String {
    let dt = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").ok();
    match dt {
        Some(d) => match lang {
            "zh-CN" => format!("{}年{}月{}日", d.year(), d.month(), d.day()),
            "en-US" => format!("{}/{}/{}", d.month(), d.day(), d.year()),
            "ja-JP" => format!("{}年{}月{}日", d.year(), d.month(), d.day()),
            _ => d.to_string(),
        },
        None => date.to_string(),
    }
}

fn format_number(n: u64, lang: &str) -> String {
    match lang {
        "zh-CN" | "ja-JP" => {
            if n >= 10000 {
                format!("{:.1}万", n as f64 / 10000.0)
            } else {
                n.to_string()
            }
        }
        _ => {
            // 英文千位分隔
            let s = n.to_string();
            let mut result = String::new();
            for (i, c) in s.chars().rev().enumerate() {
                if i > 0 && i % 3 == 0 { result.insert(0, ','); }
                result.insert(0, c);
            }
            result
        }
    }
}

7. RTL 布局适配

阿拉伯语、希伯来语等需要从右到左布局:

#[component]
fn RtlAwareLayout(children: Element) -> Element {
    let i18n = use_context::<Signal<I18n>>();
    let is_rtl = use_memo(move || {
        ["ar", "he", "fa", "ur"].contains(&i18n.read().lang.as_str())
    });

    rsx! {
        div {
            dir: "{if is_rtl() { \"rtl\" } else { \"ltr\" }}",
            class: if is_rtl() { "rtl-layout" } else { "" },
            {children}
        }
    }
}

CSS 适配:

/* 基础布局 */
.rtl-layout .flex-row { flex-direction: row-reverse; }
.rtl-layout .ml-auto { margin-left: 0; margin-right: auto; }
.rtl-layout .mr-auto { margin-right: 0; margin-left: auto; }
.rtl-layout .text-left { text-align: right; }
.rtl-layout .text-right { text-align: left; }

/* 使用逻辑属性更简洁 */
.card {
    padding-inline: 1rem;   /* 自动适配 LTR/RTL */
    margin-inline-start: auto;
    border-inline-start: 2px solid var(--primary);
}

8. 翻译管理工具

// 翻译检查 —— 确保所有 key 都存在
fn validate_translations() {
    let zh = serde_json::from_str::<Value>(ZH_CN).unwrap();
    let en = serde_json::from_str::<Value>(EN_US).unwrap();

    fn check_keys(a: &Value, b: &Value, path: &str) {
        match (a, b) {
            (Value::Object(am), Value::Object(bm)) => {
                for (k, v) in am {
                    let new_path = format!("{path}.{k}");
                    if !bm.contains_key(k) {
                        println!("[MISSING] {new_path} in en-US");
                    } else {
                        check_keys(v, &bm[k], &new_path);
                    }
                }
            }
            _ => {}
        }
    }

    check_keys(&zh, &en, "");
}

// 在 CI 中运行
#[test]
fn test_translations_complete() {
    validate_translations();
}

9. 性能优化

// 只加载当前语言的翻译,而不是全部加载
fn use_i18n() -> Signal<I18n> {
    let lang = use_signal(|| detect_browser_lang());

    // 动态加载翻译文件(假设服务器提供 /api/i18n/:lang)
    let translations = use_resource(move || async move {
        let resp = reqwest::get(&format!("/api/i18n/{}", lang())).await.ok()?;
        resp.json::<Value>().await.ok()
    });

    use_memo(move || {
        match translations() {
            Some(t) => I18n { lang: lang(), translations: t },
            None => I18n::new("zh-CN"),
        }
    })
}

10. 小结

  • 国际化核心:代码零硬编码文本,所有用户可见字符串通过翻译函数
  • use_context 将翻译实例注入全局,任意组件可通过 Hook 获取
  • JSON 是最简单的翻译文件格式,支持占位符替换
  • 日期/数字格式因语言而异,需要单独处理
  • RTL 布局通过 CSS 逻辑属性(inset-inline 等)适配
  • 建立翻译验证测试,确保各语言 key 完整
  • 按需加载翻译文件,避免一次性加载所有语言
dioxusi18ninternationalizationlocalizationlanguage