计算属性(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
信号)。这样,计算属性就能自动、高效地保持其值的最新状态,而无需我们手动干预。
使用 computed
函数可以创建一个计算属性。你需要传递给它一个计算函数 (getter function),这个函数描述了如何根据依赖项计算出最终值。
让我们用 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
并不会立即执行计算函数。此时控制台不会打印 "正在计算全名..."。这就是惰性求值。要获取计算属性的值,调用它返回的那个函数,并且不带任何参数。
// (接上例)
// 第一次读取 fullName 的值
const currentFullName = fullName(); // 调用 fullName() 触发计算
console.log("第一次读取:", currentFullName);
// 第二次读取 fullName 的值
const anotherFullName = fullName(); // 再次调用 fullName()
console.log("第二次读取:", anotherFullName);
输出:
计算属性已创建,但尚未读取。
正在计算全名...
第一次读取: 王五
第二次读取: 王五
解释:
fullName()
时,计算属性发现自己需要计算值(它是“惰性”的),于是执行了我们提供的计算函数 () => firstName() + lastName()
。控制台打印 "正在计算全名...",并返回结果 "王五"。fullName()
时,计算属性发现它的依赖项 (firstName
和 lastName
) 从上次计算到现在并没有改变。因此,它直接返回了缓存中的值 "王五",而没有再次执行计算函数(注意控制台没有再次打印 "正在计算全名...")。这就是缓存特性。计算属性的真正魔力在于,当它的依赖项(那些在计算函数中被读取的信号)发生变化时,它会自动感知到,并在下次被读取时重新计算。
// (接上例)
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)
时:
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
)。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
)。这样,当依赖项变化时,计算属性就被标记为“脏”,但实际的重新计算被推迟到下一次读取时进行。
让我们用一个图来梳理一下计算属性首次读取和依赖更新的流程:
关键点:
propagate
机制通知。在本章中,我们深入探讨了计算属性 (Computed):
computed(getterFn)
创建计算属性,它返回一个只读的 getter 函数。Dirty
)和传播机制 (propagate
)。计算属性是构建复杂响应式应用的重要工具,它让处理衍生数据变得简单而高效。
我们现在知道了如何用信号存储基本状态,以及如何用计算属性派生出新的状态。但是,响应式系统的最终目的通常是让数据变化能够驱动某些行为,比如更新用户界面、向服务器发送请求、或者打印日志等。这些由响应式数据变化触发的“动作”或“行为”,就是我们下一章要学习的概念。