Alien Signals 技术分析之计算属性 (Computed)(二)
AI 摘要 (由 grok-3-mini-fast-beta 生成)
计算属性(Computed)解决响应式系统中手动管理派生值的复杂性问题。通过computed函数基于信号自动计算派生值,实现惰性求值和缓存,确保依赖变化时高效更新,提升数据一致性和性能。
在上一章 信号 (Signal) 中,我们学习了如何创建和使用响应式系统中最基础的数据单元——信号,来存储和更新单个值。但是,通常我们的应用中会有一些值是依赖于其他值计算得出的。例如,用户的全名可能由姓氏和名字组合而成,或者购物车里的总价是根据商品单价和数量计算出来的。如果我们手动管理这些派生值的更新,代码很快就会变得复杂且容易出错。
这时,计算属性 (Computed) 就派上用场了!
什么是计算属性?为什么需要它?
想象一下,我们正在开发一个简单的用户界面,需要显示用户的全名。我们有两个独立的信号 (Signal):一个存储姓氏 (lastName
),一个存储名字 (firstName
)。
import { signal } from './src/index.js';
const firstName = signal("张");
const lastName = signal("三");
// 如何得到全名 "张三" 呢?
// 如果我们手动拼接:
let fullName = firstName() + lastName();
console.log("当前全名:", fullName); // 输出: 当前全名: 张三
// 当名字改变时...
firstName("李");
// 我们必须手动重新计算 fullName!
fullName = firstName() + lastName();
console.log("更新后的全名:", fullName); // 输出: 更新后的全名: 李三
看到问题了吗?每次 firstName
或 lastName
变化时,我们都需要记得手动去更新 fullName
。如果有很多地方依赖 fullName
,或者依赖关系更复杂,这种手动管理将成为一场噩梦。
计算属性 (Computed) 就是为了解决这个问题而生的。它允许你声明一个值,这个值是根据一个或多个其他响应式源(比如信号 (Signal))计算出来的。
你可以把计算属性想象成一个“智能菜谱”:
- 原料 (依赖项): 就像菜谱依赖于各种食材(比如
firstName
和lastName
信号)。 - 菜谱 (计算逻辑): 你提供一个函数,告诉计算属性如何根据原料计算出最终的“菜肴”(比如如何拼接姓和名得到全名)。
- 智能之处:
- 惰性求值 (Lazy Evaluation): 只有当你真正需要知道“菜肴的味道”(读取计算属性的值)时,它才会去计算。如果你只是准备好了原料但从没问过味道,它就不会浪费力气去做菜。
- 缓存 (Caching): 如果原料没有变化,你每次问“味道如何?”,它都会直接告诉你上次计算好的结果,而不会重新做一遍菜。只有当原料(依赖的信号)发生变化后,你再去问味道,它才会重新计算。
这样,计算属性就能自动、高效地保持其值的最新状态,而无需我们手动干预。
如何使用计算属性?
使用 computed
函数可以创建一个计算属性。你需要传递给它一个计算函数 (getter function),这个函数描述了如何根据依赖项计算出最终值。
1. 创建计算属性
让我们用 computed
来改造上面的全名示例:
import { signal, computed } from './src/index.js'; // 导入 computed 函数
const firstName = signal("王");
const lastName = signal("五");
// 创建一个计算属性 fullName
const fullName = computed(() => {
// 这个函数就是计算逻辑 (getter)
console.log("正在计算全名..."); // 加上日志方便观察
return firstName() + lastName();
});
console.log("计算属性已创建,但尚未读取。");
解释:
- 我们调用
computed
并传入一个箭头函数() => firstName() + lastName()
。 - 这个函数就是
fullName
的计算逻辑:它读取firstName
和lastName
信号的值,并将它们拼接起来返回。 computed
函数返回的fullName
也是一个函数,类似于signal
返回的函数,但它只能用来读取计算结果。你不能像signal
那样通过传参来设置计算属性的值。- 注意,仅仅创建
computed
并不会立即执行计算函数。此时控制台不会打印 "正在计算全名..."。这就是惰性求值。
2. 读取计算属性的值
要获取计算属性的值,调用它返回的那个函数,并且不带任何参数。
// (接上例)
// 第一次读取 fullName 的值
const currentFullName = fullName(); // 调用 fullName() 触发计算
console.log("第一次读取:", currentFullName);
// 第二次读取 fullName 的值
const anotherFullName = fullName(); // 再次调用 fullName()
console.log("第二次读取:", anotherFullName);
输出:
计算属性已创建,但尚未读取。
正在计算全名...
第一次读取: 王五
第二次读取: 王五
解释:
- 当我们第一次调用
fullName()
时,计算属性发现自己需要计算值(它是“惰性”的),于是执行了我们提供的计算函数() => firstName() + lastName()
。控制台打印 "正在计算全名...",并返回结果 "王五"。 - 当我们第二次调用
fullName()
时,计算属性发现它的依赖项 (firstName
和lastName
) 从上次计算到现在并没有改变。因此,它直接返回了缓存中的值 "王五",而没有再次执行计算函数(注意控制台没有再次打印 "正在计算全名...")。这就是缓存特性。
3. 依赖项变化时的自动更新
计算属性的真正魔力在于,当它的依赖项(那些在计算函数中被读取的信号)发生变化时,它会自动感知到,并在下次被读取时重新计算。
// (接上例)
console.log("准备更新 firstName...");
firstName("赵"); // 更新 firstName 信号的值
console.log("firstName 已更新,但 fullName 尚未被读取,所以没有重新计算。");
// 现在再次读取 fullName
const updatedFullName = fullName(); // 触发重新计算
console.log("更新后读取:", updatedFullName);
// 再次读取,依赖项未变,使用缓存
const yetAnotherFullName = fullName();
console.log("再次读取(缓存):", yetAnotherFullName);
输出:
// ...之前的输出...
准备更新 firstName...
firstName 已更新,但 fullName 尚未被读取,所以没有重新计算。
正在计算全名... // 注意:这次重新计算了!
更新后读取: 赵五
再次读取(缓存): 赵五
解释:
- 当我们执行
firstName("赵")
时,firstName
这个信号 (Signal) 的值改变了。master
的响应式系统内部会知道fullName
计算属性依赖于firstName
。因此,系统会将fullName
标记为“脏 (dirty)”状态,表示它缓存的值可能已经过时了。但此时计算不会立即发生。 - 直到我们下一次调用
fullName()
读取它的值时,计算属性检查到自己是“脏”状态,于是重新执行计算函数() => firstName() + lastName()
。这时firstName()
返回 "赵",lastName()
返回 "五",所以计算结果是 "赵五"。控制台打印 "正在计算全名..."。 - 计算完成后,
fullName
会缓存新的结果 "赵五",并清除“脏”标记。 - 之后再次调用
fullName()
时,由于依赖项没有再变,它又会从缓存中返回 "赵五",不再重新计算。
总结一下:
computed(getterFn)
返回一个函数 c
。
- 调用
c()
:读取计算属性的值。 - 如果依赖项自上次读取后没有变化,直接返回缓存的值。
- 如果依赖项变化了(计算属性被标记为“脏”),则执行
getterFn
重新计算,缓存新值,然后返回新值。
计算属性就像一个聪明的缓存层,它只在必要时才进行计算,确保了性能和数据的一致性。
计算属性的内部机制(初步了解)
理解计算属性如何在幕后工作,有助于我们更好地利用它。
创建时发生了什么? (computed
函数)
当你调用 computed(getterFn)
时:
- 内部会创建一个计算属性对象 (Computed Object)。这个对象需要存储:
getter
: 你传入的计算函数。currentValue
: 用来缓存上一次计算的结果,初始可以是undefined
。flags
: 一些状态标记,比如标记自己是不是计算属性 (SubscriberFlags.Computed
),以及是否“脏” (SubscriberFlags.Dirty
)。初始时通常标记为“脏”,因为还没有计算过。deps
: 一个列表(链表),用来记录这个计算属性依赖于哪些信号或计算属性(它的“原料”)。初始为空。subs
: 一个列表(链表),用来记录哪些其他计算属性或副作用依赖于这个计算属性(谁把它当作“原料”)。初始为空。
computed
函数会返回一个特殊的函数,我们称之为 getter 函数 (Computed Getter)。这个函数绑定 (bind) 了刚才创建的计算属性对象。
// src/index.ts (简化示意)
function computed<T>(getter: (previousValue?: T) => T): () => T {
// 1. 创建计算属性对象
const computedObject: Computed<T> = {
currentValue: undefined, // 初始无缓存值
subs: undefined, // 初始没有订阅者依赖它
subsTail: undefined,
deps: undefined, // 初始不知道依赖谁
depsTail: undefined,
flags: SubscriberFlags.Computed | SubscriberFlags.Dirty, // 标记为 Computed 和 Dirty
getter: getter, // 存储计算逻辑
};
// 2. 返回绑定了计算属性对象的 getter 函数
return computedGetter.bind(computedObject) as () => T;
}
读取时发生了什么? (computedGetter
函数)
当你调用 theComputed()
来读取值时,执行的是 computedGetter
函数:
- 检查状态: 函数首先检查计算属性对象的
flags
是否包含Dirty
(脏)或PendingComputed
(依赖的计算属性可能脏了,需要检查)标记。 - 如果“脏”或“待检查”:
- 调用响应式系统核心的
processComputedUpdate(this, flags)
函数来处理更新(this
指向计算属性对象)。 - 在
processComputedUpdate
内部(或者它调用的updateComputed
): - 开始追踪: 调用
startTracking(this)
。这会设置一个全局变量activeSub = this
,表示“接下来读取的任何信号,都要把this
(当前计算属性) 记录为它们的订阅者”。同时,它会准备清空旧的依赖列表 (deps
)。 - 执行 Getter: 调用用户提供的
this.getter()
函数。 - 依赖收集: 在
getter
函数执行期间,如果它读取了某个信号 (Signal)(比如firstName()
),信号的读取逻辑(我们在上一章讲过)会检测到activeSub
(即当前计算属性),并调用link(signalObject, this)
,将当前计算属性添加到信号的订阅者列表 (subs
) 中,同时也将信号添加到当前计算属性的依赖列表 (deps
) 中。这样就建立了双向链接。 - 结束追踪: 调用
endTracking(this)
。这会清除不再被读取的旧依赖,并将全局activeSub
恢复原状。 - 缓存结果: 比较新计算出的值和
this.currentValue
。如果不同,更新this.currentValue
,并标记更新成功。 - 清除标记: 清除
Dirty
和PendingComputed
标记。 - 传播变化 (如果值改变了): 如果计算结果确实改变了,并且有其他订阅者 (
this.subs
不为空) 依赖于这个计算属性,会调用propagate(this.subs)
来通知下游的订阅者它们也需要更新(将它们标记为Dirty
或PendingComputed
)。
- 开始追踪: 调用
- 调用响应式系统核心的
- 如果不“脏”: 直接跳过计算步骤。
- 建立上游依赖: 检查当前是否还有更高层的
activeSub
(比如这个计算属性是在另一个计算属性或副作用 (Effect) 中被读取的)。如果有,则调用link(this, activeSub)
,将这个计算属性本身也链接到那个更高层的订阅者上。 - 返回结果: 返回缓存的
this.currentValue
。
// src/index.ts (简化示意 - computedGetter)
function computedGetter<T>(this: Computed<T>): T {
const flags = this.flags;
// 1. 检查是否需要更新
if (flags & (SubscriberFlags.Dirty | SubscriberFlags.PendingComputed)) {
// 2. 如果需要,调用核心更新逻辑
processComputedUpdate(this, flags); // 内部会调用 getter, 追踪依赖, 更新缓存, 传播变化
}
// 4. 如果当前在其他响应式上下文中,建立链接
if (activeSub !== undefined) {
link(this, activeSub);
}
// 5. 返回缓存的值
return this.currentValue!;
}
// src/index.ts (简化示意 - updateComputed,由 processComputedUpdate 调用)
// (这是传递给 createReactiveSystem 的选项)
function updateComputed(computed: Computed): boolean {
const prevSub = activeSub;
activeSub = computed; // 标记当前计算属性为活动订阅者
startTracking(computed); // 准备追踪依赖,清空旧依赖
try {
const oldValue = computed.currentValue;
// 执行用户提供的 getter 函数,期间读取的信号会自动 link 到 computed
const newValue = computed.getter(oldValue);
if (oldValue !== newValue) {
computed.currentValue = newValue; // 更新缓存
return true; // 返回值改变了
}
return false; // 返回值没变
} finally {
activeSub = prevSub; // 恢复之前的活动订阅者
endTracking(computed); // 清理未使用的依赖链接
}
}
依赖项变化时发生了什么?
当计算属性依赖的某个信号 (Signal)(比如 firstName
)的值发生变化时:
- 信号的写入逻辑 (
signalGetterSetter
带参数调用) 会执行。 - 它会检查自己的订阅者列表 (
subs
)。因为在fullName
第一次计算时,firstName
通过link
函数把fullName
加入了自己的subs
列表。 - 信号会调用
propagate(this.subs)
来通知它的所有订阅者。 propagate
函数会遍历订阅者列表,找到fullName
这个计算属性对象。- 它会给
fullName
对象添加Dirty
标记 (flags |= SubscriberFlags.Dirty
)。如果fullName
又被其他计算属性或副作用依赖,propagate
还会继续递归地通知下去(可能会标记为PendingComputed
或PendingEffect
)。
这样,当依赖项变化时,计算属性就被标记为“脏”,但实际的重新计算被推迟到下一次读取时进行。
简单流程图
让我们用一个图来梳理一下计算属性首次读取和依赖更新的流程:
关键点:
- 计算属性是惰性的,只在被读取且“脏”时才计算。
- 计算过程中会自动收集依赖 (依赖追踪与链接 (Tracking & Linking))。
- 结果会被缓存,依赖不变时直接返回缓存。
- 依赖项变化时,计算属性会被标记为“脏”,由响应式系统核心 (Reactive System Core) 通过
propagate
机制通知。
总结
在本章中,我们深入探讨了计算属性 (Computed):
- 计算属性用于创建派生值,其结果依赖于一个或多个其他响应式源(如信号 (Signal))。
- 使用
computed(getterFn)
创建计算属性,它返回一个只读的 getter 函数。 - 计算属性具有惰性求值和缓存特性,只有在其依赖项变化且其值被请求时才会重新计算,否则返回缓存结果。
- 这使得我们能够以声明式的方式定义数据之间的依赖关系,系统会自动、高效地维护数据一致性。
- 内部机制依赖于依赖追踪与链接 (Tracking & Linking) 和响应式系统核心 (Reactive System Core) 的状态标记(如
Dirty
)和传播机制 (propagate
)。
计算属性是构建复杂响应式应用的重要工具,它让处理衍生数据变得简单而高效。
下一步
我们现在知道了如何用信号存储基本状态,以及如何用计算属性派生出新的状态。但是,响应式系统的最终目的通常是让数据变化能够驱动某些行为,比如更新用户界面、向服务器发送请求、或者打印日志等。这些由响应式数据变化触发的“动作”或“行为”,就是我们下一章要学习的概念。