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: The Life Cycle of a Signal

在使用 Leptos 时,中级开发者通常会产生三个疑问:

  1. 我该如何连接到组件生命周期,在组件挂载(mount)或卸载(unmount)时运行某些代码?
  2. 我该如何知道信号何时被销毁(disposed),以及为什么在尝试访问已销毁的信号时偶尔会发生 panic?
  3. 为什么信号是 Copy 的,并且可以在不显式克隆的情况下移动到闭包和其他结构中?

Three questions commonly arise at the intermediate level when using Leptos:

  1. How can I connect to the component lifecycle, running some code when a component mounts or unmounts?
  2. How do I know when signals are disposed, and why do I get an occasional panic when trying to access a disposed signal?
  3. How is it possible that signals are Copy and can be moved into closures and other structures without being explicitly cloned?

这三个问题的答案紧密相关,且都有些复杂。本附录将尝试为你提供理解这些答案的背景,以便你可以正确地推理应用程序的代码及其运行方式。

The answers to these three questions are closely inter-related, and are each somewhat complicated. This appendix will try to give you the context for understanding the answers, so that you can reason correctly about your application's code and how it runs.

组件树与决策树

The Component Tree vs. The Decision Tree

考虑以下简单的 Leptos 应用:

Consider the following simple Leptos app:

use leptos::logging::log;
use leptos::prelude::*;

#[component]
pub fn App() -> impl IntoView {
    let (count, set_count) = signal(0);

    view! {
        <button on:click=move |_| *set_count.write() += 1>"+1"</button>
        {move || if count.get() % 2 == 0 {
            view! { <p>"Even numbers are fine."</p> }.into_any()
        } else {
            view! { <InnerComponent count/> }.into_any()
        }}
    }
}

#[component]
pub fn InnerComponent(count: ReadSignal<usize>) -> impl IntoView {
    Effect::new(move |_| {
        // count 是奇数,其值为 {}
        log!("count is odd and is {}", count.get());
    });

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

#[component]
pub fn OddDuck() -> impl IntoView {
    view! {
        <p>"You're an odd duck."</p>
    }
}

它所做的只是显示一个计数器按钮,如果是偶数则显示一条消息,如果是奇数则显示另一条消息。如果是奇数,它还会在控制台中记录这些值。

All it does is show a counter button, and then one message if it's even, and a different message if it's odd. If it's odd, it also logs the values in the console.

映射这个简单应用的一种方法是画一个嵌套组件树: One way to map out this simple application would be to draw a tree of nested components:

App 
|_ InnerComponent
   |_ OddDuck

另一种方法是画出决策点树: Another way would be to draw the tree of decision points:

root (根)
|_ count 是偶数吗?
   |_ 是
   |_ 否

如果你将两者结合起来,你会注意到它们并没有完美地映射在一起。决策树将我们在 InnerComponent 中创建的视图切成了三块,并将 InnerComponent 的一部分与 OddDuck 组件结合在了一起:

If you combine the two together, you'll notice that they don't map onto one another perfectly. The decision tree slices the view we created in InnerComponent into three pieces, and combines part of InnerComponent with the OddDuck component:

DECISION (决策)      COMPONENT (组件)    DATA (数据)    SIDE EFFECTS (副作用)
root                <App/>              (count)       渲染 <button>
|_ count 是偶数吗?  <InnerComponent/>
   |_ 是                                               渲染偶数 <p>
   |_ 否                                               开始记录 count
                    <OddDuck/>                        渲染奇数 <p>
                                                      渲染奇数 <p> (在 <InnerComponent/> 中!)

看着这张表,我注意到了以下几点:

  1. 组件树和决策树并不匹配:“count 是偶数吗?”这个决策将 <InnerComponent/> 分成了三个部分(一个永远不变的部分,一个偶数部分,一个奇数部分),并将其中之一与 <OddDuck/> 组件合并。
  2. 决策树和副作用列表完美对应:每个副作用都在特定的决策点创建。
  3. 决策树和数据树也对应上了。虽然表中只有一个信号很难看出来,但与组件(一个可以包含多个决策或不包含决策的函数)不同,信号总是创建在决策树的特定行中。

Looking at this table, I notice the following things:

  1. The component tree and the decision tree don't match one another: the "is count even?" decision splits <InnerComponent/> into three parts (one that never changes, one if even, one if odd), and merges one of these with the <OddDuck/> component.
  2. The decision tree and the list of side effects correspond perfectly: each side effect is created at a specific decision point.
  3. The decision tree and the tree of data also line up. It's hard to see with only one signal in the table, but unlike a component, which is a function that can include multiple decisions or none, a signal is always created at a specific line in the tree of decisions.

关键点在于:你的数据结构和副作用结构影响了应用程序的实际功能。你的组件结构只是为了编写方便。你不在乎,也不应该在乎哪个组件渲染了哪个 <p> 标签,或者哪个组件创建了记录值的副作用。唯一重要的是它们在正确的时间发生。

Here's the thing: The structure of your data and the structure of side effects affect the actual functionality of your application. The structure of your components is just a convenience of authoring. You don't care, and you shouldn't care, which component rendered which <p> tag, or which component created the effect to log the values. All that matters is that they happen at the right times.

在 Leptos 中,组件并不存在。也就是说:你可以将应用程序写成组件树,因为这样很方便,而且我们围绕组件提供了一些调试工具和日志记录,因为那也很方便。但你的组件在运行时并不存在:组件不是变更检测或渲染的单位。它们仅仅是函数调用。你可以将整个应用程序写在一个大组件中,或者将其拆分成一百个组件,这都不会影响运行时行为,因为组件并不真正存在。

In Leptos, components do not exist. That is to say: You can write your application as a tree of components, because that's convenient, and we provide some debugging tools and logging built around components, because that's convenient too. But your components do not exist at runtime: Components are not a unit of change detection or of rendering. They are simply function calls. You can write your whole application in one big component, or split it into a hundred components, and it does not affect the runtime behavior, because components don't really exist.

另一方面,决策树确实存在。而且它非常重要!

The decision tree, on the other hand, does exist. And it's really important!

决策树、渲染与所有权

The Decision Tree, Rendering, and Ownership

每个决策点都是某种响应式语句:一个可以随时间变化的信号或函数。当你将信号或函数传递给渲染器时,它会自动将其包装在一个副作用(effect)中,该副作用订阅它包含的任何信号,并随时间相应地更新视图。

Every decision point is some kind of reactive statement: a signal or a function that can change over time. When you pass a signal or a function into the renderer, it automatically wraps it in an effect that subscribes to any signals it contains, and updates the view accordingly over time.

这意味着当你的应用程序渲染时,它会创建一个完美镜像决策树的嵌套副作用树。用伪代码表示:

This means that when your application is rendered, it creates a tree of nested effects that perfectly mirrors the decision tree. In pseudo-code:

// root (根)
let button = /* 渲染一次 <button> */;

// 渲染器围绕 `move || if count() ...` 包装了一个副作用
// the renderer wraps an effect around the `move || if count() ...`
Effect::new(|_| {
    if count.get() % 2 == 0 {
        let p = /* 渲染偶数 <p> */;
    } else {
        // 用户创建了一个记录 count 的副作用
        // the user created an effect to log the count
        Effect::new(|_| {
            log!("count is odd and is {}", count.get());
        });

        let p1 = /* 渲染来自 OddDuck 的 <p> */;
        let p2 = /* 渲染第二个 <p> */ 

        // 渲染器创建了一个副作用来更新第二个 <p>
        // the renderer creates an effect to update the second <p>
        Effect::new(|_| {
            // 用信号更新 <p> 的文本内容
            // update the content of the <p> with the signal
            p2.set_text_content(count.get());
        });
    }
})

每个响应式值都被包装在它自己的副作用中,以更新 DOM,或运行信号变更产生的任何其他副作用。但你不需要这些副作用永远运行下去。例如,当 count 从奇数切回偶数时,第二个 <p> 不再存在,因此持续更新它的副作用也不再有用。副作用不会永远运行,而是在创建它们的决策发生变化时被取消。换句话说,更准确地说:每当创建副作用时正在运行的父副作用重新运行时,该副作用就会被取消。如果它们是在条件分支中创建的,并且重新运行父副作用时经过了相同的分支,则会再次创建该副作用;如果没有经过,则不会。

Each reactive value is wrapped in its own effect to update the DOM, or run any other side effects of changes to signals. But you don't need these effects to keep running forever. For example, when count switches from an odd number back to an even number, the second <p> no longer exists, so the effect to keep updating it is no longer useful. Instead of running forever, effects are canceled when the decision that created them changes. In other words, and more precisely: effects are canceled whenever the effect that was running when they were created re-runs. If they were created in a conditional branch, and re-running the effect goes through the same branch, the effect will be created again: if not, it will not.

从响应式系统本身的角度来看,你应用的“决策树”实际上是一个响应式“所有权树”。简单地说,响应式“所有者”(owner)是当前正在运行的副作用(effect)或备忘(memo)。它拥有在其内部创建的副作用,这些副作用又拥有它们自己的子副作用,依此类推。当一个副作用准备重新运行时,它首先“清理”其子项,然后再次运行。

From the perspective of the reactive system itself, your application's "decision tree" is really a reactive "ownership tree." Simply put, a reactive "owner" is the effect or memo that is currently running. It owns effects created within it, they own their own children, and so on. When an effect is going to re-run, it first "cleans up" its children, then runs again.

到目前为止,这种模型与 S.js 或 Solid 等 JavaScript 框架中存在的响应式系统是共有的,其中所有权的概念存在是为了自动取消副作用。

So far, this model is shared with the reactive system as it exists in JavaScript frameworks like S.js or Solid, in which the concept of ownership exists to automatically cancel effects.

Leptos 增加的是我们为所有权赋予了第二个类似的含义:响应式所有者不仅拥有其子副作用,以便它可以取消它们;它还拥有其信号(备忘等),以便它可以销毁它们。

What Leptos adds is that we add a second, similar meaning to ownership: a reactive owner not only owns its child effects, so that it can cancel them; it also owns its signals (memos, etc.) so that it can dispose of them.

所有权与 Copy 竞技场 (Arena)

Ownership and the Copy Arena

这就是使 Leptos 能够作为 Rust UI 框架使用的创新点。传统上,在 Rust 中管理 UI 状态非常困难,因为 UI 核心就是共享可变性。(一个简单的计数器按钮就足以说明问题:你既需要不可变访问来设置显示计数器值的文本节点,又需要在点击处理程序中进行可变访问,而每个 Rust UI 框架的设计都是为了应对 Rust 旨在防止这种情况发生这一事实!)在 Rust 中使用像事件处理程序这样的东西,传统上依赖于通过具有内部可变性的共享内存进行通信的原语(Rc<RefCell<_>>Arc<Mutex<_>>),或者通过通道通信进行共享内存,这两者通常都需要显式的 .clone() 才能移动到事件监听器中。这虽然可行,但也带来了巨大的不便。

This is the innovation that allows Leptos to be usable as a Rust UI framework. Traditionally, managing UI state in Rust has been hard, because UI is all about shared mutability. (A simple counter button is enough to see the problem: You need both immutable access to set the text node showing the counter's value, and mutable access in the click handler, and every Rust UI framework is designed around the fact that Rust is designed to prevent exactly that!) Using something like an event handler in Rust traditionally relies on primitives for communicating via shared memory with interior mutability (Rc<RefCell<_>>, Arc<Mutex<_>>) or for shared memory by communicating via channels, either of which often requires explicit .clone()ing to be moved into an event listener. This is kind of fine, but also an enormous inconvenience.

Leptos 一直对信号使用一种形式的竞技场分配(arena allocation)。信号本身本质上是存储在别处的数据结构的索引。它是一个廉价拷贝的整数类型,其自身不进行引用计数,因此可以在不显式克隆的情况下到处拷贝、移动到事件监听器等。

Leptos has always used a form of arena allocation for signals instead. A signal itself is essentially an index into a data structure that's held elsewhere. It's a cheap-to-copy integer type that does not do reference counting on its own, so it can be copied around, moved into event listeners, etc. without explicit cloning.

这些信号的生命周期并非由 Rust 的生命周期(lifetimes)或引用计数决定,而是由所有权树决定的。

Instead of Rust lifetimes or reference counting, the life cycles of these signals are determined by the ownership tree.

正如所有副作用都属于一个父级所有者副作用,并且子项在所有者重新运行时被取消一样,所有信号也属于一个所有者,并在父级重新运行时被销毁。

Just as all effects belong to an owning parent effect, and the children are canceled when the owner reruns, so too all signals belong to an owner, and are disposed of when the parent reruns.

在大多数情况下,这完全没问题。想象在上面的例子中,<OddDuck/> 创建了用于更新其部分 UI 的其他信号。在大多数情况下,该信号将用于该组件中的局部状态,或者作为 prop 传递给另一个组件。将它从决策树中提升出来并在应用程序的其他地方使用是不寻常的。当 count 切回偶数时,它不再需要,可以被销毁。

In most cases, this is completely fine. Imagine that in our example above, <OddDuck/> created some other signal that it used to update part of its UI. In most cases, that signal will be used for local state in that component, or maybe passed down as a prop to another component. It's unusual for it to be hoisted up out of the decision tree and used somewhere else in the application. When the count switches back to an even number, it is no longer needed and can be disposed.

然而,这意味着可能会出现两个问题。

However, this means there are two possible issues that can arise.

信号在销毁后仍可能被使用

Signals can be used after they are disposed

你持有的 ReadSignalWriteSignal 只是一个整数:比如,如果它是应用程序中的第 3 个信号,它就是 3。(实际上要复杂一些,但复杂得不多。)你可以到处拷贝那个数字,并用它来说:“嘿,给我 3 号信号。”当所有者清理时,3 号信号的将失效;但你拷贝到各处的数字 3 无法失效。(除非有完整的垃圾回收器!)这意味着如果你将信号往决策树“上方”推送,并将其存储在应用程序中概念上比它被创建时更“高”的地方,那么在销毁后仍可以访问它们。

The ReadSignal or WriteSignal that you hold is just an integer: say, 3 if it's the 3rd signal in the application. (As always, the reality is a bit more complicated, but not much.) You can copy that number all over the place and use it to say, "Hey, get me signal 3." When the owner cleans up, the value of signal 3 will be invalidated; but the number 3 that you've copied all over the place can't be invalidated. (Not without a whole garbage collector!) That means that if you push signals back "up" the decision tree, and store them somewhere conceptually "higher" in your application than they were created, they can be accessed after being disposed.

如果你尝试在信号销毁后更新它,并不会发生什么坏事。框架只会警告你尝试更新一个已不存在的信号。但如果你尝试访问一个已销毁的信号,除了发生 panic 外没有其他合理的回答:因为没有值可以返回。(.get().with() 方法有对应的 try_ 版本,如果信号已被销毁,它们将直接返回 None)。

If you try to update a signal after it was disposed, nothing bad really happens. The framework will just warn you that you tried to update a signal that no longer exists. But if you try to access one, there's no coherent answer other than panicking: there is no value that could be returned. (There are try_ equivalents to the .get() and .with() methods that will simply return None if a signal has been disposed).

如果你在更高的作用域创建信号且从未销毁,可能会发生泄漏

Signals can be leaked if you create them in a higher scope and never dispose of them

反之亦然,当处理信号集合时(例如 RwSignal<Vec<RwSignal<_>>>)这种情况尤为突出。如果你在较高级别创建一个信号,并将其传递给较低级别的组件,那么在较高级别的所有者清理之前,它不会被销毁。

The opposite is also true, and comes up particularly when working with collections of signals, like an RwSignal<Vec<RwSignal<_>>>. If you create a signal at a higher level, and pass it down to a component at a lower level, it is not disposed until the higher-up owner is cleaned up.

例如,如果你有一个待办事项应用,为每个待办事项创建一个新的 RwSignal<Todo>,将其存储在 RwSignal<Vec<RwSignal<Todo>>> 中,然后将其传递给 <Todo/>,当你从列表中删除该待办事项时,该信号并不会自动销毁,而必须手动销毁,否则只要其所有者还存活,它就会发生“泄漏”。(更多讨论请参见 TodoMVC 示例。)

For example, if you have a todo app that creates a new RwSignal<Todo> for each todo, stores it in an RwSignal<Vec<RwSignal<Todo>>>, and then passes it down to a <Todo/>, that signal is not automatically disposed when you remove the todo from the list, but must be manually disposed, or it will "leak" for as long as its owner is still alive. (See the TodoMVC example for more discussion.)

这只有在你创建信号、将其存储在集合中、并从集合中移除它们时没有同时手动销毁它们的情况下才会成为问题。

This is only an issue when you create signals, store them in a collection, and remove them from the collection without manually disposing of them as well.

使用引用计数信号解决这些问题

Solving these Problems with Reference-Counted Signals

0.7 版本为我们的每个竞技场分配原语引入了引用计数等效项:对于每个 RwSignal,都有一个 ArcRwSignal(以及 ArcReadSignalArcWriteSignalArcMemo 等)。

0.7 introduces a reference-counted equivalent for each of our arena-allocated primitive: for every RwSignal there is an ArcRwSignal (ArcReadSignal, ArcWriteSignal, ArcMemo, and so on).

这些原语的内存和销毁由引用计数管理,而不是由所有权树管理。

These have their memory and disposal managed by reference counting, rather than the ownership tree.

这意味着它们可以安全地用于竞技场分配等效项可能会泄漏或在销毁后被使用的情况。

This means that they can safely be used in situations in which the arena-allocated equivalents would either be leaked or used after being disposed.

这在创建信号集合时特别有用:例如,你可能会创建 ArcRwSignal<_> 而不是 RwSignal<_>,然后在表格的每一行中将其转换为 RwSignal<_>

This is especially useful when creating collections of signals: you might create ArcRwSignal<_> instead of RwSignal<_>, and then convert it into an RwSignal<_> in each row of a table, for example.

查看 counters 示例ArcRwSignal<i32> 的用法,以获得更具体的例子。

See the use of ArcRwSignal<i32> in the counters example for a more concrete example.

串联知识点

Connecting the Dots

我们开始时提出的问题的答案现在应该能解释通了。

The answers to the questions we started with should probably make some sense now.

组件生命周期

Component Life-Cycle

组件生命周期并不存在,因为组件并不真正存在。但存在所有权生命周期,你可以利用它来完成相同的事情:

  • 挂载前 (before mount):在组件主体中直接运行代码即在“组件挂载前”运行。
  • 挂载时 (on mount)create_effect 在组件其余部分运行后的一个刻度(tick)运行,因此对于需要等待视图挂载到 DOM 的副作用非常有用。
  • 卸载时 (on unmount):你可以使用 on_cleanup 向响应式系统提供在当前所有者清理时(即再次运行前)应运行的代码。因为所有者围绕着一个“决策”,这意味着当你的组件卸载时,on_cleanup 将运行:如果某个东西可以卸载,渲染器一定创建了一个正在卸载它的副作用!

There is no component life-cycle, because components don't really exist. But there is an ownership lifecycle, and you can use it to accomplish the same things:

  • before mount: simply running code in the body of a component will run it "before the component mounts"
  • on mount: create_effect runs a tick after the rest of the component, so it can be useful for effects that need to wait for the view to be mounted to the DOM.
  • on unmount: You can use on_cleanup to give the reactive system code that should run while the current owner is cleaning up, before running again. Because an owner is around a "decision," this means that on_cleanup will run when your component unmounts: if something can unmount, the renderer must have created an effect that's unmounting it!

已销毁信号的问题

Issues with Disposed Signals

一般来说,只有当你正在所有权树的较低处创建信号并将其存储在较高处时,才会出现问题。如果你在这里遇到问题,你应该将信号的创建“提升”到父组件中,然后将创建的信号向下传递——并确保在需要时在移除它们后进行销毁!

Generally speaking, problems can only arise here if you are creating a signal lower down in the ownership tree and storing it somewhere higher up. If you run into issues here, you should instead "hoist" the signal creation up into the parent, and then pass the created signals down—making sure to dispose of them on removal, if needed!

Copy 信号

Copy signals

整个可拷贝(Copy)包装类型系统(信号、StoredValue 等)使用所有权树来近似模拟 UI 不同部分的生命周期。实际上,它用一个基于 UI 区域的生命周期系统来平行于 Rust 语言基于代码块的生命周期系统。这虽然无法总是在编译时被完美检查,但总的来说,我们认为它是利大于弊的。

The whole system of Copyable wrapper types (signals, StoredValue, and so on) uses the ownership tree as a close approximation of the life-cycle of different parts of your UI. In effect, it parallels the Rust language's system of lifetimes based on blocks of code with a system of lifetimes based on sections of UI. This can't always be perfectly checked at compile time, but overall we think it's a net positive.