计算属性(Computed)解决响应式系统中手动管理派生值的复杂性问题。通过computed函数基于信号自动计算派生值,实现惰性求值和缓存,确保依赖变化时高效更新,提升数据一致性和性能。
在上一章 信号 (Signal) 中,我们学习了如何创建和使用响应式系统中最基础的数据单元——信号,来存储和更新单个值。但是,通常我们的应用中会有一些值是依赖于其他值计算得出的。例如,用户的全名可能由姓氏和名字组合而成,或者购物车里的总价是根据商品单价和数量计算出来的。如果我们手动管理这些派生值的更新,代码很快就会变得复杂且容易出错。
这时,计算属性 (Computed) 就派上用场了!
想象一下,我们正在开发一个简单的用户界面,需要显示用户的全名。我们有两个独立的信号 (Signal):一个存储姓氏 (lastName),一个存储名字 (firstName)。
看到问题了吗?每次 firstName 或 lastName 变化时,我们都需要记得手动去更新 fullName。如果有很多地方依赖 fullName,或者依赖关系更复杂,这种手动管理将成为一场噩梦。
计算属性 (Computed) 就是为了解决这个问题而生的。它允许你声明一个值,这个值是根据一个或多个其他响应式源(比如信号 (Signal))计算出来的。
你可以把计算属性想象成一个“智能菜谱”:
firstName 和 lastName 信号)。这样,计算属性就能自动、高效地保持其值的最新状态,而无需我们手动干预。
使用 computed 函数可以创建一个计算属性。你需要传递给它一个计算函数 (getter function),这个函数描述了如何根据依赖项计算出最终值。
让我们用 computed 来改造上面的全名示例:
解释:
computed 并传入一个箭头函数 () => firstName() + lastName()。fullName 的计算逻辑:它读取 firstName 和 lastName 信号的值,并将它们拼接起来返回。computed 函数返回的 fullName 也是一个函数,类似于 signal 返回的函数,但它只能用来读取计算结果。你不能像 signal 那样通过传参来设置计算属性的值。computed 并不会立即执行计算函数。此时控制台不会打印 "正在计算全名..."。这就是惰性求值。要获取计算属性的值,调用它返回的那个函数,并且不带任何参数。
输出:
计算属性已创建,但尚未读取。
正在计算全名...
第一次读取: 王五
第二次读取: 王五
解释:
fullName() 时,计算属性发现自己需要计算值(它是“惰性”的),于是执行了我们提供的计算函数 () => firstName() + lastName()。控制台打印 "正在计算全名...",并返回结果 "王五"。fullName() 时,计算属性发现它的依赖项 (firstName 和 lastName) 从上次计算到现在并没有改变。因此,它直接返回了缓存中的值 "王五",而没有再次执行计算函数(注意控制台没有再次打印 "正在计算全名...")。这就是缓存特性。计算属性的真正魔力在于,当它的依赖项(那些在计算函数中被读取的信号)发生变化时,它会自动感知到,并在下次被读取时重新计算。
输出:
// ...之前的输出...
准备更新 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) 了刚才创建的计算属性对象。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。当计算属性依赖的某个信号 (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)。计算属性是构建复杂响应式应用的重要工具,它让处理衍生数据变得简单而高效。
我们现在知道了如何用信号存储基本状态,以及如何用计算属性派生出新的状态。但是,响应式系统的最终目的通常是让数据变化能够驱动某些行为,比如更新用户界面、向服务器发送请求、或者打印日志等。这些由响应式数据变化触发的“动作”或“行为”,就是我们下一章要学习的概念。