用 Effects 响应变化

我们已经走到了这一步,却还没有提到反应式系统的一半内容:Effects(效果)

反应性由两部分组成:更新单个反应性值(“信号”)会通知依赖于它们的代码片段(“效果”)需要重新运行。这两部分是相互依赖的。没有效果,信号可以在反应式系统中发生变化,但无法被外界观察到,也无法与外界交互。没有信号,效果只会运行一次,之后再也不会运行,因为没有可观察的值可以订阅。效果实际上是反应式系统的“副作用”:它们的存在是为了将反应式系统与外部的非反应式世界同步。

渲染器使用效果来根据信号的变化更新 DOM 的某些部分。你也可以创建自己的效果,以其他方式将反应式系统与外界同步。

Effect::new 接受一个函数作为参数。它会在反应式系统的下一个“tick”中运行该函数。(例如,如果在组件中使用它,它会在组件渲染后立即运行。)如果你在这个函数中访问了任何反应性信号,效果会记录下该效果依赖于这些信号的事实。只要效果依赖的信号之一发生变化,效果就会再次运行。

let (a, set_a) = signal(0);
let (b, set_b) = signal(0);

Effect::new(move |_| {
  // 立即打印 "Value: 0",并订阅 `a`
  logging::log!("Value: {}", a.get());
});

效果函数会接收一个参数,参数包含效果上次运行时返回的值。在初次运行时,这个值为 None

默认情况下,效果不会在服务器端运行。这意味着你可以在效果函数中调用浏览器特定的 API 而不会引发问题。如果需要效果在服务器端运行,可以使用 Effect::new_isomorphic

自动追踪和动态依赖

如果你熟悉像 React 这样的框架,你可能会注意到一个关键的区别。React 和类似框架通常需要你提供一个“依赖数组”,明确指定哪些变量决定效果何时重新运行。

由于 Leptos 源自同步反应式编程的传统,我们不需要这个显式的依赖列表。相反,我们会根据效果中访问的信号自动追踪依赖。

这种方式有两个效果(不是双关):

  1. 自动化:你无需维护一个依赖列表,也不用担心哪些应该或不应该被包括。框架会自动追踪哪些信号可能导致效果重新运行,并处理这些依赖。
  2. 动态化:依赖列表会在每次效果运行时清除并更新。如果效果包含条件语句(例如),只有当前分支中使用的信号会被追踪。这意味着效果只会以绝对最小的次数重新运行。

如果这听起来像是魔法,并且你想深入了解自动依赖追踪的原理,可以观看这个视频。(音量较低,请见谅!)

Effects 作为接近零成本的抽象

从技术上讲,效果并非完全“零成本抽象”——它们需要一些额外的内存,并在运行时存在等。然而,从更高层次的视角来看,对于你在其中进行的任何昂贵的 API 调用或其他操作,效果可以视为零成本抽象。它们只会以必要的最小次数重新运行。

假设我正在创建某种聊天软件,我希望用户可以显示全名或仅显示名字,并在名字更改时通知服务器:

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_lasttrue,效果会在 firstlastuse_last 发生变化时重新运行。但如果我将 use_last 切换为 falselast 的变化将不会触发全名的变化。实际上,last 会从依赖列表中移除,直到 use_last 再次切换为 true。这避免了在 use_lastfalse 时多次更改 last 导致的多余 API 请求。

是否创建效果?

效果的目的是将反应式系统与外部的非反应式世界同步,而不是在不同的反应式值之间同步。换句话说:使用效果从一个信号读取值并将其设置到另一个信号中总是次优的。

如果需要定义一个依赖于其他信号值的信号,可以使用派生信号或 Memo。在效果中写入信号不会引发灾难(例如,电脑不会着火),但派生信号或 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)同步,在效果中写入信号是可以接受的。然而在很多情况下,你实际上是在事件监听器或其他地方写入信号,而不是在效果中。在这些情况下,可以查看 leptos-use 是否已经提供了一个反应式封装原语来完成这一任务!

如果你想进一步了解何时应该或不应该使用 create_effect可以观看这个视频,以获得更深入的理解!

Effects 与渲染

我们已经讨论了这么多,却几乎没有提到效果,因为它们被内置到了 Leptos 的 DOM 渲染器中。我们已经看到,你可以创建一个信号并将其传递到 view! 宏中,信号变化时相关的 DOM 节点会更新:

let (count, set_count) = signal(0);

view! {
    <p>{count}</p>
}

这之所以有效,是因为框架实际上为这个更新创建了一个效果。你可以想象 Leptos 将这个视图翻译成如下代码:

let (count, set_count) = signal(0);

// 创建一个 DOM 元素
let document = leptos::document();
let p = document.create_element("p").unwrap();

// 创建一个效果以反应式更新文本
Effect::new(move |prev_value| {
    // 首先,访问信号的值并将其转换为字符串
    let text = count.get().to_string();

    // 如果与之前的值不同,则更新节点
    if prev_value != Some(text) {
        p.set_text_content(&text);
    }

    // 返回此值,以便对下一次更新进行记忆
    text
});

每次 count 更新时,这个效果都会重新运行。这是实现对 DOM 进行细粒度更新的关键。

使用 Effect::watch() 进行显式追踪

除了 Effect::new(),Leptos 还提供了 Effect::watch() 方法,可以通过显式传递值集来分离追踪和响应变化。

watch 有三个参数。其中deps参数是反应式追踪的,然而callbackimmediate不是。每当 deps 参数中的反应式值发生变化时,callback 就会运行。如果immediate的值是false,callback只有在检测到任何在deps中访问的信号发生第一次变化后才会运行。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); // (没有输出)

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::html::Input;
use leptos::prelude::*;

#[derive(Copy, Clone)]
struct LogContext(RwSignal<Vec<String>>);

#[component]
fn App() -> impl IntoView {
    // Just making a visible log here
    // You can ignore this...
    let log = RwSignal::<Vec<String>>::new(vec![]);
    let logged = move || log.get().join("\n");

    // the newtype pattern isn't *necessary* here but is a good practice
    // it avoids confusion with other possible future `RwSignal<Vec<String>>` contexts
    // and makes it easier to refer to it
    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);

    // this will add the name to the log
    // any time one of the source signals changes
    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)
}