2025.09.20

computed:缺陷修复与惰性求值实现

前文我们虽然初步实现了 computed 的双重角色,但在实际应用中,它暴露了两个核心缺陷:冗余执行 和 不必要的更新传播 ,导致其性能远低于官方实现。

前文我们虽然初步实现了 computed 的双重角色,但在实际应用中,它暴露了两个核心缺陷:冗余执行不必要的更新传播 ,导致其性能远低于官方实现。

🚨 缺陷一:计算函数的冗余执行


<script type="module">
  // ...
  const count = ref(0)

  const c = computed(() => {
    console.count('computed') // 实际执行了 3 次!
    return count.value + 1
  })

  effect(() => {
    console.log('state.a ==> ', c.value)
  })

  setTimeout(() => {
    count.value = 1
  })
</script>

官方 Vue 实现中,computed 仅会执行两次:

  1. 初始化: effect 首次执行时访问 c.value -> computed执行
  2. 依赖变更: count.value = 1 触发 computed 重新执行。

我们的实现中多出的第 3 次执行,正是因为 缺乏缓存

解决方式: 引入 dirty 脏检查

  1. 状态定义: 在 ComputedRefImpl 中新增 dirty = true
  2. 惰性检查: 在 get value() 访问器中,仅当 dirtytrue 时才调用 update() 重新计算。
  3. 更新后缓存: update() 执行完毕后,立即设置 this.dirty = false
// ComputedRefImpl.ts 核心逻辑
export class ComputedRefImpl implements ReactiveNode {
  dirty = true // 标记是否需要重新计算

  get value() {
    if (this.dirty) { // 检查脏标记
      this.update()
    }
    // ... 依赖收集逻辑 ...
    return this._value
  }

  update() {
    // ... 收集依赖的 setup 逻辑 ...
    try {
      // 重新执行 getter 函数
      this._value = this.fn()
      this.dirty = false // 缓存新值,标记为 clean
      // ...
    }
    finally {
      // ...
    }
  }
}

⚠️ 缺陷二:无订阅者的 computed 不应执行


<script type="module">
  // ...
  const c = computed(() => {
    console.count('computed') // 不应该在 setTimeout 中执行
    return count.value + 1
  })

  // effect(...) <-- 这一部分被注释掉

  setTimeout(() => {
    count.value = 1 // 此时 c 应该只标记为 dirty,不执行 getter
  })
</script>

count.value = 1 触发 propagate 时,computed (c) 被找到。由于我们之前的 propagate 逻辑如下:

  1. 找到 c,发现它是 computed
  2. 调用 processComputedUpdate(c)
  3. processComputedUpdate 调用 c.update() -> 导致 console.count('computed') 立即执行。

解决方式: 在 propagate 阶段遇到 computed 节点时,新增一个对 该节点自身订阅者 (sub.subs) 的检查。

  1. 统一标记: 无论如何,先将 computed 节点标记为 dirty = true
  2. 条件判断: 只有在 sub.subs 链表不为空(即有 effect 或其他 computed 依赖它)时,才调用 processComputedUpdate 立即更新并向下游传播。
// system.ts
function processComputedUpdate(sub: ComputedRefImpl) {
  if (sub.subs) {
    sub.update()
    propagate(sub.subs)
  }
}

export function propagate(subs: Link): void {
  let linkNode = subs
  const queuedEffect = []
  while (linkNode) {
    const sub = linkNode.sub
    if (!sub.tracking && !sub.dirty) {
      if ('update' in sub) {
        sub.dirty = true
        processComputedUpdate(sub as ComputedRefImpl)
      }
      else {
        queuedEffect.push(sub)
      }
    }

    linkNode = linkNode.nextSub
  }
  queuedEffect.forEach(effect => effect.notify())
}

🎯 陷阱三:计算结果不变时的冗余 effect 执行

即使上游依赖(count)发生变化,如果 computed 的返回值没有改变(例如 count.value * 0),下游的 effect 也不应该被触发执行。然而,当前的实现中 effect 仍会被执行两次。


<script type="module">
  // ...
  const count = ref(0)
  const c = computed(() => {
    console.count('computed');
    return count.value * 0
  })
  effect(() => {
    console.count('effect');
    console.log('state.a ==> ', count.value);
    count.value
  })
  setTimeout(() => {
    count.value = 1
  }) // effect 仍执行 2 次
</script>

解决方案: 通过在 ComputedRefImpl.update() 中返回 hasChanged 的结果,并在 processComputedUpdate 中利用该结果来决定是否调用 propagate

// computed.ts (ComputedRefImpl 内部逻辑)
class ComputedRefImpl implements ReactiveNode {
  update() {
    try {
      const oldValue = this._value
      this._value = this.fn()
      // 核心:返回新旧值是否发生变化
      return hasChanged(this._value, oldValue)
    }
    finally {
      // ... 清理逻辑
    }
  }
}

// system.ts (processComputedUpdate 逻辑)
function processComputedUpdate(sub: ComputedRefImpl) {
  /**
   * 1. 调用 update() 重新计算,并获取返回值 (changed: boolean)
   * 2. 仅在 subs 链表存在 且 值发生变化时,才向下游传播
   */
  if (sub.subs && sub.update()) {
    propagate(sub.subs)
  }
}

💥 陷阱四:重复依赖收集导致的冗余 effect 执行

在同一个 effect 函数体内,如果多次访问同一个 ref(如 count.value 访问 5 次),由于重复收集了依赖,当 count 变化时, effect 可能会被冗余通知多次执行。


<script type="module">
  // ...
  effect(() => {
    count.value // 重复访问 5 次
  })
  setTimeout(() => {
    count.value = 1
  }) // effect 触发 3 次
</script>

解决方式:

  1. 源码里是在 link 函数中, 建立 Dep-Sub 关联之前,遍历 effect (Sub) 的依赖链表 (deps),确认是否已存在对该 ref (Dep) 的引用。
    export function link(dep: ReactiveNode, sub: ReactiveNode): void {
      const currentDep = sub.depsTail
      const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
      if (nextDep && nextDep.dep === dep) {
        sub.depsTail = nextDep
        return
      }
    
      /**
       * 如果之前创建过依赖关系, 直接返回, 避免依赖收集
       */
      let existingLink = sub.deps
      while (existingLink) {
        if (existingLink.dep === dep) {
          return
        }
        existingLink = existingLink.nextDep
      }
    }
    
  2. 优化脏检查的逻辑 换种思路 -> 不管是否是重复创建依赖, 而是确保 effect 在一次更新周期内只入队执行一次
// effect.ts
export class ReactiveEffect {
  // ... (其他属性)
  dirty = false // 统一调度锁:true 表示已被处理/已入队
}

// system.ts
export function propagate(subs: Link): void {
  // ...
  while (linkNode) {
    const sub = linkNode.sub
    if (!sub.tracking && !sub.dirty) {
      sub.dirty = true
      if ('update' in sub) {
        // computed 的处理
        processComputedUpdate(sub as ComputedRefImpl)
      }
      else {
        queuedEffect.push(sub)
      }
    }

    linkNode = linkNode.nextSub
  }
// ...
}

export function endTrack(sub) {
  sub.tracking = false
  const depsTail = sub.depsTail
  sub.dirty = false
}