插曲:反应性与函数
我们的核心贡献者之一最近对我说:“在用 Leptos 之前,我从未如此频繁地使用闭包。”这确实是事实。闭包是任何 Leptos 应用程序的核心。有时看起来有点滑稽:
// 一个信号持有一个值,可以被更新
let (count, set_count) = signal(0);
// 一个派生信号是一个访问其他信号的函数
let double_count = move || count.get() * 2;
let count_is_odd = move || count.get() & 1 == 1;
let text = move || if count_is_odd() {
"odd"
} else {
"even"
};
// 一个效果会自动追踪它依赖的信号
// 并在信号发生变化时重新运行
Effect::new(move |_| {
logging::log!("text = {}", text());
});
view! {
<p>{move || text().to_uppercase()}</p>
}
闭包,到处都是闭包!
但为什么呢?
函数与 UI 框架
函数是每个 UI 框架的核心。这完全合情合理。创建用户界面基本上可以分为两个阶段:
- 初始渲染
- 更新
在 Web 框架中,框架会进行某种初始渲染。然后将控制权交还给浏览器。当某些事件(如鼠标点击)触发或异步任务(如 HTTP 请求完成)结束时,浏览器会唤醒框架以更新内容。框架运行某些代码来更新用户界面,然后再次休眠,直到浏览器再次唤醒它。
这里的关键短语是“运行某些代码”。在 Rust 或任何其他编程语言中,在任意时间点“运行某些代码”的自然方式是调用一个函数。实际上,每个 UI 框架都基于某种函数的反复运行:
- 虚拟 DOM(VDOM)框架(如 React、Yew 或 Dioxus)反复运行组件或渲染函数,以生成一个虚拟 DOM 树,该树可以与之前的结果对比并更新 DOM。
- 编译型框架(如 Angular 和 Svelte)将组件模板划分为“创建”和“更新”函数,并在检测到组件状态发生变化时运行更新函数。
- 细粒度反应式框架(如 SolidJS、Sycamore 或 Leptos),由开发者自己定义重新运行的函数。
这就是我们所有组件在做的事情。
以下是我们常见的 <SimpleCounter/> 示例的最简形式:
#[component]
pub fn SimpleCounter() -> impl IntoView {
let (value, set_value) = signal(0);
let increment = move |_| *set_value.write() += 1;
view! {
<button on:click=increment>
{value}
</button>
}
}
SimpleCounter 函数本身只运行一次。value 信号只创建一次。框架将 increment 函数作为事件监听器交给浏览器。当你点击按钮时,浏览器调用 increment,通过 set_value 更新 value,从而更新视图中由 {value} 表示的单个文本节点。
函数是反应性的关键。它们为框架提供了在响应变化时重新运行应用程序最小单位的能力。
所以记住两点:
- 你的组件函数是一个初始化函数,而不是渲染函数:它只运行一次。
- 对于模板中的值要具有反应性,它们必须是反应性函数:要么是信号,要么是捕获并读取信号的闭包。
这实际上是 Leptos 稳定版和夜间版的主要区别之一。如你所知,使用夜间编译器和 nightly 功能可以直接调用信号作为函数:比如 value() 替代 value.get()。
但这不仅仅是语法糖。它允许一个极为一致的语义模型:反应性事物就是函数。信号通过调用函数访问。要表示“传递一个信号作为参数”,你可以接收任何实现 impl Fn() -> T 的东西。而这种基于函数的接口不会区分信号、memo 或派生信号:它们都可以通过函数调用访问。
不幸的是,在自定义结构(如信号)上实现 Fn 特性需要夜间版 Rust,尽管这个功能已经搁置很久且可能短期内不会稳定。由于种种原因,许多人会避免使用夜间版。因此,随着时间推移,我们的文档等默认内容都更倾向于稳定版。然而,这让“信号是函数”这个简单的心智模型稍显复杂。