第二十二章:国际化与多语言支持
博客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