使用 Effects 响应变化
到目前为止,我们还没有提及响应式系统的另一半核心概念:Effects(副作用)。
响应式系统由两部分协同工作:更新单个响应式值(“Signals”)会通知依赖这些值的代码段(“Effects”),告知它们需要重新运行。响应式系统的这两部分是相互依赖的。如果没有 Effect,Signal 虽然可以在响应式系统内部发生变化,但这种变化永远无法以与外部世界交互的方式被观察到。如果没有 Signal,Effect 只会运行一次,之后便不再运行,因为没有可供订阅的可观察值。Effect 从字面上看确实就是响应式系统的“副作用(side effects)”:它们存在的目的,是为了让响应式系统与外部的非响应式世界保持同步。
渲染器利用 Effect 来更新 DOM 的各个部分,以响应 Signal 的变化。你也可以创建自己的 Effect,通过其他方式将响应式系统与外部世界进行同步。
Effect::new 接受一个函数作为参数。它会在响应式系统的下一个“tick”运行该函数。(举个例子,如果你在组件中使用它,它恰好会在该组件渲染 完成之后 运行。)如果你在该函数内部访问了任何响应式 Signal,系统就会注册该 Effect 依赖于该 Signal 这一事实。每当该 Effect 所依赖的 Signal 中有任何一个发生变化,Effect 就会重新运行。
let (a, set_a) = signal(0);
let (b, set_b) = signal(0);
Effect::new(move |_| {
// 立即打印 "Value: 0" 并订阅 `a`
logging::log!("Value: {}", a.get());
});
Effect 函数在被调用时会接收一个参数,该参数包含了它上一次运行时返回的值。在首次运行时,该参数为 None。
默认情况下,Effect 不会在服务端运行。这意味着你可以在 Effect 函数内部调用浏览器专属的 API,而不会引发问题。如果你需要 Effect 在服务端运行,请使用 Effect::new_isomorphic。
内容详解
为了让你更容易理解,我们可以把这个系统想象成 “广播电台” 和 “收音机” 的关系。
1. 文档核心概念通俗解释
文档主要讲了以下几点:
-
什么是响应式系统? 它由两部分组成:
- Signal(信号): 就像“广播电台”。它保存数据,当数据变化时,它会发出通知。
- Effect(效应): 就像“收音机”或“听众”。它依赖于信号,当信号发出通知时,它会收到消息并执行相应的操作(比如更新网页上的文字、打印日志等)。
-
为什么需要 Effect?
- 如果没有 Effect,Signal 里的数据变了也就变了,没人知道,网页也不会更新,就像电台在播音但没人听。
- 如果没有 Signal,Effect 只会运行一次就结束了,因为它没有可以监听的目标。
- Effect 的作用就是同步:把内部的数据变化,同步到外部世界(比如修改 DOM 元素、发送网络请求、打印控制台日志)。
-
它是怎么工作的?
- 当你创建一个 Effect 时,你给它一段代码(函数)。
- 这段代码会自动运行一次。
- 关键点(自动追踪): 在这段代码运行过程中,如果你读取了某个 Signal 的值(比如
count.get()),这个 Effect 就会自动拿个小本本记下来:“哦,我依赖这个count”。 - 以后只要这个
count变了,Effect 就知道:“我依赖的数据变了,我得重新运行一遍代码”。
-
运行环境: 默认情况下,Effect 只在浏览器端运行,不在服务器端运行。这很安全,因为你可以在 Effect 里放心地使用浏览器专用的功能(比如
window或document),而不用担心服务器跑不动报错。
2. 代码与语法通俗详解
下面我们来拆解文档中提供的 Rust 代码,并解释其中用到的语法。
// 1. 创建信号
let (a, set_a) = signal(0);
let (b, set_b) = signal(0);
// 2. 创建 Effect
Effect::new(move |_| {
// 3. 这里的代码会立即执行一次
// 并且因为它用了 a.get(),所以它订阅了 a
logging::log!("Value: {}", a.get());
});
第一部分:创建信号
let (a, set_a) = signal(0);
- 含义: 创建一个初始值为
0的信号。 - 语法解释
let (x, y) = ...:- 这叫解构(Destructuring)。
signal(0)这个函数返回一个“包裹”,里面装着两个东西。我们用括号把它们拆出来,分别起名叫a和set_a。 a:读取器(Getter)。就像收音机,用来听当前的值是多少。set_a:设置器(Setter)。就像麦克风,用来改变这个值。
- 这叫解构(Destructuring)。
第二部分:创建 Effect
Effect::new( ... );
- 含义: 告诉系统“新建一个监听任务”。
- 语法解释
Struct::method:Effect是一个结构体(或者说一个工具类)。::new表示调用这个工具类的静态方法(通常用于创建一个新的实例)。这就好比在说“Effect 工具箱,请给我新建一个(new)实例”。
第三部分:闭包(核心难点)
这是代码中最“Rust”的部分:
move |_| { ... }
这整体是一个 闭包(Closure) ,你可以把它理解为 “匿名函数” (一个没有名字的函数,直接当参数传进去)。
-
{ ... }:- 这是函数体。花括号里面包裹的代码,就是当信号变化时,你要执行的具体操作。
-
|_|:- 这是参数列表。在其他语言里通常写成
(arg),Rust 里用竖线|arg|。 _(下划线):这是一个特殊的符号,意思是**“我不关心这个参数叫什么,我也不用它”**。- 文档提到: Effect 每次运行都会收到上一次运行的返回值。但在上面的代码里,我们不需要上一次的返回值,所以用
_把这个参数忽略掉。
- 这是参数列表。在其他语言里通常写成
-
move:- 通俗解释: “打包带走”。
- 详细解释: Rust 非常讲究“所有权(Ownership)”。
- 变量
a是在 Effect 外面定义的。 - Effect 里的代码可能会在将来(当数据变化时)多次运行。
- 如果不加
move,Rust 会担心:万一 Effect 还没运行完,外面的a就被清理掉了怎么办? - 加上
move,就是告诉 Rust:“把这个闭包里用到的外部变量(比如a)的所有权转移到这个闭包里面来”。可以想象成把a也就是那个“读取器”装进了 Effect 的背包里,走到哪带到哪,确保随时可用。
- 变量
第四部分:执行逻辑
logging::log!("Value: {}", a.get());
-
logging::log!(...):- 这是一个宏(Macro),你看它后面有个感叹号
!。 - 宏就像是“代码生成器”。你可以把它简单理解为打印日志的函数,类似于 JS 的
console.log。 "Value: {}"是格式化字符串,{}是占位符,会被后面的值替换。
- 这是一个宏(Macro),你看它后面有个感叹号
-
a.get():- 含义: 获取信号
a当前的值。 - 重要作用: 只要你在这里写了
a.get(),Leptos 框架就会立刻发现:“哈!这个 Effect 用到了a!”。于是它自动建立连接:以后只要set_a改变了数值,这个 Effect 就会再次运行。 - 注意: 代码里没有用到
b.get()。所以,即使你调用set_b(100)改变了b的值,这个 Effect 也不会运行,因为它不依赖b。
- 含义: 获取信号
总结
这段代码做的事情是:
- 创建了两个数据源
a和b。 - 创建了一个监听器(Effect)。
- 这个监听器把
a“打包”带在身上(move)。 - 监听器立即运行一次,打印
a的值。 - 因为读取了
a,监听器从此开始“死死盯着”a。 - 一旦
a变了,监听器就重新打印日志。 b变了,监听器无动于衷。
自动追踪与动态依赖
如果你熟悉像 React 这样的框架,你可能会注意到一个关键的区别。React 和类似的框架通常需要你传递一个“依赖数组(dependency array)”,这是一组显式的变量,用于决定 Effect 何时应该重新运行。
因为 Leptos 沿袭了同步响应式编程(synchronous reactive programming)的传统,所以我们不需要这种显式的依赖列表。相反,我们会根据 Effect 内部访问了哪些信号(signals)来自动追踪依赖。
这带来了两个结果(此处并非双关)。依赖项是:
- 自动的:你不需要维护依赖列表,也不必担心应该包含或不包含什么。框架会自动追踪哪些信号可能导致 Effect 重新运行,并为你处理这一切。
- 动态的:每次 Effect 运行时,依赖列表都会被清空并更新。例如,如果你的 Effect 包含条件判断,那么只有当前分支中使用的信号才会被追踪。这意味着 Effect 的重新运行次数被控制在绝对最小的范围内。
如果这听起来像魔法,并且你想深入了解自动依赖追踪的工作原理,请观看这个视频。(抱歉音量较低!)
自动追踪与动态依赖内容详解
第一部分:文档解释 —— “自动依赖追踪”是什么?
1. React 的做法(显式依赖)
如果你用过 React 这样的框架,你可能熟悉 useEffect。在 React 里,如果你希望一个副作用函数(比如更新网页标题)在某个变量变化时重新运行,你需要手动写一个“依赖数组”。
- 通俗比喻:就像你要做一道菜,React 要求你必须手写一张清单交给厨师:“如果盐或者糖变了,你就重做这道菜。”如果你忘了写“盐”,即使盐罐子空了,厨师也不会重做,这就出 bug 了。
2. Leptos 的做法(自动追踪)
Leptos 不需要你写这个清单。它会在代码运行时“偷窥”你用了哪些变量。
- 通俗比喻:Leptos 派了一个观察员站在厨师旁边。厨师拿起盐,观察员就记下:“哦,这道菜依赖盐”;厨师拿起糖,观察员记下:“哦,这道菜也依赖糖”。
- 好处 1:自动化:你不需要维护清单,不会因为漏写变量而产生 bug。
- 好处 2:动态化:
- 假设代码里有个
if判断:如果是星期天,就加糖。 - 在 React 里,你必须把“糖”一直写在清单里。
- 在 Leptos 里,如果今天不是星期天,代码没走到“加糖”那一行,观察员就不会把“糖”记入依赖。只有当代码真的读取了那个信号,它才会被追踪。这使得程序效率最高,不跑冤枉路。
- 假设代码里有个
第二部分:视频代码深度解析 —— 如何从零手写这个系统?
视频演示了如何用 Rust 从头写一个简易版的 Leptos 响应式系统。我们分步骤来拆解,顺便解释 Rust 语法。
核心角色介绍
- Runtime (运行时):这是一个全局的大仓库,存放所有的数据(Signals)和所有的操作(Effects)。
- Signal (信号):一个会变的数据(比如计数器)。
- Effect (副作用):一个函数,当它依赖的数据变化时,它会自动重新运行(比如“把计数器的值更新到网页上”)。
步骤一:搭建仓库 (Runtime)
在 Rust 中,我们要定义数据结构。
// struct 就像是一个蓝图或模具,定义了一个对象的形状
struct Runtime {
// Vector (Vec) 是一个可变长度的数组/列表
// Box<...> 意思是把东西放在“堆”内存上,这里只拿一个指针。
// RefCell<...> 是 Rust 的一个魔法盒子。
// Any 意思是这盒子里装什么类型都可以。
signal_values: RefCell<Vec<Box<RefCell<dyn Any>>>>,
// 这是一个关键变量!用来记录“当前正在运行哪个 Effect”
// Option 意思是:可能有值 (Some),也可能没值 (None)
running_effect: Cell<Option<EffectId>>,
// 记录每个 Signal 被哪些 Effect 订阅了
// HashSet 是一个集合,里面的元素不重复
signal_subscribers: RefCell<HashMap<SignalId, HashSet<EffectId>>>,
}
Rust 语法小白课堂:
RefCell(内部可变性):Rust 默认非常严格,如果你借阅了一本书(引用),你就不能在上面乱涂乱画(修改)。RefCell像是一个特权通行证,它允许你在看似不可变的情况下,在运行时去修改里面的数据。这在实现这种复杂系统时非常有用。Box(堆分配):Rust 里的变量默认在栈上,空间有限且位置固定。Box就像是你租了一个大仓库(堆),把东西放进去,手里只拿着仓库钥匙(指针)。这样方便移动钥匙,而不用搬运货物。
步骤二:创建信号 (Create Signal)
我们需要一个函数来把数据存进仓库,并给用户一个“取货凭证”(ID)。
// <T> 是泛型,表示这个函数可以接受任何类型(整数、字符串等)
// 'static 表示这个数据会活得跟程序一样长
fn create_signal<T>(cx: &'static Runtime, value: T) -> Signal<T> {
// 1. 把值包装好,塞进仓库的 signal_values 列表里
cx.signal_values.borrow_mut().push(Box::new(RefCell::new(value)));
// 2. 算出这个新值的 ID (就是列表的最后一个索引)
let id = cx.signal_values.borrow().len() - 1;
// 3. 返回一个 Signal 结构体,里面拿着 ID 和仓库的引用
Signal { cx, id, ty: PhantomData }
}
Rust 语法小白课堂:
- 泛型
<T>:就像一个万能插座,T 代表 Type。你可以传入i32(整数),也可以传String。 borrow_mut():这是RefCell的用法。意思是“我要借用这块数据,并且我要修改它(mut = mutable)”。
步骤三:读取信号 (Get) —— 自动追踪的核心!
这是最精彩的部分。当你读取一个信号的值时,它会偷偷看一眼现在是谁在读它。
impl<T> Signal<T> {
fn get(&self) -> T {
// --- 自动追踪逻辑开始 ---
// 检查仓库里的 running_effect 变量
if let Some(effect_id) = self.cx.running_effect.get() {
// 如果真的有一个 Effect 正在运行(观察员发现了!)
// 就把这个 Effect 的 ID,加入到我这个 Signal 的订阅者列表中
let mut subs = self.cx.signal_subscribers.borrow_mut();
// 在我的 ID 名下,添加这个 Effect ID
subs.entry(self.id).or_default().insert(effect_id);
}
// --- 自动追踪逻辑结束 ---
// 然后才是正常的取值逻辑,从仓库里把值拿出来拷贝一份给你
// ... (省略取值代码)
}
}
原理详解:
- 想象有一个公共黑板叫
running_effect。 - 当你调用
get()时,Signal 会抬头看黑板。 - 如果黑板上写着“Effect A 正在运行”,Signal 就知道了:“原来是 Effect A 需要我。”
- 于是 Signal 拿出自己的小本本(
signal_subscribers),记下:“Effect A 订阅了我。”
步骤四:运行副作用 (Run Effect)
为了让上面的 get 能看到黑板上有字,Effect 运行的时候必须自己去写黑板。
fn run_effect(cx: &'static Runtime, effect_id: EffectId) {
// 1. 获取之前的运行状态(也许有 Effect 嵌套 Effect 的情况)
let prev_effect = cx.running_effect.get();
// 2. 【关键】把自己(当前的 Effect ID)写在黑板上
cx.running_effect.set(Some(effect_id));
// 3. 真正执行用户写的那个函数(比如打印日志、更新 DOM)
// 在这期间,如果函数里调用了 signal.get(),Signal 就能看到黑板上的 ID 了!
let effect_func = ...; // 从仓库取出函数
effect_func();
// 4. 恢复现场。把黑板擦回之前的状态。
cx.running_effect.set(prev_effect);
}
Rust 语法小白课堂:
- Closure (闭包):视频中
create_effect接收的参数通常是一个闭包,长这样move || { count.get() + 1 }。move关键字的意思是“把这个闭包里用到的外部变量(比如 count)的所有权转移进来”,这样闭包即使以后运行,也能找到这些变量。
步骤五:更新信号 (Set) —— 触发响应
当你要修改数据时,Signal 会去查它的小本本,看看谁关注了它。
impl<T> Signal<T> {
fn set(&self, new_value: T) {
// 1. 在仓库里更新值
// ... (省略更新代码)
// 2. 【关键】拿出小本本,看谁订阅了我
let subs = self.cx.signal_subscribers.borrow();
if let Some(effect_ids) = subs.get(&self.id) {
// 3. 遍历每一个订阅的 Effect ID
for effect_id in effect_ids {
// 4. 重新运行这些 Effect
run_effect(self.cx, *effect_id);
}
}
}
}
Rust 语法小白课堂:
for ... in ...:这就是 Rust 的循环,遍历列表里的每一个东西。
总结:自动依赖追踪的工作流
让我们把整个流程串起来,看一个完整的动画:
-
用户写代码:
create_effect(move || { console_log("计数: " + count.get()); }) -
Effect 首次运行:
- 系统调用
run_effect。 - 系统在全局黑板
running_effect写上:“现在是 Effect #1 在运行”。 - 开始执行用户代码
console_log...。
- 系统调用
-
触发 Get:
- 代码执行到
count.get()。 count(Signal) 抬头看黑板,发现是 Effect #1。count在自己的订阅者列表里加上 Effect #1。
- 代码执行到
-
Effect 结束:
- 用户代码执行完毕。
- 系统把黑板擦干净 (
running_effect设为 None)。
-
用户更新数据:
- 用户点击按钮,调用
count.set(5)。 count更新自己的值为 5。count翻看订阅者列表,发现有 Effect #1。count大喊一声:“Effect #1,该干活了!”(调用run_effect)。- 回到第 2 步,Effect 重新运行,界面更新。
- 用户点击按钮,调用
这就是为什么你不需要手写依赖数组。因为在代码运行的那一瞬间,读取这个动作本身就完成了订阅的过程。
Effect 作为“近似零成本”的抽象
虽然从最严格的技术意义上讲,它们并非“零成本抽象(zero-cost abstraction)”——因为它们需要占用一些额外的内存,并且存在于运行时中等等——但在更高层面上,就你在其中执行的昂贵 API 调用或其他工作而言,Effect 确实是一种零成本抽象。基于你所描述的逻辑,它们仅在绝对必要时才重新运行,将运行次数降至最低。
想象一下,我正在开发某种聊天软件,希望用户能够显示全名,或者只显示名字(First Name),并且每当名字发生变化时通知服务器:
let (first, set_first) = signal(String::new());
let (last, set_last) = signal(String::new());
let (use_last, set_use_last) = signal(true);
// 无论何时源信号发生变化,
// 这都会将名字添加到日志中
Effect::new(move |_| {
logging::log!(
"{}", if use_last.get() {
format!("{} {}", first.get(), last.get())
} else {
first.get()
},
)
});
如果 use_last 为 true,那么每当 first、last 或 use_last 发生变化时,Effect 都应该重新运行。但是,如果我将 use_last 切换为 false,那么 last 的变化将永远不会导致全名发生改变。实际上,last 会从依赖列表中移除,直到 use_last 再次被切换回来。这样一来,即便我在 use_last 仍为 false 时多次修改 last,也能避免向 API 发送多次不必要的请求。
创建 effect,还是不创建 effect?
Effects 旨在将响应式系统与外部的非响应式世界同步,而不是在不同响应式值之间同步。换句话说:使用 effect 从一个 signal 读取值并在另一个 signal 中设置它总是次优的。
如果您需要定义一个依赖于其他 signals 值的 signal,请使用派生 signal 或 Memo。在 effect 内写入 signal 不是世界末日,它不会导致您的计算机着火,但派生 signal 或 memo 总是更好——不仅因为数据流清晰,而且因为性能更好。
let (a, set_a) = signal(0);
// ⚠️ 不太好
let (b, set_b) = signal(0);
Effect::new(move |_| {
set_b.set(a.get() * 2);
});
// ✅ 太棒了!
let b = move || a.get() * 2;
如果您需要将某些响应式值与外部的非响应式世界同步——如 Web API、控制台、文件系统或 DOM——在 effect 中写入 signal 是一种很好的方法。但在许多情况下,您会发现您实际上是在事件监听器或其他东西内部写入 signal,而不是在 effect 内部。在这些情况下,您应该查看 leptos-use 看看它是否已经提供了响应式包装原语来做到这一点!
如果您想了解更多关于何时应该和不应该使用
create_effect的信息,查看这个视频 进行更深入的考虑!
Effects 和渲染
我们已经走了这么远而没有提到 effects,因为它们内置在 Leptos DOM 渲染器中。我们已经看到您可以创建一个 signal 并将其传递到 view 宏中,它将在 signal 更改时更新相关的 DOM 节点:
let (count, set_count) = signal(0);
view! {
<p>{count}</p>
}
这之所以有效,是因为框架本质上创建了一个包装此更新的 effect。您可以想象 Leptos 将此视图转换为类似这样的东西:
let (count, set_count) = signal(0);
// 创建 DOM 元素
let document = leptos::document();
let p = document.create_element("p").unwrap();
// 创建 effect 来响应式更新文本
Effect::new(move |prev_value| {
// 首先,访问 signal 的值并将其转换为字符串
let text = count.get().to_string();
// 如果这与之前的值不同,更新节点
if prev_value != Some(text) {
p.set_text_content(&text);
}
// 返回此值,以便我们可以记忆化下一次更新
text
});
每次 count 更新时,此 effect 将重新运行。这就是允许对 DOM 进行响应式、细粒度更新的原因。
使用 Effect::watch() 显式跟踪
除了 Effect::new() 之外,Leptos 还提供了一个 Effect::watch() 函数,可用于通过显式传入一组要跟踪的值来分离跟踪和响应变化。
watch 接受三个参数。dependency_fn 参数被响应式跟踪,而 handler 和 immediate 不被跟踪。每当 dependency_fn 更改时,handler 就会运行。如果 immediate 是 false,handler 只会在检测到在 dependency_fn 中访问的任何 signal 的第一次更改后运行。watch 返回一个 Effect,可以用 .stop() 调用来停止跟踪依赖。
let (num, set_num) = signal(0);
let effect = Effect::watch(
move || num.get(),
move |num, prev_num, _| {
leptos::logging::log!("Number: {}; Prev: {:?}", num, prev_num);
},
false,
);
set_num.set(1); // > "Number: 1; Prev: Some(0)"
effect.stop(); // 停止监视
set_num.set(2); // (什么都不发生)
CodeSandbox 源码
use leptos::html::Input;
use leptos::prelude::*;
#[derive(Copy, Clone)]
struct LogContext(RwSignal<Vec<String>>);
#[component]
fn App() -> impl IntoView {
// 只是在这里制作一个可见的日志
// 您可以忽略这个...
let log = RwSignal::<Vec<String>>::new(vec![]);
let logged = move || log.get().join("\n");
// newtype 模式在这里不是*必需的*,但是一个好的实践
// 它避免了与其他可能的未来 `RwSignal<Vec<String>>` contexts 的混淆
// 并使引用它变得更容易
provide_context(LogContext(log));
view! {
<CreateAnEffect/>
<pre>{logged}</pre>
}
}
#[component]
fn CreateAnEffect() -> impl IntoView {
let (first, set_first) = signal(String::new());
let (last, set_last) = signal(String::new());
let (use_last, set_use_last) = signal(true);
// 这将在任何源 signals 更改时将名称添加到日志中
Effect::new(move |_| {
log(if use_last.get() {
let first = first.read();
let last = last.read();
format!("{first} {last}")
} else {
first.get()
})
});
view! {
<h1>
<code>"create_effect"</code>
" Version"
</h1>
<form>
<label>
"First Name"
<input
type="text"
name="first"
prop:value=first
on:change:target=move |ev| set_first.set(ev.target().value())
/>
</label>
<label>
"Last Name"
<input
type="text"
name="last"
prop:value=last
on:change:target=move |ev| set_last.set(ev.target().value())
/>
</label>
<label>
"Show Last Name"
<input
type="checkbox"
name="use_last"
prop:checked=use_last
on:change:target=move |ev| set_use_last.set(ev.target().checked())
/>
</label>
</form>
}
}
#[component]
fn ManualVersion() -> impl IntoView {
let first = NodeRef::<Input>::new();
let last = NodeRef::<Input>::new();
let use_last = NodeRef::<Input>::new();
let mut prev_name = String::new();
let on_change = move |_| {
log(" listener");
let first = first.get().unwrap();
let last = last.get().unwrap();
let use_last = use_last.get().unwrap();
let this_one = if use_last.checked() {
format!("{} {}", first.value(), last.value())
} else {
first.value()
};
if this_one != prev_name {
log(&this_one);
prev_name = this_one;
}
};
view! {
<h1>"Manual Version"</h1>
<form on:change=on_change>
<label>"First Name" <input type="text" name="first" node_ref=first/></label>
<label>"Last Name" <input type="text" name="last" node_ref=last/></label>
<label>
"Show Last Name" <input type="checkbox" name="use_last" checked node_ref=use_last/>
</label>
</form>
}
}
fn log(msg: impl std::fmt::Display) {
let log = use_context::<LogContext>().unwrap().0;
log.update(|log| log.push(msg.to_string()));
}
fn main() {
leptos::mount::mount_to_body(App)
}