Alien Signals 技术分析之计算属性 (Computed)(二)

11 分钟
grok-3-mini-fast-beta Logo

AI 摘要 (由 grok-3-mini-fast-beta 生成)

计算属性(Computed)解决响应式系统中手动管理派生值的复杂性问题。通过computed函数基于信号自动计算派生值,实现惰性求值和缓存,确保依赖变化时高效更新,提升数据一致性和性能。

4.09s
~15590 tokens

在上一章 信号 (Signal) 中,我们学习了如何创建和使用响应式系统中最基础的数据单元——信号,来存储和更新单个值。但是,通常我们的应用中会有一些值是依赖于其他值计算得出的。例如,用户的全名可能由姓氏和名字组合而成,或者购物车里的总价是根据商品单价和数量计算出来的。如果我们手动管理这些派生值的更新,代码很快就会变得复杂且容易出错。

这时,计算属性 (Computed) 就派上用场了!

什么是计算属性?为什么需要它?

想象一下,我们正在开发一个简单的用户界面,需要显示用户的全名。我们有两个独立的信号 (Signal):一个存储姓氏 (lastName),一个存储名字 (firstName)。

typescript
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); // 输出: 更新后的全名: 李三

看到问题了吗?每次 firstNamelastName 变化时,我们都需要记得手动去更新 fullName。如果有很多地方依赖 fullName,或者依赖关系更复杂,这种手动管理将成为一场噩梦。

计算属性 (Computed) 就是为了解决这个问题而生的。它允许你声明一个值,这个值是根据一个或多个其他响应式源(比如信号 (Signal))计算出来的。

你可以把计算属性想象成一个“智能菜谱”:

  • 原料 (依赖项): 就像菜谱依赖于各种食材(比如 firstNamelastName 信号)。
  • 菜谱 (计算逻辑): 你提供一个函数,告诉计算属性如何根据原料计算出最终的“菜肴”(比如如何拼接姓和名得到全名)。
  • 智能之处:
    • 惰性求值 (Lazy Evaluation): 只有当你真正需要知道“菜肴的味道”(读取计算属性的值)时,它才会去计算。如果你只是准备好了原料但从没问过味道,它就不会浪费力气去做菜。
    • 缓存 (Caching): 如果原料没有变化,你每次问“味道如何?”,它都会直接告诉你上次计算好的结果,而不会重新做一遍菜。只有当原料(依赖的信号)发生变化后,你再去问味道,它才会重新计算。

这样,计算属性就能自动、高效地保持其值的最新状态,而无需我们手动干预。

如何使用计算属性?

使用 computed 函数可以创建一个计算属性。你需要传递给它一个计算函数 (getter function),这个函数描述了如何根据依赖项计算出最终值。

1. 创建计算属性

让我们用 computed 来改造上面的全名示例:

typescript
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 的计算逻辑:它读取 firstNamelastName 信号的值,并将它们拼接起来返回。
  • computed 函数返回的 fullName 也是一个函数,类似于 signal 返回的函数,但它只能用来读取计算结果。你不能像 signal 那样通过传参来设置计算属性的值。
  • 注意,仅仅创建 computed 并不会立即执行计算函数。此时控制台不会打印 "正在计算全名..."。这就是惰性求值

2. 读取计算属性的值

要获取计算属性的值,调用它返回的那个函数,并且不带任何参数

(接上例)
typescript
// (接上例)

// 第一次读取 fullName 的值
const currentFullName = fullName(); // 调用 fullName() 触发计算
console.log("第一次读取:", currentFullName);

// 第二次读取 fullName 的值
const anotherFullName = fullName(); // 再次调用 fullName()
console.log("第二次读取:", anotherFullName);

输出:

计算属性已创建,但尚未读取。
正在计算全名...
第一次读取: 王五
第二次读取: 王五

解释:

  • 当我们第一次调用 fullName() 时,计算属性发现自己需要计算值(它是“惰性”的),于是执行了我们提供的计算函数 () => firstName() + lastName()。控制台打印 "正在计算全名...",并返回结果 "王五"。
  • 当我们第二次调用 fullName() 时,计算属性发现它的依赖项 (firstNamelastName) 从上次计算到现在并没有改变。因此,它直接返回了缓存中的值 "王五",而没有再次执行计算函数(注意控制台没有再次打印 "正在计算全名...")。这就是缓存特性。

3. 依赖项变化时的自动更新

计算属性的真正魔力在于,当它的依赖项(那些在计算函数中被读取的信号)发生变化时,它会自动感知到,并在下次被读取时重新计算。

(接上例)
typescript
// (接上例)

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) 时:

  1. 内部会创建一个计算属性对象 (Computed Object)。这个对象需要存储:
    • getter: 你传入的计算函数。
    • currentValue: 用来缓存上一次计算的结果,初始可以是 undefined
    • flags: 一些状态标记,比如标记自己是不是计算属性 (SubscriberFlags.Computed),以及是否“脏” (SubscriberFlags.Dirty)。初始时通常标记为“脏”,因为还没有计算过。
    • deps: 一个列表(链表),用来记录这个计算属性依赖于哪些信号或计算属性(它的“原料”)。初始为空。
    • subs: 一个列表(链表),用来记录哪些其他计算属性或副作用依赖于这个计算属性(谁把它当作“原料”)。初始为空。
  2. computed 函数会返回一个特殊的函数,我们称之为 getter 函数 (Computed Getter)。这个函数绑定 (bind) 了刚才创建的计算属性对象。
src/index.ts (简化示意)
typescript
// 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 函数:

  1. 检查状态: 函数首先检查计算属性对象的 flags 是否包含 Dirty(脏)或 PendingComputed(依赖的计算属性可能脏了,需要检查)标记。
  2. 如果“脏”或“待检查”:
    • 调用响应式系统核心的 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,并标记更新成功。
      • 清除标记: 清除 DirtyPendingComputed 标记。
      • 传播变化 (如果值改变了): 如果计算结果确实改变了,并且有其他订阅者 (this.subs 不为空) 依赖于这个计算属性,会调用 propagate(this.subs) 来通知下游的订阅者它们也需要更新(将它们标记为 DirtyPendingComputed)。
  3. 如果不“脏”: 直接跳过计算步骤。
  4. 建立上游依赖: 检查当前是否还有更高层的 activeSub(比如这个计算属性是在另一个计算属性或副作用 (Effect) 中被读取的)。如果有,则调用 link(this, activeSub),将这个计算属性本身也链接到那个更高层的订阅者上。
  5. 返回结果: 返回缓存的 this.currentValue
src/index.ts (简化示意 - computedGetter)
typescript
// 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)的值发生变化时:

  1. 信号的写入逻辑 (signalGetterSetter 带参数调用) 会执行。
  2. 它会检查自己的订阅者列表 (subs)。因为在 fullName 第一次计算时,firstName 通过 link 函数把 fullName 加入了自己的 subs 列表。
  3. 信号会调用 propagate(this.subs) 来通知它的所有订阅者。
  4. propagate 函数会遍历订阅者列表,找到 fullName 这个计算属性对象。
  5. 它会给 fullName 对象添加 Dirty 标记 (flags |= SubscriberFlags.Dirty)。如果 fullName 又被其他计算属性或副作用依赖,propagate 还会继续递归地通知下去(可能会标记为 PendingComputedPendingEffect)。

这样,当依赖项变化时,计算属性就被标记为“脏”,但实际的重新计算被推迟到下一次读取时进行。

简单流程图

让我们用一个图来梳理一下计算属性首次读取和依赖更新的流程:

关键点:

  • 计算属性是惰性的,只在被读取且“脏”时才计算。
  • 计算过程中会自动收集依赖 (依赖追踪与链接 (Tracking & Linking))。
  • 结果会被缓存,依赖不变时直接返回缓存。
  • 依赖项变化时,计算属性会被标记为“脏”,由响应式系统核心 (Reactive System Core) 通过 propagate 机制通知。

总结

在本章中,我们深入探讨了计算属性 (Computed)

  • 计算属性用于创建派生值,其结果依赖于一个或多个其他响应式源(如信号 (Signal))。
  • 使用 computed(getterFn) 创建计算属性,它返回一个只读的 getter 函数。
  • 计算属性具有惰性求值缓存特性,只有在其依赖项变化且其值被请求时才会重新计算,否则返回缓存结果。
  • 这使得我们能够以声明式的方式定义数据之间的依赖关系,系统会自动、高效地维护数据一致性。
  • 内部机制依赖于依赖追踪与链接 (Tracking & Linking) 和响应式系统核心 (Reactive System Core) 的状态标记(如 Dirty)和传播机制 (propagate)。

计算属性是构建复杂响应式应用的重要工具,它让处理衍生数据变得简单而高效。

下一步

我们现在知道了如何用信号存储基本状态,以及如何用计算属性派生出新的状态。但是,响应式系统的最终目的通常是让数据变化能够驱动某些行为,比如更新用户界面、向服务器发送请求、或者打印日志等。这些由响应式数据变化触发的“动作”或“行为”,就是我们下一章要学习的概念。