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

前言

上一篇我们把 rsx! 语法拆开了。

这一篇聊 Dioxus 真正容易拉开差异的部分:响应式状态管理

如果你是从 React 过来的,一开始很容易把 Dioxus 理解成"Rust 版 React"。这个理解在组件写法、事件绑定、rsx! 这些层面没什么问题,但一到状态这里,就会越来越别扭。

原因不复杂:React 更像"组件重新执行,然后顺手把 UI 算出来";Dioxus 更在意"谁读了这个值,谁就订阅它"。

这意味着:

  1. Signal 是可变状态源头
  2. Memo 是同步派生值
  3. Effect 是副作用同步器
  4. 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 SignalCopy,但里面的值不一定是

这个地方很容易误会。

很多人看到 Dioxus 里的信号可以随手 move 进闭包,就会下意识以为:里面的值是不是也跟着免费复制了?

不是。

Signal<T>Copy,复制的是句柄,不是内部那份业务数据。

所以这段代码没问题:

rust 复制代码
let mut count = use_signal(|| 0);

let inc = move |_| count += 1;
let dec = move |_| count -= 1;

因为你复制的是 count 这个信号句柄,不是里面那个整数本身。

但如果内部值是 StringVec<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 不是新的源状态,它只是从 querytasks 算出来的结果。

这种场景就很适合 use_memo,因为它有两个特点:

  1. 在闭包里读到的响应式值,会自动成为依赖
  2. 只有新旧结果 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 本来就是从 querytasks 算出来的,没必要再把它变成一个独立可写状态。你每多维护一份这种状态,就多一份同步成本。

更合适的写法就是前面那种 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

一到异步,很多人的第一反应都是:

  1. 用户输入关键词
  2. use_effect 监听关键词
  3. effect 里发请求
  4. 请求回来再 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_resourceuse_memo 最容易混的地方

压成一句话就是:

  • use_memo:同步计算,且结果会按 PartialEq 判断要不要继续传播
  • use_resource:异步计算,只要 future 重新跑完,读它的地方就会重新感知结果

所以一个同步筛选列表,明显该用 memo

一个依赖关键词发请求的推荐列表,明显该用 resource

别把同步逻辑做成异步,也别把异步逻辑硬塞回 effect。

6. 一个完整例子:把 4 个角色放到同一页里

前面是拆开讲,这里把它们放到一个小例子里看,会直观很多。

场景:一个搜索页。

  • query:用户输入的关键词,用 Signal
  • all_posts:已有文章列表,用 Signal
  • visible_posts:本地同步过滤结果,用 Memo
  • suggestions:远端推荐词,用 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 个工具的边界就比较清楚了:

  • queryall_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 不要忘了 SignalCopy 复制的是句柄

它能让你把状态很顺手地带进回调和异步任务里,这确实比手写一堆 Rc<RefCell<_>> 轻松很多。

但内部值该 clone 还是得 clone,读写守卫该避开重叠还是得避开。

Rust 这层约束没有消失,只是 Dioxus 把它包成了更适合写 UI 的样子。

总结

把这篇压成几句最实用的人话,大概就是:

  1. Signal 存源状态,Memo 算同步派生值,Effect 做副作用,Resource 管异步派生值。
  2. Dioxus 的依赖追踪不是靠手写依赖数组,而是靠"你在响应式作用域里读了谁"。
  3. SignalCopy 很香,但复制的是句柄,不是里面那份业务数据。

我现在看 Dioxus,最喜欢的也就是这点:它不是靠"多几个 Hook"让你写前端,而是把响应式系统本身理顺了。

下一篇我会接着写组件、Props 和组件通信。到那时你会发现,前面这些 ReadSignal、状态提升、只读/可写边界,都会直接用上。

如果你已经在写 Dioxus,最让你别扭的是 Signal 的借用规则,还是 Effect / Memo 的分工?评论区聊聊。

相关推荐
yingyima1 小时前
Java 正则表达式:比你想象的更强大
前端
yuanyxh4 小时前
macOS 应用 - 纯对话生成
前端·macos·ai编程
大家的林语冰4 小时前
ES5 凉凉,Babel 8 正式发布,默认不再编译为 ES5 和 CJS......
前端·javascript·前端工程化
光影少年6 小时前
react批量更新、同步/异步更新场景
前端·react.js·掘金·金石计划
假如让我当三天老蒯6 小时前
模块化:ES Module 与 CommonJS 的区别
前端·面试
用户40950115773176 小时前
Private Forge v2.0 发布:12大前端业务场景技能系统
前端
weedsfly6 小时前
异步编程全景与事件循环——彻底搞懂 JS 执行机制
前端·javascript
用户059540174466 小时前
AI Agent记忆测试踩坑实录:Mock骗了我一周,Mem0+pytest一招破局
前端·css
用户1733598075376 小时前
纯前端 PDF 数字签名实战:Vue 3 + pdf-lib 在浏览器里完成签名嵌入
前端·javascript