Dioxus 的响应式系统:`Signal`、`Memo`、`Effect` 和异步状态到底该怎么分工

📅 2026/6/20 7:36:05
Dioxus 的响应式系统:`Signal`、`Memo`、`Effect` 和异步状态到底该怎么分工
前言上一篇我们把rsx!语法拆开了。这一篇聊 Dioxus 真正容易拉开差异的部分响应式状态管理。如果你是从 React 过来的一开始很容易把 Dioxus 理解成“Rust 版 React”。这个理解在组件写法、事件绑定、rsx!这些层面没什么问题但一到状态这里就会越来越别扭。原因不复杂React 更像“组件重新执行然后顺手把 UI 算出来”Dioxus 更在意“谁读了这个值谁就订阅它”。这意味着Signal是可变状态源头Memo是同步派生值Effect是副作用同步器use_resource是异步派生值这 4 个角色先分清后面再看组件通信、路由状态、全栈数据加载都会顺很多。顺手交代一下版本背景本文示例按Dioxus 0.7写参考的是官方 Learn 0.7 文档和docs.rs上 2026 年 6 月 19 日可见的dioxus文档版本。1. 先说结论4 个响应式工具各管一件事很多时候不是 API 难学而是职责容易混。先把分工摆出来use_signal存“会变”的源数据use_memo根据源数据计算“同步派生值”use_effect把状态同步到 UI 外面的世界use_resource根据源数据计算“异步派生值”如果你拿错工具代码通常会出现两种味道本来能直接算的值被你额外塞回另一个状态里本来只是个副作用被你写成一串绕来绕去的状态联动很多 React 项目后面越写越绕问题往往就出在这里。Dioxus 这套模型反而逼着你把边界想清楚。2.use_signal唯一可变源头读和写要分开想先把一句最要紧的话放前面在 Dioxus 里会变的业务状态通常就放在Signal里。2.1 最基础的用法读、写、订阅举个例子最经典的计数器。usedioxus::prelude::*;fnCounter()-Element{letmutcountuse_signal(||0);rsx!{div{h2{当前计数{count}}button{onclick:move|_|count1,加一}button{onclick:move|_|count-1,减一}}}}这里先记两个点count 1本质上还是在写Signal当前计数{count}这种写法本质上还是在读Signal官方文档里把这个机制说得很明确调用.read()会订阅当前响应式作用域调用.write()/.set()会触发相关作用域重新运行。换句话说Dioxus 不是靠“整棵组件树一起刷新”来兜底而是靠“谁读了它谁订阅它”做细粒度更新。如果你想显式地写出来也可以这样fnCounter()-Element{letmutcountuse_signal(||0);letcurrentcount.read().clone();rsx!{div{h2{当前计数{current}}button{onclick:move|_|{*count.write()1;},加一}}}}平时写业务优先用顺手的语法糖就行。但心里要清楚底层还是read / write 两套动作。2.2ReadSignal/WriteSignal组件接口别一上来就给可写权限这个点放到组件封装里很好用。举个例子一个标题组件只负责展示用户名那它其实根本不该知道怎么修改用户名。usedioxus::prelude::*;#[component]fnUserTitle(name:ReadSignalString)-Element{rsx!{h1{你好{name}}}}fnApp()-Element{letnameuse_signal(||Dioxus.to_string());rsx!{UserTitle{name}}}这样写有几个很直接的好处子组件只能读不能乱写父组件既可以传Signal也可以传Memo组件职责更清楚不容易演变成“谁拿到状态都能顺手改一下”Dioxus 官方文档也建议过接口层优先考虑ReadSignal/WriteSignal这种抽象不要把某个具体响应式类型写死。这个设计挺务实。组件真正关心的通常不是“你是不是Signal”而是“你能不能读”“你能不能写”。2.3Signal是Copy但里面的值不一定是这个地方很容易误会。很多人看到 Dioxus 里的信号可以随手move进闭包就会下意识以为里面的值是不是也跟着免费复制了不是。SignalT的Copy复制的是句柄不是内部那份业务数据。所以这段代码没问题letmutcountuse_signal(||0);letincmove|_|count1;letdecmove|_|count-1;因为你复制的是count这个信号句柄不是里面那个整数本身。但如果内部值是String、VecT、结构体你在读出来以后还是要遵守 Rust 的借用规则。比如下面这种写法就是典型错误letmutnameuse_signal(||Rust.to_string());// 错误写法读借用还活着又去写letcurrentname.read();name.set(format!({current} Dioxus));正确思路是先拿到一个拥有所有权的值再去写letmutnameuse_signal(||Rust.to_string());letcurrentname();name.set(format!({current} Dioxus));或者letmuttasksuse_signal(||vec![learn.to_string(),build.to_string()]);letnext{letcurrenttasks.read();letmutclonedcurrent.clone();cloned.push(ship.to_string());cloned};tasks.set(next);说成人话就是Signal本身像一个可复制句柄.read()拿到的是读守卫.write()拿到的是写守卫内部值如果不是Copy你还是要决定什么时候clone这套东西刚上手时会觉得 Rust 味儿挺重但反过来看它确实能少掉很多“这个状态到底是谁改的”之类的烂账。3.use_memo它负责派生值不负责副作用use_memo在 Dioxus 里很有用但也特别容易被用过头。先说定义use_memo用来根据其他响应式值计算一个同步派生值。举个例子任务列表里有一个搜索词我们只想展示匹配当前关键字的任务。usedioxus::prelude::*;fnTaskList()-Element{letqueryuse_signal(String::new);lettasksuse_signal(||{vec![learn rust.to_string(),build dioxus app.to_string(),write notes.to_string(),]});letvisible_tasksuse_memo(move||{letkeywordquery().trim().to_lowercase();letitemstasks();ifkeyword.is_empty(){returnitems;}items.into_iter().filter(|task|task.to_lowercase().contains(keyword)).collect::Vec_()});rsx!{div{input{value:{query},oninput:move|evt|query.set(evt.value()),placeholder:搜索任务}ul{fortaskinvisible_tasks.read().iter(){li{{task}}}}}}}这里的visible_tasks不是新的源状态它只是从query和tasks算出来的结果。这种场景就很适合use_memo因为它有两个特点在闭包里读到的响应式值会自动成为依赖只有新旧结果PartialEq不相等时依赖它的地方才会继续传播更新它和use_effect的区别也正在这里。memo关心的是“算出一个值”不是“顺手做点别的事”。3.1 什么时候该上use_memo我一般这么判断这个值是不是从别的状态算出来的它是不是同步计算这个计算是不是值得单独隔离出来如果答案都是“是”那就挺适合use_memo。常见例子搜索过滤后的列表表格排序结果根据多个字段组合出来的展示文案根据权限状态算出来的按钮可见性3.2 别把所有表达式都塞进use_memo这个也很重要。不是说“只要算了点东西”就必须上use_memo。举个例子这种值我一般不会特地 memoletbutton_textifloading(){提交中...}else{提交};这类计算极轻而且可读性本来就很好直接写就行。use_memo更适合这几类情况计算本身稍微重一点结果会在多个地方复用你想把依赖关系单独隔离出来别把它用成“凡算必 memo”。那样最后只会把代码写得像在走流程。4.use_effect负责副作用不负责算 UI这一段我想多说一句use_effect不是用来制造更多状态的它是用来把状态同步到外部世界的。官方文档对这件事的态度也很明确Effect 要谨慎用很多场景直接在 action 里处理会更合适。4.1 一个正确场景同步浏览器标题举个例子页面标题跟输入框内容保持一致。usedioxus::prelude::*;fnTitleEditor()-Element{letmuttitleuse_signal(||Dioxus Demo.to_string());use_effect(move||{#[cfg(target_arch wasm32)]{web_sys::window().unwrap().document().unwrap().set_title(title());}});rsx!{input{value:{title},oninput:move|evt|title.set(evt.value()),placeholder:输入页面标题}}}这里的title是 UI 内部状态。浏览器的document.title不属于 Dioxus 的 UI 树它在外面。所以这件事交给use_effect很自然。4.2 一个常见误区拿effect做派生状态很多人从 React 迁过来最容易顺手写出这种代码letmutfiltereduse_signal(Vec::String::new);use_effect(move||{letkeywordquery().trim().to_lowercase();letresulttasks().into_iter().filter(|task|task.to_lowercase().contains(keyword)).collect::Vec_();filtered.set(result);});这段代码不是不能跑但职责已经串了。因为filtered本来就是从query和tasks算出来的没必要再把它变成一个独立可写状态。你每多维护一份这种状态就多一份同步成本。更合适的写法就是前面那种use_memoletfiltereduse_memo(move||{letkeywordquery().trim().to_lowercase();tasks().into_iter().filter(|task|task.to_lowercase().contains(keyword)).collect::Vec_()});说到底还是前面那套分工需要“值”就用memo需要“副作用”才用effect4.3 Dioxus 的effect没有 React 那种依赖数组心智负担这点很多前端同学第一次用时会觉得挺省心。在 React 里写useEffect脑子里经常要滚这几个问题依赖数组写全了吗这个函数要不要useCallback这次为什么重复执行了而 Dioxus 的思路更直接你在use_effect里面读了谁谁就是依赖。use_effect(move||{log::info!(当前关键字{},query());});这里 effect 会订阅query。如果你没读tasks它就不会因为tasks变化而重跑。这套自动追踪机制比手动维护依赖数组更不容易漏。当然它也不是完全没代价。你得更自觉地控制“effect 里到底读了什么”不然依赖范围可能比你以为的大。5.use_resource异步数据别硬塞进effect一到异步很多人的第一反应都是用户输入关键词use_effect监听关键词effect 里发请求请求回来再 set 一个 signal能不能写能。但 Dioxus 已经给了一个更顺手的工具use_resource。use_resource本质上就是异步版的派生状态。举个例子根据当前搜索词拉推荐词。usedioxus::prelude::*;asyncfnfetch_suggestions(keyword:String)-VecString{ifkeyword.is_empty(){returnvec![];}// 这里只是示意真实项目里换成 reqwest 即可vec![format!({keyword} tutorial),format!({keyword} example),format!({keyword} github),]}fnSearchPanel()-Element{letqueryuse_signal(String::new);letsuggestionsuse_resource(move||asyncmove{fetch_suggestions(query()).await});rsx!{div{input{value:{query},oninput:move|evt|query.set(evt.value()),placeholder:输入搜索词}ul{ifletSome(items)suggestions(){foriteminitems{li{{item}}}}else{li{加载中...}}}}}}这里有几个要点query()在异步闭包里被读到了所以query会成为资源依赖query改了resource 会自动重跑resource 运行中时结果通常是Nonefuture 被重启时要考虑cancel safecancel safe这个点很重要。因为use_resource发现依赖变了会直接重启那条 future。也就是说如果你在 future 里先改了某些全局状态后面又await了中途被取消时不能把系统留在半残状态。5.1use_resource和use_memo最容易混的地方压成一句话就是use_memo同步计算且结果会按PartialEq判断要不要继续传播use_resource异步计算只要 future 重新跑完读它的地方就会重新感知结果所以一个同步筛选列表明显该用memo。一个依赖关键词发请求的推荐列表明显该用resource。别把同步逻辑做成异步也别把异步逻辑硬塞回 effect。6. 一个完整例子把 4 个角色放到同一页里前面是拆开讲这里把它们放到一个小例子里看会直观很多。场景一个搜索页。query用户输入的关键词用Signalall_posts已有文章列表用Signalvisible_posts本地同步过滤结果用Memosuggestions远端推荐词用Resource页面标题同步为关键词用Effectusedioxus::prelude::*;asyncfnfetch_suggestions(keyword:String)-VecString{ifkeyword.trim().is_empty(){returnvec![];}vec![format!({keyword} 入门),format!({keyword} 实战),format!({keyword} 踩坑),]}fnSearchPage()-Element{letmutqueryuse_signal(String::new);letall_postsuse_signal(||{vec![Rust 所有权速查.to_string(),Dioxus 路由实践.to_string(),Axum 接口设计.to_string(),Dioxus Signals 详解.to_string(),]});letvisible_postsuse_memo(move||{letkeywordquery().trim().to_lowercase();letpostsall_posts();ifkeyword.is_empty(){returnposts;}posts.into_iter().filter(|post|post.to_lowercase().contains(keyword)).collect::Vec_()});letsuggestionsuse_resource(move||asyncmove{fetch_suggestions(query()).await});use_effect(move||{letkeywordquery();lettitleifkeyword.is_empty(){搜索文章.to_string()}else{format!(搜索{keyword})};#[cfg(target_arch wasm32)]{web_sys::window().unwrap().document().unwrap().set_title(title);}});rsx!{section{h1{文章搜索}input{value:{query},oninput:move|evt|query.set(evt.value()),placeholder:输入 Rust / Dioxus}h2{本地过滤结果}ul{forpostinvisible_posts.read().iter(){li{{post}}}}h2{推荐搜索词}ul{ifletSome(items)suggestions(){ifitems.is_empty(){li{暂无推荐}}else{foriteminitems{li{{item}}}}}else{li{推荐词加载中...}}}}}}这段代码里4 个工具的边界就比较清楚了query、all_posts是源状态visible_posts是同步派生值suggestions是异步派生值document.title同步属于副作用业务状态如果能按这个思路拆开组件基本就不太会写成一锅粥。7. 从 React 迁过来时最容易拧巴的 4 个点最后把最常见的误区单独收一下。7.1 不要把use_signal仅仅理解成useState它当然有“存状态”的功能但更关键的是它参与了 Dioxus 的依赖追踪。你读它不只是拿值你是在声明“我关心它”。7.2 不要用effect维护本来可以直接推导的状态这类代码短期能跑时间一长最容易把人绕晕。能memo的就别effect signal二次倒腾。7.3 不要神化memo简单表达式直接写。真正值得抽出来的是那些同步派生值、计算稍重或者你明确想隔离依赖边界的逻辑。7.4 不要忘了Signal的Copy复制的是句柄它能让你把状态很顺手地带进回调和异步任务里这确实比手写一堆RcRefCell_轻松很多。但内部值该clone还是得clone读写守卫该避开重叠还是得避开。Rust 这层约束没有消失只是 Dioxus 把它包成了更适合写 UI 的样子。总结把这篇压成几句最实用的人话大概就是Signal存源状态Memo算同步派生值Effect做副作用Resource管异步派生值。Dioxus 的依赖追踪不是靠手写依赖数组而是靠“你在响应式作用域里读了谁”。Signal的Copy很香但复制的是句柄不是里面那份业务数据。我现在看 Dioxus最喜欢的也就是这点它不是靠“多几个 Hook”让你写前端而是把响应式系统本身理顺了。下一篇我会接着写组件、Props 和组件通信。到那时你会发现前面这些ReadSignal、状态提升、只读/可写边界都会直接用上。如果你已经在写 Dioxus最让你别扭的是Signal的借用规则还是Effect/Memo的分工评论区聊聊。