从零到一打造 Vue3 响应式系统 Day 22 - Computed:缓存机制实现

在上一篇文章中,我们提到将通过「缓存」机制来解决 computed 在访问时重复执行的问题。

在 Vue 3 的源码里,computed 是靠一个「脏值标记(dirty flag)」来判断是否需要重新计算的。

Computed 缓存解决方案

核心逻辑

在 computed 中记录脏标记:当脏标记为 true,才需要进行更新;当脏标记为 false,则表示可以走缓存。

jsx 复制代码
class ComputedRefImpl implements Dependency, Sub {
  ...
  ...
  tracking = false

  // 计算属性是否需要重新计算;为 true 时重新计算
  dirty = true

  ...
  ...
  get value() {
    if (this.dirty) {
      this.update()
    }
    ...
    ...
  }

  update() {
    ...
    ...
    try {
      this._value = this.fn()
      // update 执行完成后,将 dirty 改为 false,表示已缓存
      this.dirty = false
    } finally {
      endTrack(this)
      setActiveSub(prevSub)
    }
  }
}

回到示例,现在已经有了缓存,只执行两次。但我们又发现另一个问题:如果你把 index.html 设置为以下内容:

xml 复制代码
<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { ref, computed, effect } from '../dist/reactivity.esm.js'

    const count = ref(0)

    const c = computed(() => {
      console.log('computed')
      return count.value + 1
    })

    // effect(() => {
    //   console.log(c.value)
    // })

    console.log(c.value)
    count.value = 1

  </script>
</body>

你会发现 count.value 数值变更之后,它还是访问了 computed;但当依赖 computed 的值被变更时,我们不一定会当场访问 computed

看一下官方实现,count.value 数值变更后,如果没有访问 computedcomputed 并不会立刻求值。

而我们的版本会再访问一次 computed

遇到这种情况怎么处理?可以仅做脏标记,等下次 computed 被 effect 访问时再执行更新

perl 复制代码
// system.ts
...
...
export function processComputedUpdate(sub) {
  // 只有存在 sub.subs(effect 链表的头节点)时,才进行更新与传播
  if (sub.subs) {
    sub.update()
    propagate(sub.subs)
  }
}

export function propagate(subs) {
  let link = subs
  const queuedEffect = []

  while (link) {
    const sub = link.sub

    if (!sub.tracking) {
      if ('update' in sub) {
        // 被 effect 再次访问,计算属性需要重新计算:改脏标记
        sub.dirty = true
        processComputedUpdate(sub)
      } else {
        queuedEffect.push(sub)
      }
    }
    link = link.nextSub
  }

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

这样即可解决缓存时机的问题。但我们又发现了新的问题。

Effect 重复执行问题

xml 复制代码
<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { ref, computed, effect } from '../dist/reactivity.esm.js'

    const count = ref(0)

    const c = computed(() => {
      console.log('computed')
      return count.value * 0
    })

    effect(() => {
      console.log(c.value)
    })

    setTimeout(() => {
      count.value = 1
    }, 1000)

  </script>
</body>

现在我们看到,computed 执行了两次,这没问题;但 effect 的输出值没有变化,它却也执行了两次。如果数值没变,应只执行一次。

回顾我们在 Ref 的实现:触发更新时,只有新旧值不相同时才会继续通知订阅者。在这里也用同样做法。

kotlin 复制代码
// computed.ts
import { hasChanged } from '@vue/shared'
...
...
class ComputedRefImpl implements Dependency, Sub {
  ...
  ...
  update() {
    ...
    ...
    try {
      // 更新前的值
      const oldValue = this._value
      // 计算后的新值
      this._value = this.fn()
      this.dirty = false
      // 只有当新旧值不同才返回 true
      return hasChanged(oldValue, this._value)
    } finally {
      endTrack(this)
      setActiveSub(prevSub)
    }
  }
}

保存更新前的值,用 hasChanged 判断数值是否改变,并用 update 的返回值来控制传播:

vbscript 复制代码
// system.ts
export function processComputedUpdate(sub) {
  // update 返回 true 表示数值发生变化,才继续向下触发 effect
  if (sub.subs && sub.update()) {
    propagate(sub.subs)
  }
}

得到期望结果:computed 执行两次、effect 仅执行一次。

看起来问题解决了,但这其实只是片面的 。因为当 effect 在一次运行中多次访问相同依赖 时,仍会重复触发。

Effect 访问相同依赖重复触发问题

xml 复制代码
<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { ref, computed, effect } from '../dist/reactivity.esm.js'

    const count = ref(0)

    effect(() => {
      console.count('effect')
      console.log(count.value)
      count.value
    })

    setTimeout(() => {
      count.value = 1
    }, 1000)

  </script>
</body>

可以看到触发了三次。如果你查看 count

javascript 复制代码
effect(() => {
  console.count('effect')
  console.log(count.value)
  count.value
})
console.log(count)

会发现它把同一个依赖收集了两次。如何解决?

在源码中,link 函数里,每次建立关联之前都会遍历链表,确认是否已经建立过关联。

perl 复制代码
export function link(dep, sub) {
  /**
   * 复用节点
   * sub.depsTail 为 undefined 且 sub.deps 存在时,尝试复用
   */
  const currentDep = sub.depsTail
  const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep
  // 如果 nextDep.dep 等于当前要收集的 dep,则只移动指针
  if (nextDep && nextDep.dep === dep) {
    sub.depsTail = nextDep  // 移动指针
    return
  }

  /**
   * 若 dep 与 sub 之间已建立过关联,则直接返回,避免重复收集
   */
  let existingLink = sub.deps
  while (existingLink) {
    if (existingLink.dep === dep) {
      // 已经建立过关联,直接返回
      return
    }
    existingLink = existingLink.nextDep
  }

  ...
  ...
}

要点:

  • 先尝试"复用节点",做"是否已建立过依赖"的遍历检查;
  • 如果把"已建立依赖检查"放在前面直接 return,可能导致 depsTail 长期保持 undefined,从而被错误清理。

方法二:重构脏标记(推荐更简洁)

换一种更简单的思路:不去管是否重复建立依赖 ,而是确保 effect 在一次更新周期内只入队执行一次。做法是对 effect 加统一的脏标记控制。

arduino 复制代码
// effect.ts
export class ReactiveEffect {
  ...
  ...
  dirty = true // 是否需要重新计算(用于控制入队)
  ...
}

在触发更新与结束追踪时,加入脏标记逻辑:

perl 复制代码
// system.ts
export function propagate(subs) {
  ...
  ...
  // 仅当不在执行中,且目前是"干净状态"(dirty=false)时,才入队
  if (!sub.tracking && !sub.dirty) {
    // 入队前先设置为"脏"(避免同一轮事件循环被重复入队)
    sub.dirty = true
    if ('update' in sub) {
      processComputedUpdate(sub)
    } else {
      queuedEffect.push(sub)
    }
  }
  ...
  ...
}

export function endTrack(sub) {
  sub.tracking = false // 执行结束,取消执行中标记
  const depsTail = sub.depsTail
  sub.dirty = false   // 本次 fn 执行完毕,复位为"干净"
  ...
  ...
}

这样,如果多个依赖同时触发同一个 effect ,它也只会被加入队列一次:因为一旦 dirty 被设为 true,后续 !sub.dirty 就为 false,不会再次入队。endTrack 中把 dirty 复位为 false,表示该 effect 已经完成了本轮的最新计算。

同时,删除 computed.ts 中对脏标记的重复初始化,避免两边打架:

kotlin 复制代码
// computed.ts
..
..
update() {
  ...
  ...
  try {
    const oldValue = this._value
    this._value = this.fn()
    this.dirty = false // 移除重复的初始化逻辑,统一交给 endTrack 处理
    return hasChanged(oldValue, this._value)
  } finally {
    endTrack(this)
    setActiveSub(prevSub)
  }
}
..
..

脏标记的判断与运行流程

  1. 初始化 :一个 effect 在执行完毕后,dirty 会被设为 false,表示「当前是最新状态,不需要再次执行」。
  2. 触发更新 :当依赖变更时,propagate 会检查该 effect 是否为 dirty: false
  3. 入队前 :只有当 dirtyfalse 时,才会将其先置为 true ,然后再把 effect 加入待执行队列。
  4. 防重复 :由于入队前已设为 true,同一事件循环中即便有多个依赖触发,也只会入队一次,避免不必要的重复执行。

想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

相关推荐
xiaoyan20152 小时前
2025最新款Electron38+Vite7+Vue3+ElementPlus电脑端后台系统Exe
前端·vue.js·electron
梅孔立2 小时前
本地多版本 Node.js 切换指南:解决 Vue nodejs 等项目版本冲突问题
前端·vue.js·node.js
爱泡脚的鸡腿3 小时前
VUE移动端项目跟练2(简洁易懂)
前端·javascript·vue.js
小狮子安度因3 小时前
FFmpeg-vflip滤镜使用
vue.js·ffmpeg·myeclipse
古夕3 小时前
技术复盘文档:解决 `watchEffect` 导致的图片闪烁无限循环问题
前端·javascript·vue.js
拾缘3 小时前
esm和cmj混用报错分析
前端·javascript
古夕3 小时前
技术复盘文档:`resourceLogoUrl` 数据丢失问题分析与最终解决方案
前端·javascript·vue.js
高热度网3 小时前
从 Vercel 构建失败谈 Git 大小写敏感性问题:一个容易被忽视的跨平台陷阱
前端·javascript
青衫旧故3 小时前
Uniapp Vue2 Vue3常量保存及调用
前端·javascript·vue.js·uni-app