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

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

Appendix: How does the Reactive System Work?

为了成功使用本库,你并不需要了解太多关于响应式系统实际运行机制的知识。但一旦你开始在高级水平上使用该框架,了解幕后发生的事情总是很有用的。

You don’t need to know very much about how the reactive system actually works in order to use the library successfully. But it’s always useful to understand what’s going on behind the scenes once you start working with the framework at an advanced level.

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

The reactive primitives you use are divided into three sets:

  • 信号 (Signals) (ReadSignal/WriteSignalRwSignalResourceTrigger):你可以主动更改以触发响应式更新的值。
  • Signals (ReadSignal/WriteSignal, RwSignal, Resource, Trigger) Values you can actively change to trigger reactive updates.
  • 计算 (Computations) (Memo):依赖于信号(或其他计算),并通过某些纯计算推导出新的响应式值。
  • Computations (Memo) Values that depend on signals (or other computations) and derive a new reactive value through some pure computation.
  • 副作用 (Effects):监听某些信号或计算的变化并运行一个函数,从而引起某些副作用的观察者。
  • Effects Observers that listen to changes in some signals or computations and run a function, causing some side effect.

派生信号(Derived signals)是一种非原语计算:作为普通闭包,它们仅仅允许你将一些重复的基于信号的计算重构为可在多处调用的可重用函数,但它们本身并不在响应式系统中表示。

Derived signals are a kind of non-primitive computation: as plain closures, they simply allow you to refactor some repeated signal-based computation into a reusable function that can be called in multiple places, but they are not represented in the reactive system itself.

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

All the other primitives actually exist in the reactive system as nodes in a reactive graph.

响应式系统的大部分工作在于将变更从信号传播到副作用,期间可能经过一些中间的 Memo。

Most of the work of the reactive system consists of propagating changes from signals to effects, possibly through some intervening memos.

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

The assumption of the reactive system is that effects (like rendering to the DOM or making a network request) are orders of magnitude more expensive than things like updating a Rust data structure inside your app.

因此,响应式系统的首要目标尽可能不频繁地运行副作用

So the primary goal of the reactive system is to run effects as infrequently as possible.

Leptos 通过构建响应式图来实现这一点。

Leptos’s current reactive system is based heavily on the Reactively library for JavaScript. You can read Milo’s article “Super-Charging Fine-Grained Reactivity” for an excellent account of its algorithm, as well as fine-grained reactivity in general—including some beautiful diagrams!

响应式图

The Reactive Graph

信号、Memo 和副作用都共有三个特征:

Signals, memos, and effects all share three characteristics:

  • 值 (Value):它们拥有一个当前值:要么是信号的值,要么是(对于 Memo 和副作用)之前运行返回的值(如果有)。
  • Value They have a current value: either the signal’s value, or (for memos and effects) the value returned by the previous run, if any.
  • 源 (Sources):它们依赖的任何其他响应式原语。(对于信号,这是一个空集。)
  • Sources Any other reactive primitives they depend on. (For signals, this is an empty set.)
  • 订阅者 (Subscribers):依赖于它们的任何其他响应式原语。(对于副作用,这是一个空集。)
  • Subscribers Any other reactive primitives that depend on them. (For effects, this is an empty set.)

实际上,信号、Memo 和副作用只是响应式图中一个通用“节点”概念的惯用名称。信号始终是“根节点”,没有源/父节点。副作用始终是“叶节点”,没有订阅者。Memo 通常既有源又有订阅者。

In reality then, signals, memos, and effects are just conventional names for one generic concept of a “node” in a reactive graph. Signals are always “root nodes,” with no sources/parents. Effects are always “leaf nodes,” with no subscribers. Memos typically have both sources and subscribers.

在接下来的示例中,我将使用 nightly 语法,纯粹是为了减少这份旨在让你阅读而非复制粘贴的文档的冗余!

In the following examples, I’m going to use the nightly syntax, simply for the sake of reducing verbosity in a document that’s intended for you to read, not to copy-and-paste from!

简单依赖

Simple Dependencies

想象以下代码:

So imagine the following code:

// 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 是唯一的信号/起始节点,Effect::new 是唯一的副作用/终端节点,中间有一个 Memo。

You can easily imagine the reactive graph here: name is the only signal/origin node, the Effect::new is the only effect/terminal node, and there’s one intervening memo.

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

分支拆分

Splitting Branches

让我们让它复杂一点。

Let’s make it a little more complex.

// 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());
});

这也很直观:一个信号源信号 (name/A) 分成两条平行轨道:name_upper/Bname_len/C,每条轨道都有一个依赖它的副作用。

This is also pretty straightforward: a signal source signal (name/A) divides into two parallel tracks: name_upper/B and name_len/C, each of which has an effect that depends on it.

 __A__
|     |
B     C
|     |
E     D

现在让我们更新信号。

Now let’s update the signal.

set_name("Bob");

我们立即记录:

We immediately log

len = 3
name = BOB

再做一次。

Let’s do it again.

set_name("Tim");

日志应该显示:

The log should shows

name = TIM

len = 3 不会再次记录。

len = 3 does not log again.

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

Remember: the goal of the reactive system is to run effects as infrequently as possible. Changing name from "Bob" to "Tim" will cause each of the memos to re-run. But they will only notify their subscribers if their value has actually changed. "BOB" and "TIM" are different, so that effect runs again. But both names have the length 3, so they do not run again.

分支合并

Reuniting Branches

最后一个例子,有时被称为钻石问题 (the diamond problem)

One more example, of what’s sometimes called the diamond problem.

// 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());
});

这个图看起来像什么?

What does the graph look like for this?

 __A__
|     |
B     C
|     |
|__D__|

你可以看到为什么它被称为“钻石问题”。如果我用直线连接节点而不是蹩脚的 ASCII 艺术,它会形成一个钻石形状:两个 Memo 都依赖于一个信号,并共同馈入同一个副作用。

You can see why it's called the “diamond problem.” If I’d connected the nodes with straight lines instead of bad ASCII art, it would form a diamond: two memos, each of which depend on a signal, which feed into the same effect.

一个原始的基于推送(push-based)的响应式实现会导致这个副作用运行两次,这很糟糕。(记住,我们的目标是尽可能减少副作用运行次数。)例如,你可以实现这样一个响应式系统:信号和 Memo 立即将其更改一直向下传播到图中,穿过每个依赖项,本质上是深度优先地遍历图。换句话说,更新 A 会通知 BB 会通知 D;然后 A 会通知 CC 会再次通知 D。这既低效(D 运行了两次),又容易产生故障(glitchy)(在 D 第一次运行时,第二个 Memo 的值实际上是不正确的)。

A naive, push-based reactive implementation would cause this effect to run twice, which would be bad. (Remember, our goal is to run effects as infrequently as we can.) For example, you could implement a reactive system such that signals and memos immediately propagate their changes all the way down the graph, through each dependency, essentially traversing the graph depth-first. In other words, updating A would notify B, which would notify D; then A would notify C, which would notify D again. This is both inefficient (D runs twice) and glitchy (D actually runs with the incorrect value for the second memo during its first run.)

解决钻石问题

Solving the Diamond Problem

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

Any reactive implementation worth its salt is dedicated to solving this issue. There are a number of different approaches (again, see Milo’s article for an excellent overview).

简而言之,我们的方法是这样的。

Here’s how ours works, in brief.

一个响应式节点始终处于以下三种状态之一:

A reactive node is always in one of three states:

  • Clean(干净):已知没有发生变化
  • Check(待查):可能发生了变化
  • Dirty(脏):肯定发生了变化

更新一个信号会将该信号标记为 Dirty,并递归地将其所有后代标记为 Check。其作为副作用的任何后代都会被添加到一个待重新运行的队列中。

Updating a signal Dirty marks that signal Dirty, and marks all its descendants Check, recursively. Any of its descendants that are effects are added to a queue to be re-run.

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

现在运行这些副作用。(此时所有的副作用都将被标记为 Check。)在重新运行其计算之前,副作用会检查其父节点以查看它们是否为 Dirty

Now those effects are run. (All of the effects will be marked Check at this point.) Before re-running its computation, the effect checks its parents to see if they are dirty.

  • 因此 D 找到 B 并检查它是否为 Dirty

  • B 也被标记为 Check。所以 B 做同样的事情:

    • B 找到 A,发现它是 Dirty
    • 这意味着 B 需要重新运行,因为它的一个源发生了变化。
    • B 重新运行,生成一个新值,并将自己标记为 Clean
    • 因为 B 是一个 Memo,它随后会将之前的值与新值进行对比。
    • 如果相同,B 返回“无变化”。否则,它返回“是的,我变了”。
  • 如果 B 返回“是的,我变了”,D 就知道它肯定需要运行,并在检查任何其他源之前立即重新运行。

  • 如果 B 返回“不,我没变”,D 继续检查 C(见上述 B 的过程)。

  • 如果 BC 都没有变化,则副作用不需要重新运行。

  • 如果 BC 中任一发生了变化,副作用现在重新运行。

  • So D goes to B and checks if it is Dirty.

  • But B is also marked Check. So B does the same thing:

    • B goes to A, and finds that it is Dirty.
    • This means B needs to re-run, because one of its sources has changed.
    • B re-runs, generating a new value, and marks itself Clean
    • Because B is a memo, it then checks its prior value against the new value.
    • If they are the same, B returns "no change." Otherwise, it returns "yes, I changed."
  • If B returned “yes, I changed,” D knows that it definitely needs to run and re-runs immediately before checking any other sources.

  • If B returned “no, I didn’t change,” D continues on to check C (see process above for B.)

  • If neither B nor C has changed, the effect does not need to re-run.

  • If either B or C did change, the effect now re-runs.

因为副作用只被标记为 Check 一次且只入队一次,所以它只运行一次。

Because the effect is only marked Check once and only queued once, it only runs once.

如果原始版本是“基于推送”的响应式系统(只是将响应式变更一直推送到图的底端,从而导致副作用运行两次),那么这个版本可以被称为“推拉结合(push-pull)”。它将 Check 状态一直向下推送,然后“拉取”回溯。事实上,对于大型图,当它试图确定具体哪些节点需要重新运行时,最终可能会在图中上下左右弹跳。

If the naive version was a “push-based” reactive system, simply pushing reactive changes all the way down the graph and therefore running the effect twice, this version could be called “push-pull.” It pushes the Check status all the way down the graph, but then “pulls” its way back up. In fact, for large graphs it may end up bouncing back up and down and left and right on the graph as it tries to determine exactly which nodes need to re-run.

请注意这一重要的权衡:基于推送的响应式系统能更快地传播信号变更,代价是过度重新运行 Memo 和副作用。请记住:响应式系统旨在最大程度地减少重新运行副作用的次数,这是基于一个(准确的)假设,即副作用比这种完全在库的 Rust 代码内部发生的缓存友好型图遍历要昂贵几个数量级。一个好的响应式系统的衡量标准不是它传播变更的速度有多快,而是它在_不过度通知_的情况下传播变更的速度有多快。

Note this important trade-off: Push-based reactivity propagates signal changes more quickly, at the expense of over-re-running memos and effects. Remember: the reactive system is designed to minimize how often you re-run effects, on the (accurate) assumption that side effects are orders of magnitude more expensive than this kind of cache-friendly graph traversal happening entirely inside the library’s Rust code. The measurement of a good reactive system is not how quickly it propagates changes, but how quickly it propagates changes without over-notifying.

Memo vs. 信号

Memos vs. Signals

请注意,信号总是会通知其子项;即,信号在更新时始终被标记为 Dirty,即使它的新值与旧值相同。否则,我们将不得不要求信号实现 PartialEq,而这对某些类型来说实际上是非常昂贵的检查。(例如,当显然已经发生变化时,给诸如 some_vec_signal.update(|n| n.pop()) 之类的操作添加不必要的相等性检查。)

Note that signals always notify their children; i.e., a signal is always marked Dirty when it updates, even if its new value is the same as the old value. Otherwise, we’d have to require PartialEq on signals, and this is actually quite an expensive check on some types. (For example, add an unnecessary equality check to something like some_vec_signal.update(|n| n.pop()) when it’s clear that it has in fact changed.)

另一方面,Memo 在通知子项之前会检查自己是否发生了变化。无论你 .get() 多少次结果,它们都只运行一次计算,但只要其信号源发生变化,它们就会运行。这意味着如果 Memo 的计算_非常_昂贵,你实际上可能也希望对其输入进行 Memo 化,这样 Memo 就只有在确定其输入已更改时才会重新计算。

Memos, on the other hand, check whether they change before notifying their children. They only run their calculation once, no matter how many times you .get() the result, but they run whenever their signal sources change. This means that if the memo’s computation is very expensive, you may actually want to memoize its inputs as well, so that the memo only re-calculates when it is sure its inputs have changed.

Memo vs. 派生信号

Memos vs. Derived Signals

这一切都很酷,Memo 也非常棒。但大多数实际应用的响应式图都非常浅且非常宽:你可能有 100 个源信号和 500 个副作用,但没有 Memo,或者在极少数情况下,信号和副作用之间有三四个 Memo。Memo 在其所擅长的事情上非常出色:限制通知其订阅者发生变更的频率。但正如对响应式系统的这段描述所示,它们带来了两种形式的开销:

All of this is cool, and memos are pretty great. But most actual applications have reactive graphs that are quite shallow and quite wide: you might have 100 source signals and 500 effects, but no memos or, in rare case, three or four memos between the signal and the effect. Memos are extremely good at what they do: limiting how often they notify their subscribers that they have changed. But as this description of the reactive system should show, they come with overhead in two forms:

  1. PartialEq 检查,这可能昂贵也可能不昂贵。

  2. 在响应式系统中存储另一个节点的额外内存成本。

  3. 响应式图遍历的额外计算成本。

  4. A PartialEq check, which may or may not be expensive.

  5. Added memory cost of storing another node in the reactive system.

  6. Added computational cost of reactive graph traversal.

如果计算本身比这些响应式工作更便宜,你应该避免使用 Memo 进行“过度包装”,而只需使用派生信号。这里有一个永远不应该使用 Memo 的典型例子:

In cases in which the computation itself is cheaper than this reactive work, you should avoid “over-wrapping” with memos and simply use derived signals. Here’s a great example in which you should never use a memo:

let (a, set_a) = signal(1);
// 将这些用作 Memo 都没有意义
// none of these make sense as memos
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);

尽管 Memo 化在技术上可以节省在将 a3 设置为 5 之间的一次 d 的计算,但这些计算本身比响应式算法更便宜。

Even though memoizing would technically save an extra calculation of d between setting a to 3 and 5, these calculations are themselves cheaper than the reactive algorithm.

最多,你可以考虑在运行某些昂贵的副作用之前对最终节点进行 Memo 化:

At the very most, you might consider memoizing the final node before running some expensive side effect:

let text = Memo::new(move |_| {
    d()
});
Effect::new(move |_| {
    // 将文本刻入金条
    engrave_text_into_bar_of_gold(&text());
});