Alien Signals 技术分析之信号(Signal)(一)
AI 摘要 (由 qwen/qwen-2.5-72b-instruct 生成)
信号是响应式系统中的基础数据存储单元,用于存储可变状态。通过 `signal(initialValue)` 创建信号,返回一个 getter/setter 函数。调用该函数不带参数可读取当前值,带参数可写入新值。读取操作在响应式上下文中会自动追踪依赖,写入操作在值改变时会自动通知所有依赖该信号的订阅者。信号就像公告板,持有信息并在信息更新时通知所有关心它的人。
引言
之前写了一篇文章关于 vue3.6 alien signals的简单介绍,接下来我们将深度分析源码关于其中的思想与实现
首先这是一个用于构建用户界面的响应式系统基础库。 它允许你创建响应式数据源(称为 信号 Signal),当这些数据源变化时,依赖它们的值(计算属性 Computed)和操作(副作用 Effect)会自动更新。 系统的核心引擎负责追踪依赖关系,并在数据变化时高效地调度和执行必要的更新,确保应用状态和视图保持同步。
接下来将会通过这几个部分深度解析这个源码
什么是信号?为什么需要它?
想象一下,你在开发一个简单的用户界面,需要显示用户的名字。这个名字可能会在用户登录后、或者在设置中被更改。当名字变化时,界面上所有显示这个名字的地方都应该自动更新。
如果用传统的方式,你可能需要手动找到所有显示名字的地方,然后逐一更新它们。这很繁琐,而且容易出错。如果能有一个“魔法盒子”来存放这个名字,当名字改变时,这个盒子能自动通知所有关心它变化的地方去更新,那该多好!
信号 (Signal) 就是这个“魔法盒子”。
在 alien signals
中,信号是响应式系统的基础数据存储单元。你可以:
- 写入 (Write): 往信号里存入或更新一个值。
- 读取 (Read): 从信号里获取当前的值。
- 订阅 (Subscribe): 让其他部分(比如界面元素、或其他计算逻辑)“关注”这个信号。当信号的值发生变化时,所有关注它的“订阅者”都会收到通知。
用一个更形象的比喻:信号就像一个可以被监听的公告板。
- 写入:你在公告板上更新内容。
- 读取:你去看公告板上的内容。
- 订阅:如果你在看公告板的时候,表明了你“关心”这个公告板的更新(比如你是一个需要根据公告板信息做事的“订阅者”),那么你的名字就会被记录下来。下次公告板更新时,你就会收到通知。
信号使得数据变化能够自动、高效地传递给所有依赖它的部分,是构建复杂、动态应用的基础。
如何使用信号?
让我们通过一个简单的例子来看看如何创建和使用信号。
1. 创建信号
你可以使用 signal
函数来创建一个新的信号。你可以选择提供一个初始值,也可以不提供(此时初始值为 undefined
)。
import { signal } from './src/index.js'; // 导入 signal 函数
// 创建一个存储字符串的信号,初始值为 "初始名字"
const userName = signal("初始名字");
// 创建一个存储数字的信号,没有初始值
const userAge = signal<number>(); // 使用 <number> 指定类型,初始值为 undefined
解释:
signal("初始名字")
创建了一个信号,并将字符串"初始名字"
作为它的初始值。signal<number>()
创建了一个信号,我们用<number>
告诉 TypeScript 这个信号将来会存储数字类型,但现在它的初始值是undefined
。signal
函数返回的不是信号的值本身,而是另一个函数。这个返回的函数非常特殊,它既可以用来读取信号的值,也可以用来写入新值。
2. 读取信号的值
要读取信号当前的值,你需要调用创建信号时返回的那个函数,并且不带任何参数。
import { signal } from './src/index.js';
const userName = signal("张三");
// 读取 userName 信号的值
const currentName = userName(); // 调用返回的函数,不传参数
console.log(currentName); // 输出: 张三
const userAge = signal<number>();
const currentAge = userAge();
console.log(currentAge); // 输出: undefined
解释:
- 调用
userName()
(注意后面的括号()
)会返回信号userName
当前存储的值"张三"
。 - 调用
userAge()
会返回undefined
,因为我们创建它时没有给初始值。
3. 写入信号的值
要更新信号的值,你需要调用创建信号时返回的那个函数,并且传入新的值作为参数。
import { signal } from './src/index.js';
const userName = signal("张三");
console.log("更新前:", userName()); // 输出: 更新前: 张三
// 写入新值 "李四" 到 userName 信号
userName("李四"); // 调用返回的函数,传入新值
console.log("更新后:", userName()); // 输出: 更新后: 李四
解释:
- 调用
userName("李四")
将信号userName
内部存储的值更新为"李四"
。 - 再次调用
userName()
读取时,得到的就是新值"李四"
。
总结一下:
signal(initialValue)
返回一个函数 s
。
- 调用
s()
:读取信号的值。 - 调用
s(newValue)
:写入信号的值。
这种设计可能看起来有点奇怪,但它为响应式系统提供了一种统一且简洁的交互方式。
信号的内部机制(初步了解)
现在我们知道了如何使用信号,让我们稍微深入一点,看看它内部是如何工作的。这有助于我们理解为什么它能实现“自动通知”。
创建时发生了什么?
当你调用 signal(initialValue)
时:
- 内部会创建一个信号对象 (Signal Object)。这个对象至少包含:
currentValue
: 用来存储信号的当前值(也就是你传入的initialValue
)。subs
: 一个列表(或者说链表),用来记录所有“关注”这个信号的订阅者 (Subscribers)。初始为空。subsTail
: 指向订阅者列表的末尾,用于快速添加新的订阅者。
signal
函数会返回一个特殊的函数,我们称之为 getter/setter 函数。这个函数绑定 (bind) 了刚才创建的信号对象。这意味着当这个 getter/setter 函数被调用时,它内部的this
会指向那个信号对象。
// src/index.ts (简化示意)
function signal<T>(initialValue?: T) {
// 1. 创建信号对象
const signalObject = {
currentValue: initialValue,
subs: undefined, // 初始没有订阅者
subsTail: undefined, // 初始没有订阅者
};
// 2. 返回绑定了信号对象的 getter/setter 函数
return signalGetterSetter.bind(signalObject) as /* ...类型 */;
}
读取时发生了什么? (signalGetterSetter
无参数调用)
当你调用 theSignal()
来读取值时,实际上是在调用那个绑定了信号对象的 signalGetterSetter
函数:
- 函数检查当前是否在一个响应式上下文 (Reactive Context) 中。响应式上下文通常由 计算属性 (Computed) 或 副作用 (Effect) 创建(我们将在后续章节学习它们)。系统内部会有一个全局变量(比如
activeSub
)来标记当前活动的订阅者。 - 如果存在活动的订阅者 (
activeSub
不为undefined
):这意味着这次读取操作是某个“订阅者”为了计算自己的值或执行某些操作而进行的。这时,系统需要建立信号和这个订阅者之间的依赖关系。它会调用link(this, activeSub)
函数(this
指向信号对象,activeSub
指向当前活动的订阅者),将这个订阅者添加到信号对象的subs
列表中(如果尚未添加的话)。这就像在公告板旁边记下:“某某订阅者正在关注这个公告板”。 - 无论是否存在活动的订阅者,函数最后都会返回信号对象中存储的
this.currentValue
。
// src/index.ts (简化示意 - signalGetterSetter 读取部分)
function signalGetterSetter<T>(this: Signal<T>) {
// (这里省略了写入逻辑的判断)
// 检查是否有活动的订阅者
if (activeSub !== undefined) {
// 如果有,建立依赖关系:将 activeSub 添加到 this (信号对象) 的订阅者列表 subs 中
link(this, activeSub); // `link` 来自响应式系统核心
}
// 返回当前值
return this.currentValue;
}
写入时发生了什么? (signalGetterSetter
有参数调用)
当你调用 theSignal(newValue)
来写入值时:
- 同样是调用
signalGetterSetter
函数,但这次传入了参数。 - 函数首先比较传入的
newValue
和信号对象中存储的this.currentValue
是否不同。 - 如果值确实发生了变化:
- 更新
this.currentValue = newValue
。 - 通知订阅者! 这是关键步骤。函数会检查信号对象的
subs
列表是否为空。如果不为空,说明有订阅者关注了这个信号。它会调用propagate(this.subs)
函数(propagate
意为“传播”)。propagate
函数会遍历subs
列表中的所有订阅者,并通知它们:“你关注的信号变化了,你可能需要重新计算或执行你的副作用了!” (propagate
的具体工作方式涉及标记订阅者为“脏”状态,我们将在 依赖追踪与链接 (Tracking & Linking) 和 响应式系统核心 (Reactive System Core) 章节深入探讨)。 - (还有一个细节是关于
batchDepth
和processEffectNotifications
的,它们用于批量处理更新,避免不必要的重复计算,我们稍后会接触)。
- 更新
- 如果值没有变化,则什么也不做。
// src/index.ts (简化示意 - signalGetterSetter 写入部分)
function signalGetterSetter<T>(this: Signal<T>, ...value: [T]): T | void {
if (value.length) { // 检查是否传入了参数 (即写入操作)
const newValue = value[0];
// 检查值是否真的改变了
if (this.currentValue !== newValue) {
// 1. 更新当前值
this.currentValue = newValue;
// 2. 获取订阅者列表
const subs = this.subs;
if (subs !== undefined) {
// 3. 如果有订阅者,通知它们 (传播更新)
propagate(subs); // `propagate` 来自响应式系统核心
// (处理批量更新,暂时忽略)
// if (!batchDepth) {
// processEffectNotifications();
// }
}
}
} else {
// ... 读取逻辑 (如上所述) ...
}
}
简单流程图
让我们用一个简单的图来梳理一下读取和写入的流程:
关键点:
- 信号本身只是存储数据 (
currentValue
) 和订阅者列表 (subs
)。 - 读取操作可能会建立依赖关系(通过
link
)。 - 写入操作在值改变时会触发通知(通过
propagate
)。
这就是信号实现其“魔法”——自动通知变化——的基本原理。它构成了整个响应式系统的基石。
总结
在本章中,我们学习了 alien signals
响应式系统的核心概念——信号 (Signal)。
- 信号是用于存储可变状态的基础单元。
- 使用
signal(initialValue)
创建信号,它返回一个特殊的getter/setter 函数。 - 调用返回的函数不带参数 (
signal()
) 可以读取当前值。 - 调用返回的函数带参数 (
signal(newValue)
) 可以写入新值。 - 读取操作在响应式上下文中会自动追踪依赖(将当前订阅者链接到信号)。
- 写入操作在值改变时会自动通知所有依赖该信号的订阅者。
信号就像响应式系统中的一个个小小的公告板,它们持有信息,并在信息更新时通知所有关心它们的人。