前言
上一篇我们把 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 最基础的用法:读、写、订阅
举个例子:最经典的计数器。
rust
use dioxus::prelude::*;
fn Counter() -> Element {
let mut count = use_signal(|| 0);
rsx! {
div {
h2 { "当前计数:{count}" }
button {
onclick: move |_| count += 1,
"加一"
}
button {
onclick: move |_| count -= 1,
"减一"
}
}
}
}
这里先记两个点:
count += 1本质上还是在写Signal"当前计数:{count}"这种写法,本质上还是在读Signal
官方文档里把这个机制说得很明确:调用 .read() 会订阅当前响应式作用域,调用 .write() / .set() 会触发相关作用域重新运行。
换句话说,Dioxus 不是靠"整棵组件树一起刷新"来兜底,而是靠"谁读了它,谁订阅它"做细粒度更新。
如果你想显式地写出来,也可以这样:
rust
fn Counter() -> Element {
let mut count = use_signal(|| 0);
let current = count.read().clone();
rsx! {
div {
h2 { "当前计数:{current}" }
button {
onclick: move |_| {
*count.write() += 1;
},
"加一"
}
}
}
}
平时写业务,优先用顺手的语法糖就行。但心里要清楚,底层还是 read / write 两套动作。
2.2 ReadSignal / WriteSignal:组件接口别一上来就给可写权限
这个点放到组件封装里很好用。
举个例子:一个标题组件只负责展示用户名,那它其实根本不该知道怎么修改用户名。
rust
use dioxus::prelude::*;
#[component]
fn UserTitle(name: ReadSignal<String>) -> Element {
rsx! {
h1 { "你好,{name}" }
}
}
fn App() -> Element {
let name = use_signal(|| "Dioxus".to_string());
rsx! {
UserTitle { name }
}
}
这样写有几个很直接的好处:
- 子组件只能读,不能乱写
- 父组件既可以传
Signal,也可以传Memo - 组件职责更清楚,不容易演变成"谁拿到状态都能顺手改一下"
Dioxus 官方文档也建议过:接口层优先考虑 ReadSignal / WriteSignal 这种抽象,不要把某个具体响应式类型写死。
这个设计挺务实。组件真正关心的,通常不是"你是不是 Signal",而是"你能不能读""你能不能写"。
2.3 Signal 是 Copy,但里面的值不一定是
这个地方很容易误会。
很多人看到 Dioxus 里的信号可以随手 move 进闭包,就会下意识以为:里面的值是不是也跟着免费复制了?
不是。
Signal<T> 的 Copy,复制的是句柄,不是内部那份业务数据。
所以这段代码没问题:
rust
let mut count = use_signal(|| 0);
let inc = move |_| count += 1;
let dec = move |_| count -= 1;
因为你复制的是 count 这个信号句柄,不是里面那个整数本身。
但如果内部值是 String、Vec<T>、结构体,你在读出来以后,还是要遵守 Rust 的借用规则。
比如下面这种写法就是典型错误:
rust
let mut name = use_signal(|| "Rust".to_string());
// 错误写法:读借用还活着,又去写
let current = name.read();
name.set(format!("{current} + Dioxus"));
正确思路是先拿到一个拥有所有权的值,再去写:
rust
let mut name = use_signal(|| "Rust".to_string());
let current = name();
name.set(format!("{current} + Dioxus"));
或者:
rust
let mut tasks = use_signal(|| vec!["learn".to_string(), "build".to_string()]);
let next = {
let current = tasks.read();
let mut cloned = current.clone();
cloned.push("ship".to_string());
cloned
};
tasks.set(next);
说成人话就是:
Signal本身像一个可复制句柄.read()拿到的是读守卫.write()拿到的是写守卫- 内部值如果不是
Copy,你还是要决定什么时候clone
这套东西刚上手时会觉得 Rust 味儿挺重,但反过来看,它确实能少掉很多"这个状态到底是谁改的"之类的烂账。
3. use_memo:它负责派生值,不负责副作用
use_memo 在 Dioxus 里很有用,但也特别容易被用过头。
先说定义:
use_memo 用来根据其他响应式值,计算一个同步派生值。
举个例子:任务列表里有一个搜索词,我们只想展示匹配当前关键字的任务。
rust
use dioxus::prelude::*;
fn TaskList() -> Element {
let query = use_signal(String::new);
let tasks = use_signal(|| {
vec![
"learn rust".to_string(),
"build dioxus app".to_string(),
"write notes".to_string(),
]
});
let visible_tasks = use_memo(move || {
let keyword = query().trim().to_lowercase();
let items = tasks();
if keyword.is_empty() {
return items;
}
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 {
for task in visible_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。
举个例子,这种值我一般不会特地 memo:
rust
let button_text = if loading() { "提交中..." } else { "提交" };
这类计算极轻,而且可读性本来就很好,直接写就行。
use_memo 更适合这几类情况:
- 计算本身稍微重一点
- 结果会在多个地方复用
- 你想把依赖关系单独隔离出来
别把它用成"凡算必 memo"。那样最后只会把代码写得像在走流程。
4. use_effect:负责副作用,不负责算 UI
这一段我想多说一句:
use_effect 不是用来制造更多状态的,它是用来把状态同步到外部世界的。
官方文档对这件事的态度也很明确:Effect 要谨慎用,很多场景直接在 action 里处理会更合适。
4.1 一个正确场景:同步浏览器标题
举个例子:页面标题跟输入框内容保持一致。
rust
use dioxus::prelude::*;
fn TitleEditor() -> Element {
let mut title = use_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 迁过来,最容易顺手写出这种代码:
rust
let mut filtered = use_signal(Vec::<String>::new);
use_effect(move || {
let keyword = query().trim().to_lowercase();
let result = tasks()
.into_iter()
.filter(|task| task.to_lowercase().contains(&keyword))
.collect::<Vec<_>>();
filtered.set(result);
});
这段代码不是不能跑,但职责已经串了。
因为 filtered 本来就是从 query 和 tasks 算出来的,没必要再把它变成一个独立可写状态。你每多维护一份这种状态,就多一份同步成本。
更合适的写法就是前面那种 use_memo:
rust
let filtered = use_memo(move || {
let keyword = query().trim().to_lowercase();
tasks()
.into_iter()
.filter(|task| task.to_lowercase().contains(&keyword))
.collect::<Vec<_>>()
});
说到底还是前面那套分工:
- 需要"值"就用
memo - 需要"副作用"才用
effect
4.3 Dioxus 的 effect,没有 React 那种依赖数组心智负担
这点很多前端同学第一次用时会觉得挺省心。
在 React 里写 useEffect,脑子里经常要滚这几个问题:
- 依赖数组写全了吗
- 这个函数要不要
useCallback - 这次为什么重复执行了
而 Dioxus 的思路更直接:你在 use_effect 里面读了谁,谁就是依赖。
rust
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 本质上就是异步版的派生状态。
举个例子:根据当前搜索词拉推荐词。
rust
use dioxus::prelude::*;
async fn fetch_suggestions(keyword: String) -> Vec<String> {
if keyword.is_empty() {
return vec![];
}
// 这里只是示意,真实项目里换成 reqwest 即可
vec![
format!("{keyword} tutorial"),
format!("{keyword} example"),
format!("{keyword} github"),
]
}
fn SearchPanel() -> Element {
let query = use_signal(String::new);
let suggestions = use_resource(move || async move {
fetch_suggestions(query()).await
});
rsx! {
div {
input {
value: "{query}",
oninput: move |evt| query.set(evt.value()),
placeholder: "输入搜索词"
}
ul {
if let Some(items) = suggestions() {
for item in items {
li { "{item}" }
}
} else {
li { "加载中..." }
}
}
}
}
}
这里有几个要点:
query()在异步闭包里被读到了,所以query会成为资源依赖query改了,resource 会自动重跑- resource 运行中时,结果通常是
None - future 被重启时要考虑 cancel safe
cancel safe 这个点很重要。
因为 use_resource 发现依赖变了,会直接重启那条 future。也就是说,如果你在 future 里先改了某些全局状态,后面又 await 了,中途被取消时不能把系统留在半残状态。
5.1 use_resource 和 use_memo 最容易混的地方
压成一句话就是:
use_memo:同步计算,且结果会按PartialEq判断要不要继续传播use_resource:异步计算,只要 future 重新跑完,读它的地方就会重新感知结果
所以一个同步筛选列表,明显该用 memo。
一个依赖关键词发请求的推荐列表,明显该用 resource。
别把同步逻辑做成异步,也别把异步逻辑硬塞回 effect。
6. 一个完整例子:把 4 个角色放到同一页里
前面是拆开讲,这里把它们放到一个小例子里看,会直观很多。
场景:一个搜索页。
query:用户输入的关键词,用Signalall_posts:已有文章列表,用Signalvisible_posts:本地同步过滤结果,用Memosuggestions:远端推荐词,用Resource- 页面标题同步为关键词,用
Effect
rust
use dioxus::prelude::*;
async fn fetch_suggestions(keyword: String) -> Vec<String> {
if keyword.trim().is_empty() {
return vec![];
}
vec![
format!("{keyword} 入门"),
format!("{keyword} 实战"),
format!("{keyword} 踩坑"),
]
}
fn SearchPage() -> Element {
let mut query = use_signal(String::new);
let all_posts = use_signal(|| {
vec![
"Rust 所有权速查".to_string(),
"Dioxus 路由实践".to_string(),
"Axum 接口设计".to_string(),
"Dioxus Signals 详解".to_string(),
]
});
let visible_posts = use_memo(move || {
let keyword = query().trim().to_lowercase();
let posts = all_posts();
if keyword.is_empty() {
return posts;
}
posts.into_iter()
.filter(|post| post.to_lowercase().contains(&keyword))
.collect::<Vec<_>>()
});
let suggestions = use_resource(move || async move {
fetch_suggestions(query()).await
});
use_effect(move || {
let keyword = query();
let title = if keyword.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 {
for post in visible_posts.read().iter() {
li { "{post}" }
}
}
h2 { "推荐搜索词" }
ul {
if let Some(items) = suggestions() {
if items.is_empty() {
li { "暂无推荐" }
} else {
for item in items {
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 复制的是句柄
它能让你把状态很顺手地带进回调和异步任务里,这确实比手写一堆 Rc<RefCell<_>> 轻松很多。
但内部值该 clone 还是得 clone,读写守卫该避开重叠还是得避开。
Rust 这层约束没有消失,只是 Dioxus 把它包成了更适合写 UI 的样子。
总结
把这篇压成几句最实用的人话,大概就是:
Signal存源状态,Memo算同步派生值,Effect做副作用,Resource管异步派生值。- Dioxus 的依赖追踪不是靠手写依赖数组,而是靠"你在响应式作用域里读了谁"。
Signal的Copy很香,但复制的是句柄,不是里面那份业务数据。
我现在看 Dioxus,最喜欢的也就是这点:它不是靠"多几个 Hook"让你写前端,而是把响应式系统本身理顺了。
下一篇我会接着写组件、Props 和组件通信。到那时你会发现,前面这些 ReadSignal、状态提升、只读/可写边界,都会直接用上。
如果你已经在写 Dioxus,最让你别扭的是 Signal 的借用规则,还是 Effect / Memo 的分工?评论区聊聊。