Vue 3.5 双向链表 Computed 篇

761 阅读4分钟

effect 和 computed 是响应式系统两大核心功能,之前文章分析了双向链表架构下的 effect 是如何运作的,这篇文章主要是分析 computed 的流程,computed 是比较特殊的,既是 dep,又是 sub。比如这个 computed1,对于 obj.a 来说,它是 sub,而对于 computed2 来说,它是 dep

const computed1 = computed(() => {
  console.log("computed 1")
  return obj.a * 0
})
const computed2 = computed(() => {
  console.log("computed 2")
  return computed1.value + obj.c
})

依赖追踪整体结构

调试代码

import { reactive, effect, computed } from '@vue/reactivity'

const obj = reactive({
  a: 1,
  b: 2,
  c: 3,
  d: 4
})
const computed1 = computed(() => {
  console.log("computed 1")
  return obj.a * 0
})
const computed2 = computed(() => {
  console.log("computed 2")
  return computed1.value + obj.c
})

effect(() => {
  console.log("effect 1")
  computed2.value
})

debugger
obj.a++

依赖追踪结构图

image.png

具体分析

  1. obj.a++ 触发更新,根据依赖关系找到对应的 computed1 effect ,这个 computed1 又是一个 dep,订阅者是 computed2 effect,所以暂时认为这个 computed1 dep 也更新了,那就要通知 computed2 effect ,同样,computed2 dep 通知 effect1 ,由于 batch() 逻辑,通知这些 effect 的后,并不会马上执行,而是把这些 effect 函数串成一个链,链表方向和通知方向刚好是相反的
graph LR
Node1[effect1] -->|nex| Node2[computed2 effect]
Node2[computed2 effect] -->|nex| Node3[computed1 effect]
  1. 这种 effect 执行顺序,有效避免了 computed 的无效计算,毕竟 computed 可能缓存了一些昂贵的计算结果。

    1. effect 执行的时候,调用的是 runIfDirty 函数,确认 isDirty 才会真正执行

      function isDirty(sub) {
        for (let link = sub.deps; link; link = link.nextDep) {
          if (link.dep.version !== link.version || link.dep.computed && (refreshComputed(link.dep.computed) || link.dep.version !== link.version)) {
            return true;
          }
        }
        if (sub._dirty) {
          return true;
        }
        return false;
      }
      
    2. isDirty 里面,如果 dep(这里的 dep 就是 computed2) 是一个 computed 类型,会执行 refreshComputed

    3. 略过一下各种 version 的判断,会再次进入isDirty 函数,注意步骤 a 进入 isDirty 函数的时候,computed2 的身份是 dep,这次进入 isDirty 函数,computed2 的身份是 sub,因为 effect1computed2 有没有更新,computed2 自己是不知道的,computed2 会更新的前提应该是它的 effect 被重新调用,只有 computed effect2 重新调用,computed2 的值才有可能发生变化

      graph LR
      Node1[effect1执行] -->|取决于| Node2[computed2 dep]
      Node2[computed2 dep更新]-->|取决于| Node3[computed2 effect 重新执行]
      
    4. 所以到这里有点类似递归了,computed effect2 会不会被执行,又需要问 computed1 dep ,computed1 为了回答问题,需要去问 obj.a 这个 dep,到这里终于停止了

      graph LR
      Node1[computed2 effect重新执行] -->|取决于| Node2[computed1 dep]
      Node2[computed1 dep更新]-->|取决于| Node3[computed1 effect 重新执行]
      Node3[computed1 effect 重新执行]-->|取决于| Node4[obj.a 更新]
      
    5. obj.a 肯定是更新了,所以 computed1 effect 一定会重新执行,但是 computed1 effect 重新执行后,computed1 的 value 不会变,任何数*0 还是 0

    6. 这就导致了 computed1 dep 没有更新,那么 computed2 effect 不会重新执行,computed2 dep 肯定也没有更新,最后就是 effect1 准备重新执行前,isDirty 是 false,直接中断

    7. 以上的流程就像是一个递归函数,层层询问 dep 有没有更新,直到最内层的 dep 才能得出是否有更新的结论,而在归的过程中,一旦发现某一层的 dep 并没有更新,相当于直接终止了更新。

    8. 附上 refreshComputed 源码

      
      function refreshComputed(computed) {
        if (computed.flags & 4 && !(computed.flags & 16)) {
          return;
        }
        computed.flags &= ~16;
        if (computed.globalVersion === globalVersion) {
          return;
        }
        computed.globalVersion = globalVersion;
        const dep = computed.dep;
        computed.flags |= 2;
        if (dep.version > 0 && !computed.isSSR && computed.deps && !isDirty(computed)) {
          computed.flags &= ~2;
          return;
        }
        const prevSub = activeSub;
        const prevShouldTrack = shouldTrack;
        activeSub = computed;
        shouldTrack = true;
        try {
          prepareDeps(computed);
          const value = computed.fn(computed._value);
          if (dep.version === 0 || shared.hasChanged(value, computed._value)) {
            computed._value = value;
            dep.version++;
          }
        } catch (err) {
          dep.version++;
          throw err;
        } finally {
          activeSub = prevSub;
          shouldTrack = prevShouldTrack;
          cleanupDeps(computed, true);
          computed.flags &= ~2;
        }
      }
      

    其他

    1. effect 调用是这样的一个链表,根据上面的分析,在 effect1 调用前的 isDirty 逻辑里面,computed1 effect 其实是先调用,但是有 flag标记,避免 effect1 调用完后重复调用 computed2 effect 和 computed1 effect

      graph LR
      Node1[effect1] -->|nex| Node2[computed2 effect]
      Node2[computed2 effect] -->|nex| Node3[computed1 effect]
      
    2. 不一定要像上面分析的一样,层层询问 dep 到最底层的一个,中间有可能通过 version 比对,提前就能退出, version 是实现 computed 懒计算的一个重要方案。

    
      if (computed.globalVersion === globalVersion) {
        return;
      }
    

    简单分析一下 version 的作用

    1. computed 追踪一个 globalVersion,每次 refreshComputed 的时候,把自己的 globalVersion 更新为全局 globalVersion。全局的 globalVersion 在所有普通 dep 之间共享,任何一个普通 dep 只要有变化,全局 globalVersion++ ,如果 computed.globalVersion === globalVersion ,说明普通 dep 没有变化,computed 肯定也不会有变化

    2. 所有 dep 自己有一个 version,包括普通 dep 和 computed dep ,所有 link 上也有一个 version,link 上的 version 记录的是这个 link 对应 effect 最后一次读取 这个 link 对应 dep 的 version,如果两个不同,那说明 dep 更新,所以 effect 应该执行

      
      effect(() => {
        console.log("effect 1")
        a.value
      })
      
      debugger
      obj.a++
      

      简化版关系,link 链接 dep 和 effect,当上面代码 effect 里的回调函数执行后,link 和 obj.a dep 的 version 号都会同步一直,当 obj.a++ ,obj.a dep的版本号和 link 上就不同了

      graph LR
      Node1[link version=1] --> Node2[obj.a dep version=1]
      Node1[link version=1] --> Node3[effect1]
      

      现在换成 computed 理解,effect1 执行后,各种 version 版本号都相同,obj.a++ 后,最终造成 computed1 effect 更新,更新后发现值相同,computed1 dep version 不变,computed2 effect 对应 link (computed2 effect link)上version 记录是computed1 dep 上一次的 version,由于没变,所以computed2 effect 不会执行

      
      const computed1 = computed(() => {
        console.log("computed 1")
        return obj.a * 0
      })
      
      const computed2 = computed(() => {
        console.log("computed 2")
        return computed1.value + obj.c
      })
      
      effect(() => {
        console.log("effect 1")
        computed2.value
      })
      
      debugger
      obj.a++
      

      link 上 直接记录 dep 的值更直观,新旧值比对就知道要不要更新,但是用 version 记录更省内存