Leptos 无宏编程实战:用纯 Rust 函数式 API 构建计数器组件(counter_without_macros 示例详解)
2026/9/13 18:04:56 网站建设 项目流程

Leptos 无宏编程实战:用纯 Rust 函数式 API 构建计数器组件(counter_without_macros 示例详解)

【免费下载链接】leptosBuild fast web applications with Rust.项目地址: https://gitcode.com/GitHub_Trending/le/leptos

本文基于 Leptos 仓库中 examples/counter_without_macros/README.md 展开,深入解析这个"不借助任何宏"的客户端渲染计数器示例。它以极简代码演示了 Leptos 无宏编程范式的完整套路——从html::{div, button, span}命令式构建 DOM、on(ev::click, ...)绑定类型安全事件,到RwSignal+Effect的响应式状态管理,并且无需 nightly 编译器、可在 stable Rust 上直接构建。读完本文你将掌握:如何用纯函数式 API 手写组件、如何在无view!宏的情况下组织响应式 UI、以及如何用 wasm-bindgen-test 对无宏组件做端到端浏览器测试。

一、示例定位:宏版本之外的另一条路

Leptos 最为人熟知的是它的view!宏与#[component]宏——只需要写出类似 HTML 的语法,编译器就会自动生成高效的 DOM 构建代码。仓库中的 examples/counter/src/lib.rs 就是这种经典写法:

#[component] pub fn SimpleCounter(initial_value: i32, step: i32) -> impl IntoView { let (value, set_value) = signal(initial_value); view! { <div> <button on:click=move |_| set_value.set(0)>"Clear"</button> <span>"Value: " {value} "!"</span> ... </div> } }

而 counter_without_macros 则提供了完全平行的另一套实现:不写任何宏(既不用view!,也不用#[component]),只调用 Leptos 暴露的普通 Rust 函数,把组件写成一个"普通到不能再普通"的pub fn。正如其 README 所述:

This example is the same like thecounterbut it's written without using macros and can be build with stable Rust.

这句话点出了无宏路线的两个关键卖点:

  1. 功能等价:它与counter示例实现的是完全相同的计数器交互(Clear / -1 / +1);
  2. 不依赖宏展开:因此对编译器的要求更低,可以直接用 stable Rust 工具链构建(示例的 Cargo.toml 中rust-version = "1.75"也印证了这一点),无需像部分宏特性那样切换到 nightly。

二、快速开始:Trunk 一键运行

示例 README 给出的启动方式非常简洁:

trunk serve --open

trunk是一个面向客户端渲染(CSR)Web 应用的轻量构建工具与开发服务器。执行后它会读取 index.html 作为入口:

<!DOCTYPE html> <html> <head> <link>use counter_without_macros::counter; /// Show the counter pub fn main() { console_error_panic_hook::set_once(); leptos::mount::mount_to_body(|| counter(0, 1)) }
  • console_error_panic_hook::set_once():把 wasm 端的 panic 信息输出到浏览器控制台,方便调试;
  • mount_to_body(|| counter(0, 1)):把counter组件挂载到<body>mount_to_body定义于 leptos/src/mount.rs,其签名要求传入一个返回impl IntoView的闭包,内部会创建响应式 owner 并调用mount_to(body(), f)

注意:这个counter不是一个"组件宏"生成的东西,它就是一个普普通通的pub fn counter(initial_value: i32, step: u32) -> impl IntoView。源码注释对此做了非常直白的说明:

A component is really just a function call: it runs once to create the DOM and reactive system

即:组件本质上就是一次函数调用——函数运行一次,创建出 DOM 与响应式系统。这正是不需要宏的根本原因:Leptos 的#[component]宏只是把"函数即组件"的约定做了一层语法糖包装。

3.1 用 HTML 构建器拼出 DOM

无宏版本的核心在于leptos::html模块提供的类型化 HTML 构建器

use leptos::{ ev, html::{button, div, span}, prelude::*, };

view!宏里,<div><button><span>这样的标签会被宏展开为对应的构建器调用;而在无宏写法中,这些就是实实在在的函数:div()button()span()。它们各自返回一个强类型的HtmlElement<_>,可以通过链式方法继续组装:

div() .child(( button() .on(ev::click, move |_| count.update(Count::clear)) .child("Clear"), button() .on(ev::click, move |_| count.update(Count::decrease)) .child("-1"), span().child(("Value: ", move || count.get().value(), "!")), button() .on(ev::click, move |_| count.update(Count::increase)) .child("+1"), ))

这段代码信息量很大,我们逐点展开:

(1).child(...)的通用性child接收任何实现了IntoView的类型——字符串、HtmlElement<_>、数组、以及最多 26 个元素的元组。上面span的 children 就是一个三元组("Value: ", move || count.get().value(), "!"),其中move || count.get().value()是一个响应式闭包(函数作为视图):每次计数变化时,Leptos 会自动重新求值这段文本,这正是"文本即响应式数据"的体现。

(2).on(ev::click, ...)的类型安全事件:事件名称统一放在leptos::ev模块中,源码注释给出了两条理由:

  • 防止事件名字符串拼写错误(有编译期检查);
  • 允许回调获得正确的类型推断(例如ev::click的回调会收到ev::MouseEvent)。

(3).update(...)的原地修改:点击回调通过count.update(Count::clear)等方式把方法引用直接传给RwSignal::update,原地修改内部状态并触发依赖方更新。

3.2 用 RwSignal 与 Effect 搭起响应式状态

状态层同样不依赖宏:

pub fn counter(initial_value: i32, step: u32) -> impl IntoView { let count = RwSignal::new(Count::new(initial_value, step)); Effect::new(move |_| { leptos::logging::log!("count = {:?}", count.get()); }); ... }
  • RwSignal<T>是 Leptos 响应式原语中的"可读写信号",在 reactive_graph/src/signal/rw.rs 中定义为pub struct RwSignal<T, S = SyncStorage>,其内部由 arena 分配的ArcRwSignal<T>支撑。与signal()返回的"读/写分离"元组不同,RwSignal把读与写合并在同一个句柄上,非常适合"状态对象整体更新"的场景;
  • 这里存的不再是裸的i32,而是一个自定义的Count结构体(见下文),状态建模更贴近业务;
  • Effect::new(...)创建了一个副作用:每当count变化时向控制台打印当前值(浏览器中输出到 DevTools console)。Effect是 Leptos 响应式系统中"观察变化、执行副作用"的标准入口,这里相当于view!{value}这类响应式文本之外、独立的响应式联动示例。

3.3 业务模型:普通 Rust 结构体 Count

#[derive(Debug, Clone)] pub struct Count { value: i32, step: i32, } impl Count { pub fn new(value: i32, step: u32) -> Self { Count { value, step: step as i32, } } pub fn value(&self) -> i32 { ... } pub fn increase(&mut self) { self.value += self.step; } pub fn decrease(&mut self) { self.value += -self.step; } pub fn clear(&mut self) { self.value = 0; } }

Count是一个 100% 普通的 Rust 结构体:不依赖任何 Leptos 类型、不带任何宏标注,只负责纯粹的领域逻辑(increase/decrease/clear三个操作与step步长语义)。counter(0, 1)意味着初始值为 0、每次增减步长为 1;测试中也能看到使用counter(0, 1)之外的初始值/步长组合来覆盖不同场景。

这样的分层带来一个显著好处:业务逻辑与视图框架解耦Count可以被单独单元测试,甚至可以在非 wasm 环境下复用;响应式层只负责把 UI 事件翻译成对Count的方法调用,再通过RwSignal的更新把结果推回视图。

四、与宏版本 counter 的逐点对照

维度counter(宏版本)counter_without_macros(无宏版本)
组件定义#[component] pub fn SimpleCounter(...)pub fn counter(...) -> impl IntoView
模板view! { <div> <button ...> ... }div().child((button()..., span()..., ...))
状态let (value, set_value) = signal(initial_value);let count = RwSignal::new(Count::new(...));
事件绑定on:click=move |_| ....on(ev::click, move |_| ...)
响应式文本{value}move || count.get().value()
编译器要求常规(部分场景需 nightly)仅需 stable Rust(Cargo.toml 中rust-version = "1.75"

可以看出两条路线在能力上是等价的:相同的 DOM 结构、相同的交互语义、相同的响应式更新。差异仅在于"语法糖"——宏版本把构建器链与信号访问包装成类 HTML 的声明式语法,而无宏版本把这一切显式化为普通函数调用。这也从侧面说明:Leptos 的宏只是语法糖,底层运行时 API 本身就是完整自洽的;当你需要嵌入动态生成模板、编写库代码、或规避宏带来的编译依赖时,无宏路线是一条可靠的退路。

五、测试验证:无宏组件同样可测

无宏路线并没有牺牲可测试性。该示例在 tests/ 下提供两层测试,正好覆盖"纯逻辑"与"真实 DOM"两个层次。

5.1 纯逻辑单元测试(business.rs)

tests/business.rs 用rstestCount的方法做了参数化测试:对initial_value ∈ {-2,-1,0,1,2,3,4}step ∈ {1,2,3}的多组组合,逐一断言increase得到initial_value + stepdecrease得到initial_value - stepclear归零。因为Count与框架完全解耦,这些测试可以像普通 Rust 测试一样cargo test直接跑,无需浏览器环境。

5.2 浏览器端到端测试(web.rs)

tests/web.rs 则通过wasm-bindgen-test在真实浏览器中驱动组件:

wasm_bindgen_test_configure!(run_in_browser); #[wasm_bindgen_test] async fn should_increment_counter() { open_counter(); click_increment(); click_increment(); tick().await; // reactive changes run asynchronously, so yield briefly before observing the DOM assert_eq!(see_text(), Some("Value: 2!".to_string())); }

要点包括:

  • tick().await:响应式更新是异步调度到微任务中的,因此在断言 DOM 文本前需要先让出执行权(源码注释明确说明:"reactive changes run asynchronously, so yield briefly before observing the DOM");
  • 借助document().evaluate(...)XPath 查询//*[text()='...'])按文本内容定位元素并触发.click(),从而模拟"点击 Clear/-1/+1 按钮"的用户操作;
  • 三个测试分别验证+1两次后显示Value: 2!-1两次后显示Value: -2!、先加后清零显示Value: 0!,完整覆盖了组件的三条交互路径。

该测试所需的依赖集中在 Cargo.toml 的[dev-dependencies]中:wasm-bindgenwasm-bindgen-testpretty_assertionsrstest,以及带HtmlElementXPathResultfeature 的web-sys。配合 Makefile.toml 继承的wasm-test.toml任务,即可纳入 CI 一键执行。

六、小结:无宏路线的适用场景

从 counter_without_macros 这个示例可以总结出无宏编程的完整配方:

  1. 组件 = 普通函数:返回impl IntoView,入参即 props;
  2. DOM = 构建器链html::{div, button, span}等类型化构建器 +.child()/.on()组合;
  3. 状态 = 信号RwSignal(或signal)承载可变状态,Effect::new处理副作用;
  4. 业务 = 纯结构体:与框架无关的领域模型,便于单元测试与复用。

它适合以下场景:

  • 项目要求纯 stable Rust 工具链,希望规避宏展开带来的 nightly 依赖;
  • 需要动态生成 UI 结构(例如在循环或条件分支中拼接元素),构建器链比宏模板更直接;
  • 编写库或框架层代码时,希望避免view!宏对下游用户编译环境的约束。

与此同时,宏版本在可读性与声明式表达上依然占优。两条路线在 Leptos 中并非对立关系——它们共享同一套响应式运行时(信号、Effect、IntoView),你完全可以按场景混用:宏负责"长得像模板"的静态视图,构建器负责"需要编程式组装"的动态部分。这正是 counter 与 counter_without_macros 两个示例并列存在的意义:让开发者看到同一种能力的两副面孔,再按需取用

【免费下载链接】leptosBuild fast web applications with Rust.项目地址: https://gitcode.com/GitHub_Trending/le/leptos

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询