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

使用 Signals

到目前为止,我们使用了一些简单的 signal 示例,它返回一个 ReadSignal getter 和一个 WriteSignal setter。

获取和设置

有几个基本的 signal 操作:

获取

  1. .read() 返回一个读取守卫,它解引用到 signal 的值,并响应式地跟踪 signal 值的任何未来更改。注意,在此守卫被丢弃之前,您无法更新 signal 的值,否则会导致运行时错误。
  2. .with() 接受一个函数,该函数通过引用(&T)接收 signal 的当前值,并跟踪 signal。
  3. .get() 克隆 signal 的当前值并跟踪值的进一步更改。

.get() 是访问 signal 最常见的方法。.read() 对于接受不可变引用的方法很有用,无需克隆值(my_vec_signal.read().len())。.with() 在您需要对该引用做更多事情但想确保不会比需要的时间更长地持有锁时很有用。

内容详解 这段文档主要讲解了**三种“读取”Signal(信号)内部数据的方法**。虽然它们的目的都是“看数据”,但在 Rust 的内存规则下,它们的操作方式和适用场景完全不同。

我们可以把 Signal 里的数据比作锁在玻璃柜里的珍贵宝物(比如一本厚厚的字典)。


1. .get() —— “复印一份带走”

这是最常用、最简单的方法。

  • 原文解释:Clones the current value...
  • 通俗解释
    • 你走到玻璃柜前,拿出一台复印机,把里面的字典完整复印了一份
    • 然后你拿着复印件回到座位上去读。
  • 特点
    • 安全:因为你拿走的是复印件,所以别人如果要修改柜子里的原版字典,跟你手里的复印件没关系,不会冲突。
    • 成本:如果字典很厚(数据很大),复印一份比较花时间、占内存。
  • 适用场景:绝大多数情况。特别是数据比较小(比如数字、简单的字符串),或者你需要拥有这个数据的所有权时。

2. .read() —— “霸占着柜子看”

这个方法更高效,但有风险。

  • 原文解释:Returns a read guard... cannot update... until this guard is dropped.
  • 通俗解释
    • 你让管理员打开柜子,你直接拿着原版字典站在柜子旁边看。
    • Guard (守卫/锁):当你拿着字典看的时候,柜子处于“被占用”状态。
  • 风险(重点)
    • “正在读时不能写”:当你正拿着字典看的时候(手里攥着 Read Guard),如果此时程序里有另一个地方想修改这本字典(Write Signal),程序会直接报错崩溃(Runtime Error)。因为 Rust 不允许“有人在读的同时有人在改”。
    • 你必须看完赶紧把书放回去(让 Guard 销毁/Drop),别人才能修改。
  • 适用场景
    • 你想看一眼字典有多厚(比如 my_vec.read().len()),但不想把整本字典复印一份。
    • 只读不改,且操作很快。

3. .with() —— “借来看一眼”

这是 .read() 的安全改良版。

  • 原文解释:Takes a function... receives by reference... don't hold onto the lock longer than you need.
  • 通俗解释
    • 你跟管理员说:“我就看一眼,马上还你。”
    • 你提供一个具体的任务(函数/闭包),比如“查第5页的一个单词”。
    • 管理员把字典拿出来,让你做完这个任务,然后立刻强制把书收回柜子里锁好。
  • 特点
    • 自动还书:它保证了你不会因为粗心大意而一直霸占着字典(忘记 Drop Guard)。任务一结束,锁立刻释放。
    • 省资源:和 .read() 一样,它也是直接看原版,不需要复印(不 Clone)。
  • 语法样子
    // 意思:用 value 代表里面的数据,算出长度返回
    count.with(|value| value.len()) 
  • 适用场景
    • 你需要直接访问原数据(为了性能不复印),但又想确保安全,防止锁死。

总结:我该用哪个?

方法比喻优点缺点推荐指数
.get()复印带走最安全,不用担心锁的问题,代码写起来最简单。需要复制数据,如果数据巨大(比如几万字的列表)会慢一点。⭐⭐⭐⭐⭐ (默认首选)
.read()霸占着看不用复制,性能好。危险!如果不小心一直霸占着不放手,程序再想修改数据时会崩溃。⭐⭐ (仅限老手)
.with()借看一眼不用复制,且比 read 更安全(自动归还)。写法稍微麻烦一点点(要写闭包)。⭐⭐⭐⭐ (处理大物体时推荐)

新手建议: 只要你的数据不是特别巨大(比如只是几个数字、短文本),无脑使用 .get() 即可,它最不容易出 bug。只有当你发现性能有问题,或者数据结构非常复杂(不支持 Clone)时,再考虑 .with()

设置

  1. .write() 返回一个写入守卫,它是对 signal 值的可变引用,并通知任何订阅者它们需要更新。注意,在此守卫被丢弃之前,您无法从 signal 的值读取,否则会导致运行时错误。
  2. .update() 接受一个函数,该函数接收对 signal 当前值的可变引用(&mut T),并通知订阅者。(.update() 不返回闭包返回的值,但如果需要,您可以使用 .try_update();例如,如果您从 Vec<_> 中删除项目并想要被删除的项目。)
  3. .set() 替换 signal 的当前值并通知订阅者。

.set() 最常用于设置新值;.write() 对于就地更新值非常有用。就像 .read().with() 的情况一样,.update() 在您想避免比预期更长时间持有写锁的可能性时很有用。

内容详解 这段文档主要讲解了**三种“修改”Signal(信号)内部数据的方法**。也就是当你手里拿着“遥控器”(Write Signal)时,该怎么去改变玻璃柜里的数据。

我们继续沿用**“玻璃柜里的笔记本”**这个比喻。


1. .set() —— “直接换本新的”

这是最常用、最直接的方法。

  • 原文解释:Replaces the current value...
  • 通俗解释
    • 你根本不在乎柜子里的笔记本上原来写了什么。
    • 你直接拿出一本写好新内容的新笔记本,打开柜子,把旧的扔掉,换上新的
    • 换完后,立刻通知所有人:“数据变了!”
  • 适用场景
    • 当你不需要原来的数据,只想设置一个全新的值时。
    • 比如:把计数器重置为 0 (set_count.set(0)),或者把用户状态设为“已登录”。

2. .update() —— “派人进去改几笔”

这是当你需要在原有数据基础上进行修改时(比如 +1)最推荐的方法。

  • 原文解释:Takes a function... receives a mutable reference... notifies subscribers.
  • 通俗解释
    • 你不会把整本笔记本扔掉。
    • 你写了一张小纸条(闭包/函数),上面写着任务:“把第二页的数字加 1”。
    • 你把纸条递进柜子,系统会自动帮你打开柜子,执行修改,然后立刻关上柜子
  • 特点
    • 安全:因为它执行完任务立刻关柜子,不会导致“柜子门没关”的事故。
    • 省资源:如果你有一个很长的列表,.update() 只是往里面加一项,而 .set() 可能需要把整个列表重新造一遍。
  • 注意:这个方法通常没有返回值(它只负责改)。如果你想在改的同时顺便拿点东西出来(比如从列表里删掉一项并看看删了啥),需要用高级版的 .try_update()

3. .write() —— “霸占柜子自己改”

这是最底层、最强大,但也最危险的方法。

  • 原文解释:Returns a write guard... cannot read... until this guard is dropped.
  • 通俗解释
    • 你拿着钥匙打开柜子,亲自把笔记本拿在手里,想怎么改就怎么改。
    • Guard (守卫/锁):此时柜子处于“维修中”状态。
  • 巨大的风险
    • “独占锁”:当你霸占着柜子修改时,任何人(包括你自己)都不能再来看数据
    • 如果你拿着笔记本改了一半,忘记放回去(忘记 Drop Guard),或者在拿着的时候又试图去读这个信号,程序会直接崩溃(Runtime Error)。
  • 适用场景
    • 你需要做非常复杂的修改,.update() 那个小纸条写不清楚时。
    • 通常我们用 .update() 就够了,它是 .write() 的安全封装版。

总结:我该用哪个?

方法动作比喻适用场景推荐指数
.set()替换扔掉旧本子,换本新的设置简单的值(如数字、开关),或者重置数据。⭐⭐⭐⭐⭐ (最常用)
.update()修补按指令进去改两笔,改完立马关门基于旧值修改(如 n = n + 1),或者往大列表里加数据。⭐⭐⭐⭐⭐ (修改时首选)
.write()独占拿着本子不撒手,想怎么改怎么改极复杂的原地修改。容易导致死锁崩溃,新手慎用。⭐⭐ (高级玩家)

一句话口诀新的来了用 .set(),旧的要改用 .update(),没事别用 .write()

Note

这些 traits 基于 trait 组合并由毯子实现提供。例如,Read 为任何实现 TrackReadUntracked 的类型实现。With 为任何实现 Read 的类型实现。Get 为任何实现 WithClone 的类型实现。等等。

WriteUpdateSet 存在类似的关系。

在阅读文档时值得注意这一点:如果您只看到 ReadUntrackedTrack 作为已实现的 traits,您仍然能够使用 .with().get()(如果 T: Clone)等等。

内容详解 这段文档主要是在解释 Rust/Leptos 中的一种**“自动升级”**机制(学术名词叫“Blanket Implementations / 覆盖实现”)。

简单来说,它的意思是:你不需要一个个去学所有的技能,只要你拥有了基础能力,高级能力是系统自动送给你的。

为了解释清楚,我们用**“会员权益”**来打比方。


1. 核心逻辑:层层解锁的“全家桶”

文档中提到:

"Read is implemented for any type that implements Track and ReadUntracked..."

通俗解释: 想象你在办一个图书馆的会员卡。

  • 基础能力:如果你拥有“进门证”(Track)和“看书证”(ReadUntracked)。
  • 自动升级:系统判定:“既然你既能进门又能看书,那你自动就是一个正式读者Read)了!”

文档接着说:

"With is implemented for any type that implements Read. Get is implemented for any type that implements With and Clone."

继续升级

  1. 正式读者(Read $\rightarrow$ 自动获得 “借阅权”.with())。
    • 逻辑:既然你是正式读者,当然可以在馆里借阅查看。
  2. 借阅权(With) + 这里的书允许复印(Clone $\rightarrow$ 自动获得 “复印带走权”.get())。
    • 逻辑:既然你能借阅,而且书也能复印,那你自然就可以把内容复印一份带回家。

结论: 你不需要单独去申请“复印带走权”。只要你满足了前面的基础条件,这个功能是自动解锁的。

同理,对于修改数据(Write, Update, Set)也是这一套逻辑:只要你能拿到“笔”(Write),你就自动拥有了“涂改”(Update)和“重写”(Set)的能力。


2. 为什么要告诉你这个?(避坑指南)

这段话最重要的目的是为了教你怎么查文档,避免你以为功能缺失。

"This is worth noting when reading docs: if you only see ReadUntracked and Track ... you will still be able to use .with(), .get()..."

场景模拟

  1. 你打开 Leptos 的技术手册(API 文档),去查某个复杂的信号类型。
  2. 你发现文档列表里只写了它支持:ReadUntracked(不追踪读取)和 Track(追踪)。
  3. 新手反应:“完了!文档里没写它支持 .get()!难道这个信号不能用 .get() 获取值吗?”
  4. 高手反应(基于这段文档):“别慌。虽然文档没把 .get() 列在明面上,但因为它支持了基础的 TrackReadUntracked,根据自动升级规则,我知道它肯定暗地里已经支持 .get() 了,放心用!”

总结

这段话就是告诉你:Rust 的文档有时候很“懒”。它只会列出最基础的零件。但你要知道,一旦有了这些零件,像 .get().set().with() 这些高级工具都是自带的,直接用就行,不用担心文档里找不到它们。

使用 Signals

您可能注意到 .get().set() 可以用 .read().write(),或 .with().update() 来实现。换句话说,count.get()count.with(|n| n.clone())count.read().clone() 相同,count.set(1) 通过执行 count.update(|n| *n = 1)*count.write() = 1 来实现。

但当然,.get().set() 是更好的语法。

但是,其他方法有一些非常好的用例。

例如,考虑一个持有 Vec<String> 的 signal。

let (names, set_names) = signal(Vec::new());
if names.get().is_empty() {
	set_names(vec!["Alice".to_string()]);
}

在逻辑方面,这足够简单,但它隐藏了一些重大的低效率。记住 names.get().is_empty() 克隆值。这意味着我们克隆整个 Vec<String>,运行 is_empty(),然后立即丢弃克隆。

同样,set_names 用全新的 Vec<_> 替换值。这很好,但我们不妨就地改变原始 Vec<_>

let (names, set_names) = signal(Vec::new());
if names.read().is_empty() {
	set_names.write().push("Alice".to_string());
}

现在我们的函数简单地通过引用获取 names 来运行 is_empty(),避免了那个克隆,然后就地改变 Vec<_>

内容详解 这篇文章的核心内容是在讨论 **“方便” vs “性能”**。

作者想告诉你:虽然 .get()(获取)和 .set()(设置)写起来最顺手,但它们并不总是最高效的选择,特别是在处理复杂数据(比如长列表)的时候。

为了让你理解,我们用**“查字典”**来做比喻。


第一部分:核心概念 —— 表面方便的代价

文档开头提到:

".get() is identical to .with(|n| n.clone())..."

  • 含义:当你调用 .get() 时,Leptos 会把信号里的数据完整复印(Clone)一份给你。
  • 代价
    • 如果是 数字 0:复印一张小纸条,瞬间完成,没问题。
    • 如果是 一本字典 Vec<String>:复印整本字典非常耗时,而且浪费纸张(内存)。

文档接着说:

*".set(1) is implemented by ... count.write() = 1"

  • 含义:当你调用 .set() 时,你是把原来的东西扔了,换个新的
  • 代价:如果你只想在字典里加一个字,结果你买了一本新字典(包含了那个新字)替换了旧字典,这很浪费。

第二部分:反面教材(代码解析)

作者首先给出了一个逻辑正确但效率低的写法。

// 1. 创建一个信号,里面放着一个空的字符串列表
let (names, set_names) = signal(Vec::new());

// 2. 检查列表是不是空的
if names.get().is_empty() {
    // 3. 如果是空的,就放入 "Alice"
	set_names(vec!["Alice".to_string()]);
}

为什么这段代码“很浪费”?

  1. names.get().is_empty()
    • names.get():这一步把整个列表(假设里面有1万个人名)完整复印了一份。
    • .is_empty():在这个复印件上检查是不是空的。
    • 结局:检查完立刻把复印件扔进碎纸机。白白复印了一次!
  2. set_names(...)
    • 这里创建了一个全新的列表(包含 "Alice"),然后把旧列表整个替换掉。虽然在这个简单例子里问题不大,但如果列表很大,我们只是想加一个人,没必要重造整个列表。

第三部分:正面教材(代码解析)

作者接着给出了高效的写法。

let (names, set_names) = signal(Vec::new());

// 1. 高效检查
if names.read().is_empty() {
    // 2. 高效修改
	set_names.write().push("Alice".to_string());
}

为什么这段代码好?

  1. names.read().is_empty()
    • .read():这就像透过玻璃窗看一眼柜子里的列表。没有复印,没有浪费。
    • 直接在原件上检查它是不是空的。
  2. set_names.write().push(...)
    • .write():把柜子打开,拿出原件。
    • .push(...):在原件的末尾追加一行字。
    • 结局:没有创建新列表,没有扔掉旧列表,只是在原来的基础上修改(Mutate in place)。

第四部分:Rust 语法小课堂

这里解释一下代码中出现的 Rust 特有语法。

1. Vec<String>Vec::new()

  • 语法Vec 是 Vector(向量)的缩写。
  • 通俗解释:这就是一个动态数组列表。你可以把它想象成一个能自动伸缩的收纳盒
  • <String>:尖括号里表示盒子里装的是什么东西。这里装的是文本字符串。
  • Vec::new():这是“出厂设置”。意思是造一个新的、空的收纳盒。

2. vec![...]

  • 语法:这是一个(Macro,Rust里带感叹号 ! 的通常是宏)。
  • 作用:这是一个快捷方式。
    • Vec::new() 只能造空的。
    • vec!["A", "B"] 可以直接造一个里面已经装了 "A" 和 "B" 的盒子。

3. "Alice".to_string()

  • 问题:为什么不直接写 "Alice"
  • 语法解释
    • 在 Rust 里,直接写的 "Alice"&str(字符串切片)。它通常是只读的、固定在内存里的,像刻在石头上的字。
    • Vec 盒子要求装的是 String。这是一种可以在堆内存里修改、拼接、变长变短的文本对象
  • .to_string():这个函数的作用就是把“石头上的字”(&str)抄写到一张“纸”(String)上,这样才能放进盒子里管理。

4. .push(...)

  • 作用:往列表屁股后面追加一个元素。
  • 例子:盒子原来有 [A, B]push(C) 之后变成 [A, B, C]。这是原地修改,不需要换新盒子。

总结

文档的核心思想是: 如果你在处理比较大、比较重的数据(比如列表、大段文字),尽量用 .read().write()/.update(),避免用 .get().set(),因为复印一份大文件是很贵的

线程安全和线程本地值

您可能已经注意到,无论是通过阅读文档还是通过试验自己的应用程序,存储在 signals 中的值必须是 Send + Sync。这是因为响应式系统实际上支持多线程:signals 可以跨线程发送,整个响应式图可以跨多个线程工作。(这在使用像 Axum 这样使用 Tokio 多线程执行器的服务器框架进行服务端渲染时特别有用。)在大多数情况下,这对您所做的事情没有影响:普通的 Rust 数据类型默认是 Send + Sync

但是,浏览器环境只是单线程的,除非您使用 Web Worker,wasm-bindgenweb-sys 提供的 JavaScript 类型都明确是 !Send。这意味着它们不能存储在普通 signals 中。

因此,我们为每个 signal 原语提供"本地"替代方案,可用于存储 !Send 数据。只有当您有需要存储在 signal 中的 !Send 浏览器类型时,您才应该使用这些。

内容详解 这段文档主要讲的是在 Rust 和 Leptos 中,如何处理**“多线程安全”**和**“浏览器特有的数据”**之间的矛盾。

为了让你听懂,我们用**“厨房做饭”**来打比方。


第一部分:背景故事 —— 繁忙的厨房(服务器)与独角戏(浏览器)

1. 默认情况:为大饭店设计的标准流程

文档首先说:

"values that are stored in signals must be Send + Sync"

  • Rust 的设定:Rust 是一门极度重视安全的语言。Leptos 这个框架设计之初,是为了能让代码在服务器上跑(比如 Server-Side Rendering)。
  • 比喻:服务器就像一个拥有很多厨师(线程)的大饭店厨房
    • 一个盘子(Signal 数据)可能上一秒在厨师 A 手里,下一秒就传给了厨师 B。
    • 为了防止两个厨师同时抢一个盘子把菜撒了,Rust 要求所有在厨房里传递的盘子必须贴上**“允许传递”的安全标签**。
  • 术语解释
    • Send + Sync:这就是那个“安全标签”。
      • Send:表示这个数据可以安全地从一个线程(厨师)传给另一个线程。
      • Sync:表示多个线程可以安全地同时看这份数据。
    • 现状:大部分普通的食材(Rust 的普通数据类型,如数字 i32、字符串 String)默认都有这个标签,可以在大饭店里随意传递。

2. 特殊情况:浏览器的路边摊

文档接着说:

"the browser environment is only single-threaded... JavaScript types ... are all explicitly !Send"

  • 浏览器的设定:浏览器(网页)通常就像一个只有一个厨师的路边摊(单线程)。所有的活儿都是这一个厨师干的。
  • 问题出现
    • 在浏览器里,我们经常需要用到 JavaScript 的东西(比如网页上的一个 DOM 元素 <div />,或者一个 JS 对象)。
    • 这些 JS 的东西是非常娇气的,它们被绑定在了这唯一的厨师手上。它们绝对不能被传给“其他厨师”(因为根本没有其他厨师,或者技术上不允许)。
  • 术语解释
    • !Send:感叹号 ! 在编程里表示“非”或“不”。所以 !Send 意思就是 “禁止传递”
    • 冲突:Leptos 的标准 Signal 盒子(比如 signal())是为了大饭店设计的,它强制要求放入的东西必须有“允许传递”标签。但 JS 的对象贴着“禁止传递”标签。
    • 结果:如果你试图把 JS 对象放进普通的 Signal 里,Rust 编译器会报错,不让你通过。

第二部分:解决方案 —— 本地专用盒子

文档最后说:

"we provide 'local' alternatives... which can be used to store !Send data"

既然浏览器这个路边摊只有一个厨师,那其实根本不需要担心“传递给别人”的问题。于是,Leptos 提供了一套**“本地版”工具**。

  • 作用:这些工具是专门为“独角戏”环境设计的。它们不检查“允许传递”标签。
  • 比喻:既然只有你一个厨师,那盘子就不需要封盖严实防抢夺了,随便拿个普通的碗装着就行。

第三部分:代码与表格解读

文档列出了一个表格,左边是**“大饭店标准版”,右边是“路边摊本地版”**。

Standard (标准版)Local (本地版)什么时候用?
signalsignal_local99%的情况用左边的。只有当你存的数据是 JS 对象(!Send)时,用右边的。
RwSignal::newRwSignal::new_local同上。这是读写信号的本地版。
ResourceLocalResource同上。这是用于处理异步加载(如从服务器获取数据)的资源。
Action::newAction::new_local同上。这是用于处理表单提交等动作的。

语法细节解释:

  1. signal_local

    • 定义:创建一个不需要线程安全的信号。
    • 用法:和普通的 signal 一模一样,只是名字变了。
    • 例子
      // 假设 js_thing 是一个来自 JavaScript 的对象,它是 !Send 的
      // ❌ 错误:普通的 signal 会报错,因为它要求数据能跨线程发送
      let (data, set_data) = signal(js_thing); 
      
      // ✅ 正确:告诉 Leptos 我就在当前线程用,不传给别人
      let (data, set_data) = signal_local(js_thing); 
  2. RwSignal

    • 定义:Read-Write Signal(读写信号)。这是一种更高级的信号,不像 signal 那样要把读写分家(getter/setter),它自己就是一个整体,既能读也能写。
    • ::new vs ::new_local:: 是 Rust 中调用“静态方法”或“构造函数”的写法。意思是从 RwSignal 这个工具包里调用 new(新建)方法。
  3. wasm-bindgenweb-sys

    • 文档里提到了这两个词。它们是 Rust 生态系统里的翻译官
    • 作用:它们负责把 Rust 代码翻译成浏览器能听懂的指令,或者把浏览器的 DOM 元素包装成 Rust 能用的对象。正是这些被包装出来的对象,通常带有 !Send(禁止跨线程)的属性。

总结

  1. Rust 默认很严:为了支持服务器端的多线程,Leptos 的标准 Signal 要求数据必须安全(Send + Sync)。
  2. 浏览器很特殊:浏览器里的 JS 数据通常是不支持多线程的(!Send)。
  3. 解决方法
    • 平时存普通数据(数字、文字、结构体),用标准版signal)。
    • 如果编译器报错说“这个类型是 !Send”,或者你需要存浏览器特有的 JS 对象,改用本地版signal_local)。

Nightly 语法

当使用 nightly 功能和 nightly 语法时,将 ReadSignal 作为函数调用是 .get() 的语法糖。将 WriteSignal 作为函数调用是 .set() 的语法糖。所以

let (count, set_count) = signal(0);
set_count(1);
logging::log!(count());

与以下相同

let (count, set_count) = signal(0);
set_count.set(1);
logging::log!(count.get());

这不仅仅是语法糖,而是通过使 signals 在语义上与函数相同来提供更一致的 API:请参阅插曲

内容详解 这段文档介绍了一种在 Rust/Leptos 中让代码写起来更简洁、更像数学公式的**“高级写法”**。这种写法需要开启 Rust 的 **Nightly(每夜版/测试版)** 功能。

我们还是用**“电视和遥控器”**(Signal)的比喻来解释。


1. 什么是 "Nightly" (每夜版)?

在 Rust 的世界里,编译器有两个主要版本:

  • Stable(稳定版):经过严格测试,虽然有时候写起来啰嗦一点,但绝对稳健。
  • Nightly(每夜版):包含了最新的、还在实验中的功能。它允许开发者使用一些“黑科技”让代码变得更短、更漂亮,但需要专门开启配置。

这段文档说的就是:如果你愿意开启 Nightly 模式,你就能解锁一种“偷懒”的写法。


2. 什么是 "Syntax Sugar" (语法糖)?

文档中提到了 syntax sugar

  • 通俗解释:这就好比我们在聊天时打字,把“也就是”打成“is”,或者把“好笑”打成“hh”。
  • 作用:意思完全一样,但是写起来更短、更顺手。计算机在后台处理时,会自动把它翻译回原本正规的样子。

3. 代码对比与解析

文档展示了两种写法,它们的功能完全一模一样

A. 传统的“稳定版”写法(以前学的)

// 1. 创建信号:count 是电视(读),set_count 是遥控器(写)
let (count, set_count) = signal(0);

// 2. 修改值:显式调用 .set() 方法
set_count.set(1); 

// 3. 读取值:显式调用 .get() 方法
// logging::log! 是把结果打印到控制台的工具
logging::log!(count.get());
  • 特点:很直白。我要“设置”就写 .set,我要“获取”就写 .get

B. 高级的“Nightly 语法糖”写法

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

// 1. 修改值:直接把遥控器当函数用
set_count(1); 

// 2. 读取值:直接把电视当函数用
logging::log!(count());

语法变化解释:

  1. set_count(1) 代替 set_count.set(1)

    • 通俗理解:你不再需要按遥控器上的特定按钮(.set),你只要拿着遥控器喊一声“1”,它就懂了。
    • 语法定义:在 Rust 中,这叫“调用运算符重载”。简单说就是允许你把一个变量像函数一样加个括号 () 来运行。
  2. count() 代替 count.get()

    • 通俗理解:你不再需要专门去查阅(.get),你只要叫一声电视的名字“count”,它就把画面给你。
    • 语法定义:同上。这里括号里没东西,表示“我要取值”。

4. 为什么要这么做?(设计哲学)

文档最后提到:

"makes signals semantically the same thing as functions"

这是为了让代码逻辑更像数学公式或者函数

  • 函数式思维: 在数学里,$f(x)$ 表示计算结果。 如果 count 代表当前的数字,那么 count() 就像是在问:“现在的数值是多少?”

    想象一下,如果以后你不仅可以放信号,还可以放一个真正的计算函数。如果大家都用 () 来调用,那么信号函数长得就一模一样了。你不需要关心它背后到底是一个存好的数字,还是一个实时算出来的公式,反正加个括号 () 就能拿到结果。

    这是一种让代码更统一、更抽象的美学追求。

总结

  1. Nightly Syntax 是一种开启了“实验功能”后的简写模式。
  2. set_count(1) 等同于 set_count.set(1)(写)。
  3. count() 等同于 count.get()(读)。
  4. 这不仅是偷懒(语法糖),也是为了让信号看起来更像函数调用,让代码风格更统一。

使 signals 相互依赖

人们经常询问某个 signal 需要根据其他 signal 的值而改变的情况。有三种好方法来做到这一点,还有一种在受控情况下不太理想但可以的方法。

好的选择

1) B 是 A 的函数。 为 A 创建一个 signal,为 B 创建一个派生 signal 或 memo。

// A
let (count, set_count) = signal(1);
// B 是 A 的函数
let derived_signal_double_count = move || count.get() * 2;
// B 是 A 的函数
let memoized_double_count = Memo::new(move |_| count.get() * 2);

有关是否使用派生 signal 或 memo 的指导,请参阅 Memo 的文档

2) C 是 A 和其他东西 B 的函数。 为 A 和 B 创建 signals,为 C 创建派生 signal 或 memo。

// A
let (first_name, set_first_name) = signal("Bridget".to_string());
// B
let (last_name, set_last_name) = signal("Jones".to_string());
// C 是 A 和 B 的函数
let full_name = move || format!("{} {}", &*first_name.read(), &*last_name.read());

3) A 和 B 是独立的 signals,但有时同时更新。 当您调用更新 A 时,单独调用更新 B。

// A
let (age, set_age) = signal(32);
// B
let (favorite_number, set_favorite_number) = signal(42);
// 使用这个来处理 `Clear` 按钮的点击
let clear_handler = move |_| {
  // 更新 A 和 B
  set_age.set(0);
  set_favorite_number.set(0);
};

如果您真的必须...

4) 创建一个 effect 在 A 更改时写入 B。 这是官方不鼓励的,有几个原因: a) 它总是效率较低,因为这意味着每次 A 更新时,您都要完整地通过响应式过程两次。(您设置 A,这导致 effect 运行,以及依赖于 A 的任何其他 effects。然后您设置 B,这导致依赖于 B 的任何 effects 运行。) b) 它增加了意外创建无限循环或过度重新运行 effects 等情况的机会。这是 2010 年代初常见的那种乒乓球式响应式意大利面条代码,我们试图通过读写分离和不鼓励从 effects 写入 signals 等方式来避免。

在大多数情况下,最好重写事物,使基于派生 signals 或 memos 有清晰的自顶向下数据流。但这不是世界末日。

我故意不在这里提供示例。阅读 Effect 文档来弄清楚这如何工作。

内容详解 这段文档非常实用,它探讨了一个核心问题:**“当变量 A 发生变化时,怎么让变量 B 也跟着自动变?”**

这就像在 Excel 里,你希望修改了“单价”,旁边的“总价”能自动算出新结果,而不需要你手动去改。

文档给出了 3 种推荐做法(Good Options)1 种不推荐的做法(If you really must...)。我们一个个来看。


第一种好方法:B 是 A 的计算结果(衍生信号)

这是最常见、最推荐的。就像数学公式 $y = 2x$。你不需要存储 $y$,你只需要定义计算公式。

代码解析

// 1. 定义基础信号 A (count)
let (count, set_count) = signal(1);

// 2. 定义 B (derived_signal):它是一个闭包(小函数)
// 逻辑:每次有人问我要 B,我就去读 A,然后乘以 2 给它
let derived_signal_double_count = move || count.get() * 2;

// 3. 定义 B 的另一种形式 (Memo):带“记忆”功能的计算
let memoized_double_count = Memo::new(move |_| count.get() * 2);

语法小课堂:

  • move || ...:这是 闭包(Closure)
    • ||:表示这是一个没有参数的函数(就像个口袋)。
    • move:表示把外面的变量(这里是 count打包带走,装进这个口袋里,以便随时使用。
    • 通俗理解:这不是一个死数字,这是一个动态公式。Leptos 知道这个公式里用了 count,所以 count 一变,这个公式算出来的值也就变了。
  • Memo::new(...)记忆缓存(Memoization)
    • 作用:它和上面那个公式功能一样,但更聪明。
    • 区别:普通的公式(上面那个)每次你需要数字时,它都会重新算一遍 1 * 2。而 Memo记住上一次的结果。只要 count 没变,它就直接把上次记住的答案给你,不用再算一遍。适合计算量很大的数学题。

第二种好方法:C 是 A 和 B 的混合体

这就像拼接字符串:全名 = 姓 + 名

代码解析

// A: 名字
let (first_name, set_first_name) = signal("Bridget".to_string());
// B: 姓氏
let (last_name, set_last_name) = signal("Jones".to_string());

// C: 全名 (拼接 A 和 B)
let full_name = move || format!("{} {}", &*first_name.read(), &*last_name.read());

语法小课堂:

  • "Bridget".to_string()
    • 在 Rust 里,直接写的 "abc" 是只读的文本片段。
    • .to_string() 是把它变成一个可以修改、可以在内存里搬运的文本对象
  • format!("{} {}", ...)
    • 这是 Rust 的填空题工具
    • "{}" 是占位符。它会把后面传入的两个参数,依次填入这两个坑里,拼接成一句话。
  • &*first_name.read():这串符号看着很吓人,咱们拆开看(这是为了性能):
    • .read():借来看一眼(不复印)。
    • * (解引用) 和 & (借用):这是 Rust 处理内存的特殊动作。
    • 通俗解释:你就理解为**“我要安全地读取里面的文字,把它放到填空题里去”**。

第三种好方法:A 和 B 虽独立,但在同一时刻更新

有时候 A 和 B 没有计算关系(比如“年龄”和“幸运数字”),但你点击“重置”按钮时,想把它们同时归零。

这不需要建立依赖,只需要在动作里同时改它俩。

代码解析

// A
let (age, set_age) = signal(32);
// B
let (favorite_number, set_favorite_number) = signal(42);

// 定义一个“清除处理器”(比如绑定到按钮点击事件)
let clear_handler = move |_| {
  // 在这一个动作里,同时修改 A 和 B
  set_age.set(0);
  set_favorite_number.set(0);
};

语法小课堂:

  • move |_| { ... }:这也是个闭包。
    • _:下划线表示“我知道有个参数(比如点击事件详情),但我不在乎,我不打算用它”。
    • 这里面的逻辑非常直白:点一下按钮,就执行两行命令。

第四种:不推荐的做法(除非迫不得已)

做法:创建一个“监听器 Effect”,一旦 A 变了,我就去改写 B。

  • 这就像:你为了告诉 B 事情,你给 B 发了个快递。B 收到快递后,发现要改,又去发快递通知 C...

为什么不好?

  1. 效率低
    • A 变了 $\rightarrow$ 通知系统重画 $\rightarrow$ 触发监听器 $\rightarrow$ 监听器修改 B $\rightarrow$ B 变了,系统又得醒过来再重画一次
    • 这叫“跑了两趟车”(Two full trips)。
  2. 容易出乱子(意大利面条代码)
    • 如果 A 改了 B,B 又有一个监听器去改 A... 砰!死循环(Infinite loop)。
    • 这种代码很难理清楚谁先谁后,就像一团乱麻。

总结建议

  • 首选:如果你能用公式算出结果(方法 1 和 2),就用公式(衍生信号/Memo)。这是**“自上而下”**的数据流,最清晰。
  • 次选:如果是用户操作导致的共同变化(方法 3),就在事件处理函数里写。
  • 最后手段:只有在极特殊情况下,才使用 Effect 去修改 Signal。