Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

附录:响应式系统是如何工作的?

为了成功使用库,您不需要了解响应式系统实际如何工作的太多细节。但是,一旦您开始在高级水平上使用框架,了解幕后发生的事情总是有用的。

您使用的响应式原语分为三组:

  • SignalReadSignal/WriteSignalRwSignalResourceTrigger)您可以主动更改以触发响应式更新的值。
  • 计算Memo)依赖于signal(或其他计算)并通过一些纯计算派生新响应式值的值。
  • Effect 监听某些signal或计算中的变化并运行函数,产生一些副作用的观察者。

派生signal是一种非原语计算:作为普通闭包,它们只是允许您将一些重复的基于signal的计算重构为可重用的函数,可以在多个地方调用,但它们在响应式系统本身中不被表示。

所有其他原语实际上作为响应式图中的节点存在于响应式系统中。

响应式系统的大部分工作包括将变化从signal传播到effect,可能通过一些中间的memo。

响应式系统的假设是effect(如渲染到DOM或发出网络请求)比应用程序内部更新Rust数据结构等事情昂贵几个数量级。

因此,响应式系统的主要目标尽可能少地运行effect

Leptos通过构建响应式图来做到这一点。

Leptos当前的响应式系统很大程度上基于JavaScript的Reactively库。您可以阅读Milo的文章"Super-Charging Fine-Grained Reactivity",了解其算法的出色描述,以及细粒度响应式的一般情况——包括一些漂亮的图表!

响应式图

Signal、memo和effect都共享三个特征:

  • 它们有一个当前值:要么是signal的值,要么是(对于memo和effect)前一次运行返回的值(如果有的话)。
  • 它们依赖的任何其他响应式原语。(对于signal,这是一个空集。)
  • 订阅者 依赖于它们的任何其他响应式原语。(对于effect,这是一个空集。)

实际上,signal、memo和effect只是响应式图中"节点"这一通用概念的常规名称。Signal总是"根节点",没有源/父节点。Effect总是"叶节点",没有订阅者。Memo通常既有源又有订阅者。

在以下示例中,我将使用nightly语法,只是为了减少这个供您阅读而不是复制粘贴的文档中的冗长!

简单依赖

想象以下代码:

// A
let (name, set_name) = signal("Alice");

// B
let name_upper = Memo::new(move |_| name.with(|n| n.to_uppercase()));

// C
Effect::new(move |_| {
	log!("{}", name_upper());
});

set_name("Bob");

您可以轻松想象这里的响应式图:name是唯一的signal/起源节点,Effect::new是唯一的effect/终端节点,中间有一个memo。

A   (name)
|
B   (name_upper)
|
C   (the effect)

分支拆分

让我们让它稍微复杂一些。

// A
let (name, set_name) = signal("Alice");

// B
let name_upper = Memo::new(move |_| name.with(|n| n.to_uppercase()));

// C
let name_len = Memo::new(move |_| name.len());

// D
Effect::new(move |_| {
	log!("len = {}", name_len());
});

// E
Effect::new(move |_| {
	log!("name = {}", name_upper());
});

这也很直接:一个signal源signal(name/A)分为两个并行轨道:name_upper/Bname_len/C,每个都有一个依赖于它的effect。

 __A__
|     |
B     C
|     |
E     D

现在让我们更新signal。

set_name("Bob");

我们立即记录

len = 3
name = BOB

让我们再做一次。

set_name("Tim");

日志应该显示

name = TIM

len = 3不会再次记录。

记住:响应式系统的目标是尽可能少地运行effect。将name"Bob"更改为"Tim"将导致每个memo重新运行。但它们只有在值实际发生变化时才会通知其订阅者。"BOB""TIM"是不同的,所以该effect再次运行。但两个名称的长度都是3,所以它们不会再次运行。

重新合并分支

再举一个例子,有时称为钻石问题

// A
let (name, set_name) = signal("Alice");

// B
let name_upper = Memo::new(move |_| name.with(|n| n.to_uppercase()));

// C
let name_len = Memo::new(move |_| name.len());

// D
Effect::new(move |_| {
	log!("{} is {} characters long", name_upper(), name_len());
});

这个图看起来是什么样的?

 __A__
|     |
B     C
|     |
|__D__|

您可以看到为什么它被称为"钻石问题"。如果我用直线而不是糟糕的ASCII艺术连接节点,它会形成一个钻石:两个memo,每个都依赖于一个signal,它们都输入到同一个effect中。

一个天真的、基于推送的响应式实现会导致这个effect运行两次,这会很糟糕。(记住,我们的目标是尽可能少地运行effect。)例如,您可以实现一个响应式系统,使得signal和memo立即将其变化一直传播到图的下方,通过每个依赖项,本质上是深度优先遍历图。换句话说,更新A会通知B,然后B会通知D;然后A会通知C,然后C会再次通知D。这既低效(D运行两次)又有故障(D实际上在第一次运行期间使用第二个memo的不正确值运行)。

解决钻石问题

任何值得称道的响应式实现都致力于解决这个问题。有许多不同的方法(再次,参见Milo的文章以获得出色的概述)。

以下是我们的工作方式,简而言之。

响应式节点总是处于三种状态之一:

  • Clean:已知没有改变
  • Check:可能已经改变
  • Dirty:肯定已经改变

更新signal Dirty将该signal标记为Dirty,并递归地将其所有后代标记为Check。其任何作为effect的后代都会被添加到队列中以重新运行。

    ____A (DIRTY)___
   |               |
B (CHECK)    C (CHECK)
   |               |
   |____D (CHECK)__|

现在运行这些effect。(此时所有effect都将被标记为Check。)在重新运行其计算之前,effect检查其父节点是否脏。

  • 所以DB并检查它是否是Dirty
  • B也被标记为Check。所以B做同样的事情:
    • BA,发现它是Dirty
    • 这意味着B需要重新运行,因为它的一个源已经改变。
    • B重新运行,生成一个新值,并将自己标记为Clean
    • 因为B是一个memo,它然后检查其先前值与新值。
    • 如果它们相同,B返回"没有变化"。否则,它返回"是的,我改变了"。
  • 如果B返回"是的,我改变了",D知道它肯定需要运行,并在检查任何其他源之前立即重新运行。
  • 如果B返回"不,我没有改变",D继续检查C(参见上面B的过程)。
  • 如果BC都没有改变,effect不需要重新运行。
  • 如果BC中的任何一个确实改变了,effect现在重新运行。

因为effect只被标记为Check一次,只排队一次,所以它只运行一次。

如果天真版本是"基于推送"的响应式系统,简单地将响应式变化一直推送到图的下方,因此运行effect两次,这个版本可以称为"推拉"。它将Check状态一直推送到图的下方,但然后"拉"回上方。实际上,对于大图,它可能最终在图上来回弹跳,左右弹跳,试图确定哪些节点需要重新运行。

注意这个重要的权衡:基于推送的响应式更快地传播signal变化,但代价是过度重新运行memo和effect。记住:响应式系统旨在最小化您重新运行effect的频率,基于(准确的)假设,即副作用比完全在库的Rust代码内部发生的这种缓存友好的图遍历昂贵几个数量级。一个好的响应式系统的衡量标准不是它传播变化的速度,而是它传播变化的速度_而不过度通知_。

Memo vs. Signal

注意signal总是通知其子节点;即,signal在更新时总是被标记为Dirty,即使其新值与旧值相同。否则,我们必须在signal上要求PartialEq,这在某些类型上实际上是相当昂贵的检查。(例如,当很明显它确实已经改变时,向some_vec_signal.update(|n| n.pop())这样的东西添加不必要的相等检查。)

另一方面,Memo在通知其子节点之前检查它们是否改变。无论您.get()结果多少次,它们只运行一次计算,但每当其signal源改变时它们就会运行。这意味着如果memo的计算_非常_昂贵,您可能实际上也想要记忆化其输入,以便memo只有在确定其输入已改变时才重新计算。

Memo vs. 派生Signal

所有这些都很酷,memo非常棒。但大多数实际应用程序的响应式图相当浅且相当宽:您可能有100个源signal和500个effect,但没有memo,或者在罕见情况下,signal和effect之间有三或四个memo。Memo在它们所做的事情上极其出色:限制它们通知订阅者它们已经改变的频率。但正如对响应式系统的这种描述应该显示的那样,它们带来两种形式的开销:

  1. PartialEq检查,可能昂贵也可能不昂贵。
  2. 在响应式系统中存储另一个节点的额外内存成本。
  3. 响应式图遍历的额外计算成本。

在计算本身比这种响应式工作更便宜的情况下,您应该避免用memo"过度包装",而只是使用派生signal。这是一个您永远不应该使用memo的很好例子:

let (a, set_a) = signal(1);
// 这些都不适合作为memo
let b = move || a() + 2;
let c = move || b() % 2 == 0;
let d = move || if c() { "even" } else { "odd" };

set_a(2);
set_a(3);
set_a(5);

即使记忆化在技术上会在将a设置为35之间节省d的额外计算,这些计算本身比响应式算法更便宜。

最多,您可能考虑在运行一些昂贵的副作用之前记忆化最终节点:

let text = Memo::new(move |_| {
    d()
});
Effect::new(move |_| {
    engrave_text_into_bar_of_gold(&text());
});