GLORY 指南

编写自定义组件。

实现 Widget 可为可复用的 Rust 值提供独立的构建逻辑、状态所有权、子组件和语义事件处理器。trait 本身很小;理解 build、Scope 和挂载生命周期,才能正确实现组件。

完整的 Counter

下面的组件与宿主无关,不包含 DOM 类型、浏览器监听器、HTML 字符串或原生窗口调用。通过 Web、SSR、Blitz 或内存宿主挂载 Counter,使用的是相同语义树和事件行为。

counter.rs

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
use glory::prelude::*; pub struct Counter; impl Widget for Counter { fn build(&mut self, scope: &Scope) -> Result { let count = scope.cage(0_i32); let label = count.map(|count| format!("Count: {count}")); col() .class("counter") .fill(( text().kind(TextKind::Heading(1)).content("Counter"), text().content(label), button() .label("Increment") .on(activate, move |event| { if matches!(event, Event::Activate) { count.revise(|count| *count += 1); } }), )) .show_in(scope) } }

必须实现的方法是 fn build(&mut self, scope: &Scope) -> Result。此处 Result 是 Glory 的 UI 结果别名,因此节点创建、子组件挂载等可能失败的操作可使用 ?。最后的 col().show_in(scope) 将根构建器挂载到 Counter 的作用域中,并返回该 Result。在 Widget 实现中直接返回 col() 会导致类型错误。

状态属于已挂载的 Scope

scope.cage(0_i32) 创建由本次挂载拥有的可变状态。返回的 Cage 是小型 Copy 句柄,可被派生标签和按钮处理器捕获。Counter 不需要 count 字段:状态由运行时拥有,生命周期与挂载 Scope 完全一致。每个单独挂载的 Counter 都从独立的零开始。

count.map 为 text().content 派生响应式 String。该构建器方法接受可转换为 Lotus 的值,因此既可使用静态 String,也可使用响应式数据源。事件处理器调用 revise 后,派生值和文本绑定被调度,build 不会重跑。避免在纯派生计算中创建 map 或其他响应式节点。

build 负责构建

不要期待 self 字段或普通 Cage 读取自动刷新界面。将配置存入 self,在 build 中创建有所有权的响应式状态,并为每个需要变化的属性安装绑定。

Activate 是语义事件

.on(activate, handler) 订阅 EventKind::Activate。回调接收类型化的 glory::render::Event,prelude 也会重新导出它。Event::Activate 表示控件激活请求,不会让共享组件依赖浏览器 MouseEvent 或键盘事件。宿主处理原生输入并将语义事件入队,再由 Glory 分派给注册的处理器。

Counter 中的 matches! 检查明确确认事件载荷,虽然仅注册 activate 的处理器通常只会收到 Event::Activate。其他控件使用其他事件及载荷:value_changed 携带文本和修订号,checked_changed 携带布尔值。共享组件逻辑不应通过直接调用 DOM 事件模拟输入;应驱动应用状态,或在测试中使用语义事件。

fill 接受 IntoChildren

View、Group、Button 和 Link 构建器通过 fill 接受子组件。IntoChildren 实现覆盖单个 Widget、异构组件元组、Vec<W>,以及表示无子组件的 ()。固定布局通常使用元组;需要单个异构向量时使用 Vec<Box<dyn Widget>>,响应式集合则使用 each 或 list。

可复用包装组件可在构造器中接受 impl IntoChildren,并保存生成的 glory::ui::Children。Children 是会被消耗的挂载输入,不能作为可克隆模板。由于 build 接收 &mut self 且只运行一次,应将其替换为空子列表,再把原值传给 fill,后者会将每个子组件挂载到新的语义父节点下。

panel.rs

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
use glory::prelude::*; use glory::ui::{Children, IntoChildren}; pub struct Panel { children: Children, } impl Panel { pub fn new(children: impl IntoChildren) -> Self { Self { children: children.into_children() } } } impl Widget for Panel { fn build(&mut self, scope: &Scope) -> Result { // build takes &mut self, so consume the configured children once. let children = std::mem::replace(&mut self.children, ().into_children()); view().class("panel").fill(children).show_in(scope) } } pub fn page() -> impl Widget { Panel::new(( text().kind(TextKind::Heading(1)).content("Account"), text().content("Children can be any heterogeneous widget tuple."), Counter, )) }

Panel::new((heading, body, Counter)) 可用,是因为三元素元组实现了 IntoChildren。多次调用 fill 会扩展构建器保存的子组件列表,不会替换之前的子组件。这与配置单个可替换属性槽的 class 和 style 不同。

class and style replace their previous values

对同一构建器调用两次 .class(...),会替换完整的空白分隔类名列表,不会追加。同样,.style(...) 会替换完整的类型化 Style,并移除该属性槽先前的绑定,无论新值是静态还是响应式都如此。外部样式表仍可通过 CSS 层叠组合样式,但两次构建器调用并不是合并 API。

需要组合多个字段时,应通过链式字段方法构造一个 Style。特别注意 row() 和 col():其初始 flex 显示和方向也只是一个 Style 值。替换样式时若不包含这些字段,就会移除便捷构造器的默认值。可以从 view() 开始,或在替换值中显式包含 display 和 flex_direction。

overrides.rs

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// The second call replaces the complete class list, not just one token. let button = button() .class("primary") .class("primary is-active"); // final classes: primary, is-active // These fields are merged by the Style builder into one Style value. let spacing = Style::new() .padding(Edges::all(12.0)) .gap(8.0); let panel = view().style(spacing); // col() is a View with flex defaults. Replacing its Style must retain them. let column = col().style( Style::new() .display(Display::Flex) .flex_direction(FlexDirection::Column) .gap(12.0), );

子组件与 Owner 相互关联,但并不等同

语义父子关系和响应式所有权有关,但并非同一概念。Panel 的 View 是其子组件的语义父节点。fill 中的每个子组件都挂载于新的子 Scope,构建它的作用域会保留该挂载。因此,子 Owner 跟随 Panel 的生命周期,同时仍能拥有自己的状态、订阅、任务和后代。

移除已挂载分支会释放其 Owner 和后代,使代数句柄失效,移除监听器与语义节点,并取消已入队的工作。在父回调中捕获子组件的 Cage 不会反转这个关系,也不会使子组件保持存活。状态需要比某个分支活得更久时,应从较长生命周期的 Scope 显式向下传递。

通常不需要手动调用 scope.child():fill、show_in、mount_with 和控制流控制器会建立并保留所需的子作用域。如果自定义控制器直接创建子 UI Scope,就必须在整个挂载期间保留该 Scope 或其 Mounted 守卫。释放守卫会释放子树。

cleanup.rs

1 2 3 4 5 6 7 8
widget(|scope| { let value = scope.cage(String::new()); scope.on_cleanup(|| { // Release application resources that belong to this mount. })?; text().content(value).show_in(scope) })

选择 trait、闭包或普通函数

组件具有可复用名称、配置、可接收的子组件,或需要基于 trait 组合时,实现 Widget,Counter 和 Panel 就是例子。本地组件需要自有状态,但不值得定义具名结构体时,可使用 widget(|scope| ...)。闭包必须实现 FnMut(&Scope) -> Result + 'static,每次挂载仍然只构建一次。

save_button.rs

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
use glory::prelude::*; pub fn save_button() -> impl Widget { widget(|scope| { let saving = scope.cage(false); let label = saving.map(|saving| { if *saving { "Saving…" } else { "Save" }.to_owned() }); button() .label(label) .on(activate, move |_| saving.revise(|saving| *saving = true)) .show_in(scope) }) }

无状态组合,或按参数配置并返回另一个组件时,普通 fn card(...) -> impl Widget 就足够了。函数需要 Scope 来分配本地 Cage 时,应返回 widget(...),不要在普通函数执行期间尝试读取状态。这些选择不改变更新模型:动态属性需要绑定,结构变化需要控制流。

回顾保留式语义树和更新模型 →

挂载检查要点

在 build 的 Scope 中分配状态;用 show_in(scope) 完成根节点;保存的子组件只消耗一次;使用类型化 Event 处理器;并让 fill、show_in 或显式 Mounted 守卫维持每个子组件的生命周期。