十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Dioxus Signal 响应式状态管理完全指南:读写模型、运行时借用检查与生命周期

Dioxus Signal 响应式状态管理完全指南:读写模型、运行时借用检查与生命周期 Dioxus Signal 响应式状态管理完全指南读写模型、运行时借用检查与生命周期【免费下载链接】dioxusFullstack app framework for web, desktop, and mobile.项目地址: https://gitcode.com/GitHub_Trending/di/dioxusDioxus 是同时覆盖 Web、桌面与移动端的全栈应用框架而Signal正是 Dioxus 面向 UI 场景设计的核心响应式状态原语。本文以仓库中的官方文档 packages/signals/docs/signals.md 为主体骨架结合dioxus-signals的真实源码实现系统讲解 Signal 的读写作法、运行时借用规则、与 Async 的协作方式以及生命周期约束帮助你写出既不会 panic 又能精确触发组件更新的响应式 UI 代码。Signal 是什么为 UI 而生的 Copy 状态运行时从源码类型定义上看Signal并非直接把值塞进堆里的普通句柄而是基于generational-box实现的、自带订阅者列表的轻量存储// packages/signals/src/signal.rs 中 Signal 的结构 pub struct SignalT, S: static UnsyncStorage { pub(crate) inner: CopyValueSignalDataT, S, } // 真正存储的内容一份值 一个记录谁订阅了我的订阅者集合 pub struct SignalDataT { pub(crate) subscribers: ArcMutexHashSetReactiveContext, pub(crate) value: T, }dioxus-signals自称是“带有局部订阅的、符合人体工学的 Copy 运行时”见 packages/signals/README.md它有两条最重要的设计承诺全部 Signal 都是 Copy 的。即使内层值如String、Vec并不实现CopySignalString依然可以直接复制并随意 move 进async任务、子组件或闭包底层容器由其所有者generation box统一管理。仅在读取时局部订阅。组件只有在渲染期间真正read过某个 Signal 时才会被登记为该 Signal 的订阅者在 future 或事件处理器中读取数据则永远不会产生订阅。正是这两点让Signal比RcRefCellT更适合 UI 开发——你可以放心地把状态句柄到处传递UI 也只会在它真正依赖的状态变化时重渲染。读写 Signal 的三种姿势打开文档的第一段就点明了一个关键事实Signal结构体本身的方法很少绝大多数能力都定义在 [Readable] 和 [Writable] 两个 trait 上本文涉及的类型与 trait 均来自dioxus::prelude对应源码见 packages/signals/src/read.rs 与 packages/signals/src/write.rs。这一点也和Signal的Readable/Writable实现见 packages/signals/src/signal.rs互相印证。方式一像 RefCell 一样显式 read / writeSignal在读写语义上相当于“Copy 版本的RcRefCellT”read()返回一个可 deref 成T的读引用write()返回一个可 deref 成mut T的写引用。# use dioxus::prelude::*; let mut signal use_signal(|| 0); { // 这里会读取值用代码块包住读取确保读取在下一次写入前被释放 let read signal.read(); // 和 RefCell 一样可以对 read 做 deref 拿到内部的 T match *read { 0 println!(read is 0), 1 println!(read is 1), _ println!(read is something else ({read})), } } // 写入值 let mut write signal.write(); // 同样可以 deref 写引用得到内部的 mut T *write 1;代码块在这里不是可有可无的修饰而是为了保证“读引用在写操作前及时 drop”这直接关系到下文的借用 panic 规则。方式二像函数一样调用 Signal由于Signal对dyn Fn() - T实现了Deref见 packages/signals/src/signal.rs 中Deref for SignalT, S直接signal()就会克隆内部值——这在把状态快照传给异步任务、或者只需要临时取值时非常顺手# use dioxus::prelude::*; let mut signal use_signal(|| 0); // 像调用函数一样克隆出内部值 let clone: i32 signal(); // 也可以直接打印 signalfmt 实现见 impls 模块 println!({}, signal); let signal_vec use_signal(|| vec![1, 2, 3]); // 无需显式 read即可直接使用 Vec 的 get / last / len let first signal_vec.get(0); let last signal_vec.last(); let len signal_vec.len(); // 甚至可以直接对 signal 做迭代 for i in signal_vec.iter() { println!({}, i); }这些便利方法并非魔法而是由一组“扩展 trait”提供的ReadableExt、WritableExt提供基础能力针对容器/枚举再细分出ReadableVecExt、WritableVecExt、ReadableOptionExt、WritableOptionExt还有字符串、HashMap、HashSet、Result 的对应扩展。从源码可以看到ReadableVecExt的get/first/last底层其实是用self.read()try_map在单次借用内完成的packages/signals/src/read.rs因此即便直接调用也不会跨语句悬挂引用。若希望接口更灵活官方还建议用 trait 而非具体类型作为函数参数。例如编写一个接受“任意可读类型”的函数fn double(something_readable: impl ReadableTarget i32) - i32 { something_readable.cloned() * 2 }这样同一个函数既能接收Signali32也能接收GlobalSignali32、ReadSignali32等任何实现Readable的类型。方式三借用运算符合成赋值signal 1这类写法会被展开为with_mut内部的可变借用属于“一次作用域内完成读改写”的快捷方式也是状态自增最常见的写法。运行时借用检查与 RefCell 相同的 panic 规则和RefCellT一样Signal 在运行时检查借用。如果同时持有一份读引用又去写它会直接 panic# use dioxus::prelude::*; let mut signal use_signal(|| 0); // 持有读引用不放的同时去写 let read signal.read_unchecked(); // 这行会 panic signal 1; println!({}, read);源码中try_write_unchecked返回Result..., BorrowMutError而write()/read()会对失败结果直接unwrap见 packages/signals/src/write.rs 的WritableExt::write这正是 panic 的来源。借用检查之所以能放到运行时是因为Signal背后依赖generational-box的UnsyncStorage/SyncStorage存储抽象编译期并不强制区分不可变/可变引用。要避开重叠借用两种手法任选其一把读写各自包进代码块让读引用在写操作前 drop使用with_*系列方法把读写限制在单个闭包作用域内闭包结束借用即释放# use dioxus::prelude::*; let mut signal use_signal(|| 0); { // 读取被代码块限定块结束后借用即释放 let read signal.read(); println!({}, read); } signal 1; // 或者用 with / with_mut读写只在闭包内部存在 signal.with(|read| println!({}, read)); signal.with_mut(|write| *write 1);此外值得留意一对更精细的 API文档正文之外、但源码注释中反复强调read()读取并订阅当前组件Signal 变化时组件会重渲染peek()读取但不订阅适合“日志/调试需要当前值但该值变化不应触发本组件重渲染”的场景。peek也是旧版write_silent的推荐替代品——write_silent会全局跳过通知、容易让状态与 UI 悄悄失步已被官方标记为 deprecatedpackages/signals/src/signal.rs 中详细说明了迁移到peek的理由与示例。Signal 与 Async绝不要把借用挂在 await 上因为借用检查发生在运行时在 async 代码里跨await持有读/写引用是最常见的 panic 源头。文档给了一个典型反例# use dioxus::prelude::*; # async fn sleep(delay: u32) {} async fn double_me_async(value: mut u32) { sleep(100).await; *value * 2; } let mut signal use_signal(|| 0); use_future(move || async move { // 错误示范把写引用跨 await 传出去 let mut write signal.write(); double_me_async(mut write).await; // future 等待期间写引用一直打开 }); rsx!{ // 这里读取可能 panic因为写引用在 future 等待期间仍处于激活状态 {signal} };问题根源在于await让出了执行权期间写引用依旧活着其他组件或同一组件的重渲染一旦尝试读取就会撞上活跃的可变借用。正确做法是先克隆出所需的值跑完异步逻辑后再把结果写回 Signal全程不在await边界上保留借用# use dioxus::prelude::*; # async fn sleep(delay: u32) {} async fn double_me_async(value: u32) - u32 { sleep(100).await; value * 2 } let mut signal use_signal(|| 0); use_future(move || async move { // 先把值克隆出 signalsignal() 会返回克隆值不持有借用 let current_value signal(); // 再执行异步计算 let new_value double_me_async(current_value).await; // 计算完成后一次性写回 signal.set(new_value); }); rsx! { // 这里的读取不会 panic因为写引用从未跨 await 存活 {signal} };同样的纪律也适用于use_memo创建的Memo不要在use_resource/future 里halved.read()然后跨await使用而应改为halved()克隆取值详见 packages/signals/docs/memo.md 的“Memos with Async”一节。核心心智模型只有一句话await之前释放借用或用克隆值跨过await。Signal 的生命周期不要向上传递子组件的 SignalSignal 基于generational-box实现因此即使内部值不是Copy句柄也是Copy的。这对 UI 开发非常便利但也带来一个取舍Signal 的生命周期绑定于创建它的那个组件scope。当创建 Signal 的组件被卸载Signal 也随之失效持有旧句柄的代码继续读写会出错。官方文档给出一句 TLDR不要在组件树里把 Signal 向上传递。典型反例是子组件把自己的 Signal 塞进父组件传下来的SignalOption...里——一旦子组件卸载父组件手里这个句柄就成了指向已回收存储的悬垂句柄# use dioxus::prelude::*; fn MyComponent() - Element { let child_signal use_signal(|| None); rsx! { IncrementButton { child_signal } } } #[component] fn IncrementButton(mut child_signal: SignalOptionSignali32) - Element { let signal_owned_by_child use_signal(|| 0); // 不要这样做子组件被卸载后该句柄可能失效 child_signal.set(Some(signal_owned_by_child)); todo!() }规避方案按优先级排列在组件树更高的位置创建 Signal让它的所有者活得足够久改用全局 SignalGlobalSignal。static COUNT: GlobalSignali32 Signal::global(|| 0);会在应用内首次使用该静态变量时惰性初始化一份状态见 packages/signals/src/signal.rs 的Signal::global用Signal::new_in_scope(value, ScopeId::ROOT)显式指定所有者 scope让 Signal 归属于根组件而不是当前子组件。不过官方同时提醒同一段 doc comment库作者应谨慎使用全局 Signal/全局 Memo因为它会让库里的组件难以在同一页面实例化多份——全局状态天然是单例的。与生命周期相关的另一面是“同步/线程”语义SignalT默认使用UnsyncStorage非跨线程如需跨线程共享例如从后台线程写入使用SyncSignalT即SignalT, SyncStorage或use_signal_sync。延伸Signal、Memo 与局部订阅如何协同本文主角是 Signal但要写对状态管理代码还需理解它和Memo的分工——两者共享同一套Readable/Writabletrait 体系用法几乎一致。use_signal(|| ...)创建基础状态实现见 packages/hooks/src/use_signal.rs提供SignalT, UnsyncStorageuse_memo(move || ...)根据依赖派生计算值实现见 packages/hooks/src/use_memo.rs 与 packages/signals/src/memo.rs。从Memo::new的源码可以看到它内部“建一个带回调的ReactiveContext再创建SignalT承载结果”并在每次依赖变化时把自身标脏、由异步任务统一重算——这也是为什么读取 Memo 的组件只会在结果变化时重渲染而不是上游每个中间值变化都重渲染。Memo 同样适用于“结果值作为ReadSignal/Memo属性传给子组件”的组合# use dioxus::prelude::*; #[component] fn App() - Element { let mut signal use_signal(|| 0); let doubled use_memo(move || signal * 2); rsx! { button { onclick: move |_| signal 1, Increase } Child { signal: doubled } } } #[component] fn Child(signal: Memousize) - Element { rsx! { {signal} } }局部订阅是这一切正确工作的前提因为 Signal 只在“被读取的组件”里登记订阅App只是把 signal 传给子组件、自己从不读取所以点击按钮时只有真正显示{signal}的Child会重渲染即使把 Signal 放进 contextuse_context_provider再在深层组件里读取效果也一样packages/signals/README.md 给出了完整示例。若想验证这些细节可以阅读 packages/signals/tests/ 下的create.rs、subscribe.rs、memo.rs测试以及 packages/signals/examples/ 中的context.rs、split_subscriptions.rs等可运行示例。小结Signal 使用四要诀读用read()/signal()写用write()/set()/运算符集合数据优先走ReadableVecExt/WritableVecExt等方法借用自动收敛在单次调用内写前先释放读——把读包进代码块或使用with/with_mut单作用域读写Async 中不跨await持有借用先克隆快照、异步完成后整体写回Signal 归创建它的组件所有需要长寿命状态就上提创建位置、改用 GlobalSignal或使用new_in_scope指定所有者切勿把子组件的 Signal 向上传递。【免费下载链接】dioxusFullstack app framework for web, desktop, and mobile.项目地址: https://gitcode.com/GitHub_Trending/di/dioxus创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表