Leptos reactive_stores 实战:用 Store 宏构建细粒度响应式的客户端 TODO 应用
2026/9/13 5:06:09 网站建设 项目流程

Leptos reactive_stores 实战:用 Store 宏构建细粒度响应式的客户端 TODO 应用

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

本文以仓库中的 stores 示例 为主线,系统讲解 Leptos 生态中reactive_stores库的核心用法:通过#[derive(Store)]#[derive(Patch)]让普通 Rust 结构体获得任意深度的细粒度响应式访问,并以此实现一个完整的客户端渲染(CSR)TODO 应用。读完本文,你将掌握 Store 宏的数据建模、keyed 集合字段、字段级 Patch 更新,以及如何在 Leptos 组件中通过FieldFor与表单事件驱动状态变更,最终能独立搭建并运行这类响应式应用。

示例概览:一个基于 Store 的 TODO 应用

examples/stores目录下的示例是一个纯客户端渲染的 TODO 应用,其核心目的正如 示例 README 所述:展示如何使用 reactive stores 构建应用。与为每个字段创建独立信号的传统做法不同,示例把整个应用状态定义成一个普通 Rust 结构体Todos,再由Store宏为其生成一套可响应式访问字段的 getter,让嵌套字段像信号一样被读取和写入。

从 入口文件 可以看到应用的挂载方式非常简单:

use leptos::prelude::*; use stores::App; pub fn main() { console_error_panic_hook::set_once(); mount_to_body(App) }

应用入口App组件定义在 lib.rs 中,它创建了唯一一个Store<Todos>实例,并通过若干子组件展示、编辑整个应用状态。

快速开始:运行示例

按 示例 README 的说明,进入示例目录后直接运行:

trunk serve --open

trunk是一个面向客户端渲染 Web 应用(CSR)的构建工具与开发服务器,--open参数会在构建完成后自动打开浏览器。由于示例是 CSR 应用,其 HTML 骨架 index.html 通过data-trunk rel="rust"指令把 Rust 代码编译为 WebAssembly 并注入页面。

运行前需要准备以下环境(详见 Examples README 的 Prerequisites 章节):

# 安装 nightly Rust 工具链 rustup toolchain install nightly # 添加 wasm32 编译目标(示例的 rust-toolchain.toml 也声明了该 target) rustup target add wasm32-unknown-unknown # 安装 trunk cargo install trunk

也可以使用cargo-make运行:

cargo make ci # 配置并测试示例 cargo make start # 启动应用(默认 127.0.0.1:8080) cargo make stop # 停止进程

示例的 Makefile.toml 通过extend引用了 examples/cargo-make/main.toml 中的通用任务定义。

示例依赖(见 Cargo.toml)中,leptos启用了csrfeature,并直接以路径依赖方式引用仓库内的reactive_storesreactive_stores_macro,配合chrono(处理日期)、serde/serde_json(序列化)使用。

数据建模:让普通结构体成为响应式 Store

应用状态由三个结构体和一个枚举构成,定义在 lib.rs:

#[derive(Debug, Store, Patch, Serialize, Deserialize)] struct Todos { /// Current user. user: User, /// Vector storage of a collection of todo's. #[store(key: usize = |todo| todo.id)] todos: Vec<Todo>, /// User names to todo IDs. #[store(key: Arc<String> = |(name, _)| name.clone())] completed: BTreeMap<Arc<String>, usize>, } #[derive(Debug, Store, Patch, Serialize, Deserialize)] struct User { name: String, email: String, } #[derive(Debug, Store, Patch, Serialize, Deserialize)] struct Todo { id: usize, label: String, /// the `#[patch] attribute allows you to indicate how a particular field should be patch /// this expands to `if this != new { *this = new; notify(path); }` #[patch(|this, new| *this = new)] status: Status, } #[derive(Debug, Default, Clone, Store, Serialize, Deserialize, PartialEq)] enum Status { #[default] Pending, Scheduled, ScheduledFor { date: NaiveDate, }, Done, }

这里有几个关键点:

  • #[derive(Store)]为结构体/枚举生成一套 getter 方法(user()todos()name()status()等),调用这些 getter 即可获得对应字段的响应式访问句柄。正如 reactive_stores 库文档 所说明的:它把reactive_graph提供的信号、memo、effect 细粒度响应式扩展到嵌套结构体字段上,无需为每个字段手工创建嵌套信号。
  • #[derive(Patch)]为 store 与字段增加.patch()方法,允许传入一个全新的值,但只通知实际发生变化的字段(实现细节见下文源码分析)。
  • #[store(key: ...)]把集合字段声明为keyed 字段,为集合中的每个元素维护一个稳定 key。声明语法为#[store(key: <KeyType> = <闭包>)],闭包负责从集合元素中提取 key:
    • todos: Vec<Todo>|todo| todo.idusize作为元素 key;
    • completed: BTreeMap<Arc<String>, usize>|(name, _)| name.clone()Arc<String>作为 key。
  • #[patch(|this, new| *this = new)]Todo::status字段上的 patch 策略标注,代码注释明确给出其展开语义:if this != new { *this = new; notify(path); },即值不变时不触发通知。

Status枚举同样标注了Store,宏会为每个变体生成访问方法:pending()scheduled()done()等返回布尔值,带字段的变体(如ScheduledFor { date })会额外生成scheduled_for_date()之类的访问器。

初始化状态与响应式读取

Todos::data()构造了初始数据:用户 "Bob"、三条预置 TODO("Create reactive store"、"???"、"Profit"),completed统计表初始为空。注意示例用一个全局AtomicUsize(NEXT_ID)为新 TODO 分配自增 id,且起始值为 3,以避免与预置数据冲突:

static NEXT_ID: AtomicUsize = AtomicUsize::new(3);

Todo::new在创建时通过NEXT_ID.fetch_add(1, Ordering::Relaxed)获取 id。

在 App 组件 中,状态被实例化并响应式展示:

let store = Store::new(Todos::data()); // ... <p>"Hello, " {move || store.user().name().get()}</p>
  • Store::new(...)创建响应式 store;store.user().name().get()两级嵌套字段的响应式读取,move || ...闭包让模板始终读取最新值。
  • 底部用{move || serde_json::to_string_pretty(&*store.read())}把整个 store 实时序列化展示,方便观察状态变化。

表单提交:从事件写入 Store

UserForm组件 演示了从表单事件更新嵌套字段的完整流程,其入参类型为Field<User>

#[component] fn UserForm(#[prop(into)] user: Field<User>) -> impl IntoView { // ... <form on:submit:target=move |ev| { ev.prevent_default(); match User::from_event(&ev) { Ok(new_user) => { error.set(None); user.patch(new_user); } Err(e) => error.set(Some(e.to_string())), } }>
  • Field<User>是对某个 store 字段的轻量句柄,可跨组件传递,这正是把userApp传到UserForm的方式(store.user()配合#[prop(into)])。
  • User::from_event(&ev)从表单事件构造新值;成功时调用user.patch(new_user),即用Patch宏生成的 patch 方法只更新变化的字段并精确通知。
  • 输入框通过prop:value=move || user.name().get()把字段值绑定回输入框,实现受控组件。

keyed 字段与 列表渲染

添加 TODO 的表单(App)展示了 keyed 字段的可写访问:

store.todos().write().push(Todo::new(input_ref.get().unwrap().value()));

store.todos()返回 keyed 子字段,.write()获得可写守卫并直接push新元素;NodeRef则用于无状态地读取输入框内容。

列表渲染使用<For>(lib.rs):

<For each=move || store.todos() key=|row| row.id().get() let:todo> <TodoRow store todo /> </For>

如代码注释所强调:因为todos是 keyed 字段,store.todos()直接实现了IntoIterator,可以直接喂给<For/>,由 store 正确管理细粒度响应式。从 keyed.rs 源码 可以看到,KeyedSubfieldIntoIterator实现会先update_keys()刷新 key 映射、track_field()建立响应式追踪,再基于当前 key 列表产出AtKeyed迭代项。

TodoRow组件 中可以看到 keyed 元素级字段的典型用法:

let status = todo.status(); let title = todo.label(); // 删除 let id = todo.id().get(); store.todos().write().retain(|todo| todo.id != id); // 切换状态并累计成就 let was_undone = !status.done(); status.write().next_step(); if was_undone && status.done() { let name = store.user().name().get(); store.completed().update(|completed| *completed.entry(name.into()).or_default() += 1) }
  • todo.status()todo.label()返回该条元素内部的字段句柄,可像信号一样.get()/.set()/.write()
  • status.write().next_step()直接修改嵌套枚举:next_step()(lib.rs)把Pending推进为带当前日期的ScheduledFor,再把Scheduled/ScheduledFor推进为Done
  • 跨字段联动(完成任务时更新completed统计)展示了多个 keyed 字段在同一事件中的协同写入。
  • 日期输入框则演示了枚举变体字段的读写法:todo.status().scheduled_for_date()获取Option<Field<NaiveDate>>,配合class:hidden动态显隐,解析失败时通过warn!记录。

UserAchievements组件(lib.rs)演示了 keyed 集合的按键访问与遍历:

let completed = Memo::new(move |_| { store .completed() .at_key(store.user().name().get().into()) .try_get() .unwrap_or_default() .to_string() }); // ... <For each=move || store.completed() key=|row| row.key() let:achievement> <div>{achievement.key().to_uppercase()}: {move || achievement.get()}</div> </For>
  • .at_key(name)返回按 key 定位的AtKeyed句柄,.try_get()在 key 不存在时返回None,避免 panic;统计结果用Memo缓存派生状态。
  • keyed 集合的<For>|row| row.key()作为元素 key,迭代项可直接调用.key()读取 key、.get()读取值。

底层原理:字段路径与触发器

理解了使用方式后,再看 reactive_stores 源码 中的实现笔记(Implementation Notes,lib.rs),能更深刻地理解 store 的工作机制:

  1. 每个字段都可以理解为路径上的一个索引。例如Name { first, last }first是索引0last1;嵌套在User { user: Name }中时,first的路径是[0, 0]last[0, 1]。任意深度的字段都可以表示成索引路径。
  2. Store 由两部分组合而成:一个Arc<RwLock<T>>持有真实值;一个“字段路径 → 响应式触发器”的映射(TriggerMap,见 lib.rs),每个触发器包含thischildren两个ArcTrigger
  3. 读取与写入的语义.read()返回守卫,解引用到该字段在锁内的值并追踪对应路径的触发器;.write()返回可写守卫,并在守卫释放时通知同一路径的触发器。

由此带来两条核心行为(README 与示例注释均强调):

  • 更新一个字段会通知其父级与子级,但不会通知兄弟字段;兄弟字段的更新也无需 diff,这有别于早期基于 memoized "slices" 的方案;
  • 对应地,mutating_field_triggers_effectother_field_does_notifyparent_does_notify等测试(reactive_stores/src/lib.rs 起的mod tests)分别验证了“修改被监听字段会触发 effect”“修改其他字段不触发”“修改父级会通知”等行为。

Patch 的按字段通知机制

#[derive(Patch)]生成的.patch(new)语义由 patch.rs 实现。核心 trait 定义如下:

pub trait Patch { type Value; /// Patches a store or field with a new value, only notifying fields that have changed. fn patch(&self, new: Self::Value); }

底层PatchField(patch.rs)针对每种类型定制通知策略:

  • 基本类型String、整数、浮点、bool等,见patch_primitives!宏)在new != *self时才写回并通知;
  • Option<T>处理四种组合(无→无、有→无、无→有、有→有),仅在有变化时通知;
  • Vec<T>按索引逐项递归 patch,只有在长度变化(增删)时才通知集合本身;
  • keyed 集合VecHashMapBTreeMap实现PatchFieldKeyed)则先做一次 key 级 diff:新旧集合中 key 相同但值变化的元素被原地 patch,只有发生元素新增、删除或重排时才通知集合结构变化,从而让列表更新保持最小化。

这就是UserFormuser.patch(new_user)只精确通知变更字段、#[patch(|this, new| *this = new)]允许自定义字段级 patch 策略的底层依据。

相关资源索引

  • 示例说明:examples/stores/README.md
  • 示例完整源码:examples/stores/src/lib.rs(数据建模与全部组件)、examples/stores/src/main.rs(挂载入口)
  • 示例配置:examples/stores/Cargo.toml、examples/stores/Makefile.toml、examples/stores/index.html、examples/stores/rust-toolchain.toml
  • 运行环境与 cargo-make 使用说明:examples/README.md
  • Store 库文档:reactive_stores/README.md
  • Store 核心实现:reactive_stores/src/lib.rs(含实现原理与单元测试)
  • Patch 实现:reactive_stores/src/patch.rs
  • keyed 集合实现:reactive_stores/src/keyed.rs

小结

通过examples/stores这个 TODO 应用,可以看到reactive_stores的核心价值:它把响应式状态从"信号组成的结构"还原为普通的 Rust 数据结构,同时通过StorePatch宏与 keyed 字段获得比信号嵌套更细粒度、更少无谓通知的响应式能力。文中所有代码均可直接在仓库中查看与运行,读者可在此基础上继续探索OptionVec、枚举、Box等特殊字段类型在 reactive_stores/src/lib.rs 文档中的进阶用法,将其迁移到自己的 Leptos 应用中。

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

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

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

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

立即咨询