计算属性:Vue3探秘系列— computed的实现原理(六)

335 阅读11分钟

前言

Vue3探秘系列文章链接:

不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)

不止响应式:Vue3探秘系列— 组件更新会发生什么(二)

不止响应式:Vue3探秘系列— diff算法的完整过程(三)

不止响应式:Vue3探秘系列— 组件的初始化过程(四)

终于轮到你了:Vue3探秘系列— 响应式设计(五)

计算属性:Vue3探秘系列— computed的实现原理(六)

侦听属性:Vue3探秘系列— watch的实现原理(七)

生命周期:Vue3探秘系列— 钩子函数的执行过程(八)

依赖注入:Vue3探秘系列— provide 与 inject 的实现原理(九)

Vue3探秘系列— Props:初始化与更新流程(十)

Vue3探秘系列— directive:指令的实现原理(十一)

Hello~大家好。我是秋天的一阵风

在上一课中,我们探讨了响应式机制的基本原理。现在,我们将转向一个在 Vue.js 开发中极为常见的响应式 API —— 计算属性

计算属性 computed 是一种强大的工具,它允许开发者定义一个计算方法,该方法可以根据依赖的响应式数据计算出新的值并返回。当依赖的数据发生变化时,计算属性会自动重新计算结果,从而简化了数据处理的流程。

既然计算属性本质上是对依赖数据的计算,那么为什么不直接使用普通的函数呢? Vue.js 3.0 中的计算属性 API 是如何实现的?接下来,让我们一起探索这些问题的答案,并深入了解计算属性的实现原理。

一、使用例子

const count = ref(1);
const plusOne = computed(() => count.value + 1);
console.log(plusOne.value); // 2
count.value++;
console.log(plusOne.value); // 3

除了给 computed传入一个回调函数以外,你还可以传入一个包含get、set函数的对象,如下面的例子:

const count = ref(1);
const plusOne = computed({
  get: () => count.value + 1,
  set: (val) => {
    count.value = val - 1;
  },
});
plusOne.value = 1;
console.log(count.value); // 0

在这个例子中,我们可以看到,computed 函数接收了一个包含 getter 和 setter 函数的对象。

getter 函数与之前相同,返回 count.value + 1

值得注意的是 setter 函数:当我们修改 plusOne.value 的值时,就会触发 setter 函数。

实际上,setter 函数内部会根据传入的参数来修改计算属性所依赖的值 count.value

一旦依赖的值发生变化,再次获取计算属性时就会重新执行 getter 函数,因此获取到的值也会随之改变。

二、 源码实现

function computed(getterOrOptions) {
  // getter 函数
  let getter;
  // setter 函数
  let setter;
  // 标准化参数
  if (isFunction(getterOrOptions)) {
    // 表面传入的是 getter 函数,不能修改计算属性的值
    getter = getterOrOptions;
    setter =
      process.env.NODE_ENV !== "production"
        ? () => {
            console.warn("Write operation failed: computed value is readonly");
          }
        : NOOP;
  } else {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }
  // 数据是否脏的
  let dirty = true;
  // 计算结果
  let value;
  let computed;
  // 创建副作用函数
  const runner = effect(getter, {
    // 延时执行
    lazy: true,
    // 标记这是一个 computed effect 用于在 trigger 阶段的优先级排序
    computed: true,
    // 调度执行的实现
    scheduler: () => {
      if (!dirty) {
        dirty = true;
        // 派发通知,通知运行访问该计算属性的 activeEffect
        trigger(computed, "set" /* SET */, "value");
      }
    },
  });
  // 创建 computed 对象
  computed = {
    __v_isRef: true,
    // 暴露 effect 对象以便计算属性可以停止计算
    effect: runner,
    get value() {
      // 计算属性的 getter
      if (dirty) {
        // 只有数据为脏的时候才会重新计算
        value = runner();
        dirty = false;
      }
      // 依赖收集,收集运行访问该计算属性的 activeEffect
      track(computed, "get" /* GET */, "value");
      return value;
    },
    set value(newValue) {
      // 计算属性的 setter
      setter(newValue);
    },
  };
  return computed;
}

1.标准化参数

// getter 函数 
let getter; 
// setter 函数 
let setter; 
// 标准化参数 
if (isFunction(getterOrOptions)) { 
// 表面传入的是 getter 函数,不能修改计算属性的值 
getter = getterOrOptions; 
setter = process.env.NODE_ENV !== "production" 
    ? () => { console.warn("Write operation failed: computed value is readonly"); } 
: NOOP; } 

else { 
getter = getterOrOptions.get; 
setter = getterOrOptions.set;
}
  1. 首先声明了两个变量gettersetter

  2. onlyGetter变量用于判断getterOrOptions是否仅仅包含getter函数。这是通过调用isFunction函数实现的。

  3. 如果onlyGetter为真,即getterOrOptions仅包含一个函数,那么将这个函数赋值给getter,同时根据开发模式设置setter。在开发模式下,setter会输出一个警告信息,提示“写操作失败:计算属性值是只读的”;

  4. 而在生产模式下,setter则被设置为NOOP,即不执行任何操作。

  5. 如果onlyGetterfalse,即getterOrOptions包含getset属性,那么分别将getterOrOptionsgetset属性赋值给gettersetter

2.创建副作用函数

  // 创建副作用函数
  const runner = effect(getter, {
    // 延时执行
    lazy: true,
    // 标记这是一个 computed effect 用于在 trigger 阶段的优先级排序
    computed: true,
    // 调度执行的实现
    scheduler: () => {
      if (!dirty) {
        dirty = true;
        // 派发通知,通知运行访问该计算属性的 activeEffect
        trigger(computed, "set" /* SET */, "value");
      }
    },
  });

接着是创建副作用函数 runnercomputed 内部通过 effect 创建了一个副作用函数,它是对 getter 函数做的一层封装,另外我们这里要注意第二个参数,也就是 effect 函数的配置对象。

其中 lazytrue 表示 effect 函数返回的 runner 并不会立即执行;

computedtrue 用于表示这是一个 computed effect,用于 trigger 阶段的优先级排序,scheduler 表示它的调度运行的方式.

3.创建 computed 对象

// 创建 computed 对象
  computed = {
    __v_isRef: true,
    // 暴露 effect 对象以便计算属性可以停止计算
    effect: runner,
    get value() {
      // 计算属性的 getter
      if (dirty) {
        // 只有数据为脏的时候才会重新计算
        value = runner();
        dirty = false;
      }
      // 依赖收集,收集运行访问该计算属性的 activeEffect
      track(computed, "get" /* GET */, "value");
      return value;
    },
    set value(newValue) {
      // 计算属性的 setter
      setter(newValue);
    },
  };

最后是创建 computed 对象并返回,这个对象也拥有 gettersetter 函数。

computed 对象被访问的时候会触发 getter,然后会判断是否 dirty,如果是就执行 runner,然后做依赖收集;当我们直接设置 computed 对象时会触发 setter,即执行 computed 函数内部定义的 setter 函数。

以下是computed的执行流程图:

image.png

三、computed的两个特性

computed对象具有两个特性,一个是延迟执行,一个是缓存。我们利用一个简单的例子来探究这两个特性。


<template>
  <div>
    {{ plusOne }}
  </div>
  <button @click="plus">plus</button>
</template>
<script>
  import { ref, computed } from 'vue'
  export default {
    setup() {
      const count = ref(0)
      const plusOne = computed(() => {
        return count.value + 1
      })
      function plus(){
         count.value++
      }

    return {
        plusOne,
        plus
    }
  }
}
</script>

1. lazy 延迟执行


  const runner = effect(getter, {
    lazy: true,
    // mark effect as computed so that it gets priority during trigger
    computed: true,
    scheduler: () => {
      if (!dirty) {
        dirty = true
        trigger(computed, TriggerOpTypes.SET, 'value')
      }
    }
  })

export function effect<T = any>(
  fn: () => T,
  options: ReactiveEffectOptions = EMPTY_OBJ
): ReactiveEffect<T> {
  if (isEffect(fn)) {
    fn = fn.raw
  }
  const effect = createReactiveEffect(fn, options)
  if (!options.lazy) {
    effect()
  }
  return effect
}
  1. computed 中 将 getter函数(() => { return count.value + 1 })和一个配置对象 options 传入内部的 effect 函数生成一个副作用函数, 注意这个配置对象中的lazytrue ,表示是懒执行的

  2. effect函数中又创建一个effect函数,为了区分,我们把它叫reactiveEffect函数。 接着会判断传入的选项的 lazy 是否为true

  3. 如果是 false,则会马上执行这个reactiveEffect函数

  4. 如果是 true, 则不会执行,而是将它 return出去。 这个reactiveEffect函数对于computed来说就是传入的getter函数

  5. 回到computed对象,将return出来的reactiveEffect函数赋值给了runner函数

  6. 访问plusOne变量就是在访问computed对象的value属性。

  7. 只有在访问computed对象value属性时,才会执行runner函数进行求值

2. 缓存值

在文章开头的时候我们提出过一个问题:既然计算属性本质上是对依赖数据的计算,那么为什么不直接使用普通的函数呢? computed与普通函数的最大区别就是具有缓存功能,它的内部会缓存上次的计算结果,只要计算结果不变,就不会重新执行函数进行计算。我们来梳理一下缓存的实现流程

computed = {
    __v_isRef: true,
    // expose effect so computed can be stopped
    effect: runner,
    get value() {
      if (dirty) {
        value = runner()
        dirty = false
      }
      track(computed, TrackOpTypes.GET, 'value')
      return value
    },
    set value(newValue: T) {
      setter(newValue)
    }
  } as any
  1. 我们知道,访问plusOne变量其实就是在访问computed对象的value属性

  2. value函数中,首先会判断 dirty 是否为 true

  3. 在第一次访问plusOne变量时, dirty默认为true, 所以会通过执行runner函数进行求值, 然后会马上将 dirty 修改为 false

  4. plusOne 进行依赖收集后,将这个value返回

  5. 在第二次访问 plusOne时,此时dirtyfalse,并不会再次执行runner函数进行求值,将上次的缓存值返回

那么问题来了?从第二次开始都是dirtyfalse,返回的是缓存值,什么时候dirty才会变回true进行重新计算呢? 答案是在count ++ 的时候

  1. 执行 plus函数 时,count ++,这时候counttrigger函数执行,并通知依赖进行更新,count的依赖就是 plusOne runner函数
 // core/packages/reactivity/src/computed.ts
  const runner = effect(getter, {
   lazy: true,
   // mark effect as computed so that it gets priority during trigger
   computed: true,
   scheduler: () => {
     if (!dirty) {
       dirty = true
       trigger(computed, TriggerOpTypes.SET, 'value')
     }
   }
 })
 
// core/packages/reactivity/src/effect.ts
const run = (effect) => { 
 // 调度执行 
 if (effect.options.scheduler) { 
   effect.options.scheduler(effect) 
 } 
 else { 
   // 直接运行 
   effect() 
 } 
}
  1. trigger函数执行时,会将 count 的依赖都取出来,循环调用run方法
  2. run方法中,由于computed 创建时传入scheduler属性,所以会调用scheduler方法。

请注意:这里对于computed的情况并不会直接执行,而是执行scheduler函数,这就相等于将控制权交出去

  1. scheduler在执行时发现dirtyfalse,就会将他赋值为 true。这样在下一次获取时就会重新执行runner函数进行求值

四、计算属性的执行顺序

我们先来回顾一下runner函数,在通过effect函数创建副作用函数的时候,options选项里还有一个computed:true的配置。

 const runner = effect(getter, {
    lazy: true,
    // mark effect as computed so that it gets priority during trigger
    computed: true,
    scheduler: () => {
      if (!dirty) {
        dirty = true
        trigger(computed, TriggerOpTypes.SET, 'value')
      }
    }
  })

这个配置决定了 trigger函数执行effect的顺序,我们看看trigger函数内执行effects的过程:

// core/packages/reactivity/src/effect.ts
const add = (effectsToAdd) => { 
  if (effectsToAdd) { 
    effectsToAdd.forEach(effect => { 
      if (effect !== activeEffect || !shouldTrack) { 
        if (effect.options.computed) { 
          computedRunners.add(effect) 
        } 
        else { 
          effects.add(effect) 
        } 
      } 
    }) 
  } 
} 
const run = (effect) => { 
  if (effect.options.scheduler) { 
    effect.options.scheduler(effect) 
  } 
  else { 
    effect() 
  } 
} 
computedRunners.forEach(run) 
effects.forEach(run)

在添加待运行的 effects 的时候,我们会判断每一个 effect 是不是一个 computed effect,如果是的话会添加到 computedRunners 中,在后面运行的时候会优先执行 computedRunners,然后再执行普通的 effects

问题又来了,为什么computed effect需要优先执行呢?

我们先来看一道输出题:请问下面的代码会打印几次,分别打印什么呢?

import { ref, computed } from 'vue' 
import { effect } from '@vue/reactivity' 
const count = ref(0) 
const plusOne = computed(() => { 
  return count.value + 1 
}) 
effect(() => { 
  console.log(plusOne.value + count.value) 
}) 
function plus() { 
  count.value++ 
} 
plus()

//1 
//3 
//3

  1. 首先 effect 函数会先执行,此时运行 console.log(plusOne.value + count.value)count.value 是 0,plusOne.value 是 1。 所以第一次输出 1。

  2. 然后执行plus函数count.value ++, count.value变为1。同时counttrigger函数触发,通知所有count的依赖也就是副作用函数进行执行。

  3. count有两个依赖,一个是 plusOnerunner函数,一个是 effect 函数

注意:

  1. computed effect 会先执行,也就是 plusOnerunner函数会先执行
  2. 由于在effect函数中访问了plusOneplusOne的依赖也有这个effect函数
  1. plusOnerunner函数先执行,dirty会设置为true,然后通知plusOne的依赖effect执行,也就是plusOne.value + count.value ,因为dirty已经被改为true了,获取plusOne的时候,会重新求值,plusOne就是2,所以第二次打印是3

  2. 执行完 plusOne runner 以及依赖更新之后,再去执行 count 的普通 effect 依赖。effect函数再次执行,但是由于此时 pluseOnedirtyfalse,所以拿的是缓存值2。count.value为1 , plusOne.value为2,所以这时会打印第三次,打印3

知道了具体执行流程后,我们再回过头看例子就清楚了,让computed effect优先执行主要是为了处理特殊场景。因为 effect 函数依赖了 plusOnecount,所以 plusOne 先计算会更合理。

那问题又来了,如果想要输出以下值,应该怎么做呢?

1
2
3

其实很简单,我们只需要把 computed runner effect 的执行顺序换一下,让effect先执行就可以了。

如果将这两个执行顺序交换后,我们重新来走一遍流程:

  1. 首先 effect 函数会先执行,此时运行 console.log(plusOne.value + count.value)count.value 是 0,plusOne.value 是 1。 所以第一次输出 1。

  2. 然后执行plus函数,count.value ++, count.value变为1.同时counttrigger函数触发,通知所有count的依赖也就是副作用函数执行。

  3. 这一次 effect 先执行 plusOne.value + count.value,那么就会访问plusOne.value,但由于 plusOnerunner 还没执行,所以此时 dirtyfalse,得到的值还是上一次的计算结果 1。 所以 1 + 1 = 2,第二次打印2

  4. 接着再执行 plusOne runner 函数,把 plusOnedirty 设置为 true,然后通知它的依赖 effect 执行 plusOne.value + count.value

  5. 这个时候由于 dirtytrue,就会再次执行 plusOne getter 计算新值,拿到了 2,然后再加上 1 就得到 3。所以第三次打印3

总结

好了,到这次我们就学习完了computed的实现原理机制,我们知道了计算属性有两大特点:延时计算和缓存。还知道了在effects执行时会优先执行computed effect,这是为了处理特殊场景而进行的处理。下一篇我们继续探究 侦听属性 watch 的源码实现,谢谢你的阅读~