语言与本地化
使用 glory-i18n 实现国际化。
将翻译放入 Fluent 资源,为每个应用显式选择语言。glory-i18n 将不可变词典连接到静态值、Cage 数据源和 Bond 计算,响应式消息的生命周期由其 Owner 管理。
启用可选集成
启用 facade 的 i18n 特性,通过 glory::i18n 导入 API。本指南固定到包含该特性的语义架构 Git 提交;较旧的 crates.io 版本可能尚未提供此特性。服务器与浏览器的组合示例还选择 ssr 和 hydrate,后者包含 web。生成 /app.js 时需使用与锁定依赖一致的 wasm-bindgen CLI 版本。将下面的示例模块放入 src/examples/,在 mod.rs 中声明,并从库中导出该模块。i18n_server 仅在原生目标编译,i18n_browser 仅在 wasm32 编译。
Cargo.toml
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
glory = { git = "https://github.com/glory-rs/glory", rev = "0a46e2bd2f301807571b67f4a91776402eda8aeb", default-features = false, features = ["i18n", "ssr", "hydrate"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "=0.2.129"
web-sys = { version = "=0.3.106", features = ["Window", "Document", "Element", "Location"] }src/lib.rs
pub mod examples;src/examples/mod.rs
#[cfg(target_arch = "wasm32")]
pub mod i18n_browser;
pub mod i18n_catalog;
#[cfg(not(target_arch = "wasm32"))]
pub mod i18n_server;
pub mod i18n_shared;
pub mod sapid_action;
在 Fluent 中编写参数与复数形式
将这两个文件放在 crate 根目录的 locales/ 中。各语言使用相同的消息 ID 和变量名,语序与复数分支可以不同。这些演示资源与官网自身的导航及文章词典分开管理。
locales/demo.en.ftl
greeting = Hello, { $name }!
inbox = { $count ->
[one] You have one message.
*[other] You have { $count } messages.
}
add = Add a message
locales/demo.zh-CN.ftl
greeting = 你好,{ $name }!
inbox = 你有 { $count } 条消息。
add = 增加一条消息
将 count 作为数字传入,不要预先格式化成 String。Fluent 根据资源的语言为英文选择 one 或 other 类别;简体中文可对所有数量使用同一个模式。带星号的 other 分支是默认分支。请测试 0、1、2 等代表值,不要假设所有语言都具有英文的复数类别。
解析词典并格式化参数
Catalog::new 一次性解析自有资源,拒绝语法错误和重复条目。在应用中共享生成的 Rc<Catalog>,避免在每次响应式计算中重新解析。下面的初始化代码选择在内嵌资源无效时立即失败。greeting 演示普通的严格查词,使用借用的 name 参数,并将格式化错误返回给调用者。
src/examples/i18n_catalog.rs
use glory::i18n::{Args, Catalog, Error, Locale};
use std::rc::Rc;
pub fn catalog() -> Rc<Catalog> {
let catalog = Catalog::new(
"en",
[
("en", include_str!("../../locales/demo.en.ftl")),
("zh-CN", include_str!("../../locales/demo.zh-CN.ftl")),
],
)
.expect("valid embedded Fluent resources");
assert!(catalog.validate_complete().is_empty());
Rc::new(catalog)
}
pub fn greeting(catalog: &Catalog, locale: &Locale, name: &str) -> Result<String, Error> {
let mut args = Args::new();
args.set("name", name);
catalog.format_exact(locale, "greeting", Some(&args))
}
一次性创建词典,将 "en" 或 "zh-CN" 解析为 glory::i18n::Locale,然后调用 greeting(&catalog, &locale, "Ada")。Args::set 接受文本和数字。Catalog::format_exact 不会回退。I18n::format 会立即读取语言源;构造组件时的普通读取不会建立订阅。
使用有所有权的 Cage 切换语言
下面的共享组件拥有一个语言 Cage 和一个消息数量。I18n::new 也接受静态 Locale 或从应用状态派生的 Bond<Locale>。message 显式地在指定 Owner 下创建 Bond<Result<String, Error>>,同时追踪语言源和参数闭包中的读取。标签 Bond 应用本演示对已验证资源的错误处理策略。在事件处理器中修改 language 后,运行时会在宿主正常的事件或帧边界调度受影响的绑定。
src/examples/i18n_shared.rs
use super::i18n_catalog::catalog;
use glory::{
i18n::{Args, I18n, Locale},
prelude::*,
};
// Both the server and browser call this before constructing the widget.
pub fn locale_for_path(path: &str) -> Locale {
let path = path.split(['?', '#']).next().unwrap_or(path);
let tag = if path == "/zh" || path.starts_with("/zh/") { "zh-CN" } else { "en" };
tag.parse().expect("static language tag")
}
pub fn language_demo(initial: Locale) -> impl Widget {
widget(move |scope| {
let language = scope.cage(initial.clone());
let count = scope.cage(1_i32);
let i18n = I18n::new(catalog(), language);
let message = i18n
.message(scope.owner(), "inbox", move || {
let mut args = Args::new();
args.set("count", count.get());
args
})
.expect("live message owner");
let add = i18n.message(scope.owner(), "add", Args::new).expect("live message owner");
// The application chooses its error policy. These resources are tested.
let label = scope.bond(move || message.get().expect("valid inbox translation"));
let add_label = scope.bond(move || add.get().expect("valid action translation"));
col()
.class("i18n-demo")
.fill((
text().class("i18n-demo-message").content(label),
row().class("i18n-demo-actions").fill((
button()
.class("counter-button")
.label("English")
.on(activate, move |_| language.revise(|id| *id = "en".parse().unwrap())),
button().class("counter-button").label("简体中文").on(activate, move |_| {
language.revise(|id| *id = "zh-CN".parse().unwrap())
}),
button()
.class("counter-button counter-primary")
.label(add_label)
.on(activate, move |_| count.revise(|count| *count += 1)),
)),
))
.show_in(scope)
})
}
在构建期间分配消息
在组件构建作用域中一次性创建上下文和消息 Bond,不要在纯派生计算中创建。参数闭包返回含有自有或静态值的 Args,可以读取 Cage,但不能写状态或创建新的响应式节点。释放 Owner 会使消息失效并释放捕获值。克隆 I18n 只共享资源和语言句柄,不会保留 Owner,也不会创建观察器。
试用语言切换与复数
从一条消息开始,再增加一条,并在 English 与简体中文之间切换。本地语言按钮会保留消息数量。官网顶部的语言选择器则会切换本地化 URL,并重新挂载当前页面;若业务状态需要跨页面保留,应将其放到该路由边界之上。
在服务端渲染前选择语言
两端采用相同的 locale_for_path 规则:/zh 和 /zh/... 选择 zh-CN,其他路径选择 en。每个请求都创建新的组件和状态。词典可以共享,当前语言必须属于请求或应用。snapshot 序列化版本化语义树及翻译后的初始值。将 Snapshot::html 原样插入独占挂载元素,加入 BASE_CSS,并设置外层 HTML 的语言。这个最小宿主示例仅提供演示组件;完整网站还应本地化标题、描述、canonical URL 和语言替代链接。
src/examples/i18n_server.rs
use super::i18n_shared::{language_demo, locale_for_path};
pub fn render_page(path: &str) -> Result<String, Box<dyn std::error::Error>> {
let initial = locale_for_path(path);
let snapshot = glory::ssr::snapshot(language_demo(initial.clone()))?;
Ok(format!(
r#"<!doctype html>
<html lang="{initial}"><head><meta charset="utf-8">
<title>Glory i18n demo</title>
<style data-gly-base="v1">{}</style>
</head><body><main id="app">{}</main>
<script type="module">import init from '/app.js'; await init();</script>
</body></html>"#,
glory::html::BASE_CSS,
snapshot.html
))
}
使用相同的初始语言水合
浏览器在构造组件前读取 URL,使用相同资源和初始数量 1,然后调用 hydrate_with。从应用自己的 wasm-bindgen 启动入口调用 start_demo,并像示例一样保留 WebApp 守卫。将生成的 JavaScript 以 /app.js 提供,与 WASM 文件放在一起;服务器需为请求的本地化路由返回 render_page。这里展示的代码块直接取自官网实际编译和测试的源文件。
src/examples/i18n_browser.rs
use super::i18n_shared::{language_demo, locale_for_path};
use std::cell::RefCell;
use wasm_bindgen::JsValue;
thread_local! {
static APP: RefCell<Option<glory::web::WebApp>> = const { RefCell::new(None) };
}
// Call this from your app's #[wasm_bindgen(start)] entry.
pub fn start_demo() -> Result<(), JsValue> {
let window = web_sys::window().ok_or_else(|| JsValue::from_str("Missing window"))?;
let document = window.document().ok_or_else(|| JsValue::from_str("Missing document"))?;
let root =
document.get_element_by_id("app").ok_or_else(|| JsValue::from_str("Missing mount"))?;
// Use the same URL policy before hydration; do not override it with navigator.language.
let initial = locale_for_path(&window.location().pathname()?);
let app = glory::web::hydrate_with(root, move |_| language_demo(initial))
.map_err(|error| JsValue::from_str(&error.to_string()))?;
APP.with(|slot| *slot.borrow_mut() = Some(app));
Ok(())
}
首次语义树必须完全一致
不要在接管前使用 navigator.language 或存储的偏好覆盖中文 URL 的初始语言。服务器和浏览器必须使用一致的资源、语言、参数、结构和初始状态。普通 ssr::render 输出不是水合 Snapshot。应在成功水合后,通过明确导航或状态修改应用语言偏好。
选择回退与验证策略
Catalog::format 只在缺少请求语言、消息或属性时回退;缺失参数、无法解析的引用、循环引用等解析期求值错误仍会返回错误。I18n::format 和 message 使用这一回退策略。发布必须完整的翻译时使用 format_exact,并按应用需要处理 Error 结果。
validate_complete 对照回退语言,比较消息和术语的值、属性及直接引用的变量名,允许复数类别和语序不同。这个结构检查不能证明每个分支都能求值;还应测试参数化消息,并验证翻译后的 SSR 和客户端路径。
Fluent 默认启用双向文本隔离,因此参数周围可能存在不可见的隔离字符。展示时应保留这些字符。翻译内容应按文本处理,不要当作可信 HTML;链接和富文本结构使用普通语义组件表达。
glory-i18n 不负责探测浏览器偏好、持久化语言、URL 路由或更新文档元数据,这些由应用决定。官网使用英文路径和 /zh/ 路径,语言选择器保留章节、查询参数和锚点,并生成 canonical URL、hreflang 替代链接和双语 sitemap。
阅读完整的 SSR 与水合指南 →