计算属性

计算属性是基于它们的响应式依赖进行缓存的。只在相关响应式依赖发生改变时它们才会重新求值。

侦听器

watch 响应数据的变化。当需要在数据变化时执行异步或开销较大的操作时,这个方式是最有用的。

计算属性 VS 侦听器

计算属性是计算出一个新的属性,并将这个属性挂载到 vm(Vue 实例上)。侦听器是监听 vm 上已经存在的响应式属性,所以可以用侦听器监听计算属性。

计算属性本质是一个惰性求值的观察者,具有缓存性,只有当依赖发生改成时,才会重新求值。侦听器是当依赖数据发生改变时就会执行回调。
在使用场景上,计算属性适合在一个数据被多少数据影响时使用,而侦听器适合在一个数据影响多个数据。

Vue2 Computed 原理分析

Vue 2.6.11

在 Vue2 中进行实例初始化时,会进行很多初始化,包括:初始化生命周期、初始化事件、初始化injections、初始化state(props,methods,data,computed,watch) 等等 。当在初始化 state 时就会进行 computed 的初始化。涉及到函数就是 initState。

  1. function initMixin (Vue) {
  2. Vue.prototype._init = function (options) {
  3. var vm = this;
  4. ...
  5. // 初始化props,methods,data,computed,watch
  6. initState(vm);
  7. ...
  8. };
  9. }

调用 initState 函数会进行数据状态的初始化,在 Vue 中 props、methods、data、computed、watch 都可以被称为状态,所以被统一到 initState 函数中进行初始化。但是这里需要注意是先初始化 data,在初始化 computed,最后在初始化 watch。这个顺序其实是有一定讲究的。计算属性是基于它们的响应式依赖进行缓存的。只在相关响应式依赖发生改变时它们才会重新求值。本质上计算属性是依赖响应式属性的,所以需要先将响应式属性初始化。而侦听器是监听 vm 上已经存在的响应式属性,实质上也是可以用侦听器监听计算属性的,所以 watch 是在计算属性初始化完之后进行初始化。

  1. function initState (vm) {
  2. ...
  3. // 初始化数据
  4. if (opts.data) {
  5. initData(vm);
  6. } else {
  7. observe(vm._data = {}, true /* asRootData */);
  8. }
  9. // 初始化计算属性
  10. if (opts.computed) { initComputed(vm, opts.computed); }
  11. // 初始化监听
  12. if (opts.watch && opts.watch !== nativeWatch) {
  13. initWatch(vm, opts.watch);
  14. }
  15. ...
  16. }

接着调用 initComputed 函数进行 computed 的初始化,这里有几个点需要了解一下。

  1. 获取计算属性的定义 userDef 和 getter 求值函数,在 Vue 中定义一个计算属性有两种方法,一种是直接写一个函数,另外一种是添加 set 和 get 方法的对象形式。
  2. 计算属性的观察者 watcher 和 消息订阅器 dep。watcher 中实例化了 dep 并向 dep.subs 中添加了订阅者,dep 通过 notify 遍历 dep.subs 通知每个 watcher 更新。在创建 watcher 时传递了四个参数:vm 实例、getter 函数、noop 空格函数(watcher 的回调)、computedWatcherOptions 常量{ lazy: true }

在进行 Watcher 实例化时,传入常量{ lazy: true },会给当前 watcher 打上两个标记,一个标记是 lazy = true 表示当前 watcher 是计算属性的 watcher,一个标记是 dirty = ture,用于后续求值时标记是否需要重新求值 。

  1. function initComputed (vm, computed) {
  2. // $flow-disable-line
  3. var watchers = vm._computedWatchers = Object.create(null);
  4. // computed properties are just getters during SSR
  5. var isSSR = isServerRendering();
  6. // 遍历 computed 对象,为每一个属性进行依赖收集
  7. for (var key in computed) {
  8. // 1.
  9. var userDef = computed[key];
  10. // 获取 get
  11. var getter = typeof userDef === 'function' ? userDef : userDef.get;
  12. if (!isSSR) {
  13. // 2.
  14. watchers[key] = new Watcher(
  15. vm, // vm 实例
  16. getter || noop, // getter 求值函数或者是一个空函数
  17. noop, // 空函数 function noop(a, b, c) {}
  18. computedWatcherOptions // computedWatcherOptions 常量对象 { lazy: true };
  19. );
  20. }
  21. if (!(key in vm)) {
  22. // 3.
  23. defineComputed(vm, key, userDef);
  24. } else {
  25. if (key in vm.$data) {
  26. warn(("The computed property "" + key + "" is already defined in data."), vm);
  27. } else if (vm.$options.props && key in vm.$options.props) {
  28. warn(("The computed property "" + key + "" is already defined as a prop."), vm);
  29. }
  30. }
  31. }
  32. }

因为 computed 属性是直接挂载到实例对象中的,所以在定义之前需要判断对象中是否已经存在重名的属性,defineComputed 传入了三个参数:vm 实例、计算属性的 key 以及 userDef 计算属性的定义(对象或函数)。

defineComputed 定义计算属性。

  1. if (!(key in vm)) {
  2. defineComputed(vm, key, userDef);
  3. } else {
  4. ...
  5. }
  6. function defineComputed (
  7. target,
  8. key,
  9. userDef
  10. ) {
  11. ...
  12. Object.defineProperty(target, key, sharedPropertyDefinition);
  13. }

在 defineComputed 最后调用了原生的 Object.defineProperty 方法,并且在 Object.defineProperty(target, key, sharedPropertyDefinition); 传入属性描述符 sharedPropertyDefinition。 描述符初始化值为:

  1. var sharedPropertyDefinition = {
  2. enumerable: true,
  3. configurable: true,
  4. get: noop,
  5. set: noop
  6. };

在 defineComputed 时,根据 Object.defineProperty 前面的代码可以看到 sharedPropertyDefinition 的 get/set 方法在经过 userDef 和 shouldCache 等多重判断后被重写,当非服务端渲染时,sharedPropertyDefinition 的 get 函数也就是createComputedGetter(key) 的结果。

  1. var shouldCache = !isServerRendering();
  2. if (typeof userDef === 'function') {
  3. sharedPropertyDefinition.get = shouldCache
  4. ? createComputedGetter(key)
  5. : createGetterInvoker(userDef);
  6. sharedPropertyDefinition.set = noop;
  7. } else {
  8. sharedPropertyDefinition.get = userDef.get
  9. ? shouldCache && userDef.cache !== false
  10. ? createComputedGetter(key)
  11. : createGetterInvoker(userDef.get)
  12. : noop;
  13. sharedPropertyDefinition.set = userDef.set || noop;
  14. }
  15. if (sharedPropertyDefinition.set === noop) {
  16. sharedPropertyDefinition.set = function () {
  17. warn(
  18. ("Computed property "" + key + "" was assigned to but it has no setter."),
  19. this
  20. );
  21. };
  22. }

我们找到 createComputedGetter 函数调用结果并最终改写 sharedPropertyDefinition 大致呈现如下:

  1. sharedPropertyDefinition = {
  2. enumerable: true,
  3. configurable: true,
  4. get: function computedGetter () {
  5. var watcher = this._computedWatchers && this._computedWatchers[key];
  6. if (watcher) {
  7. if (watcher.dirty) {
  8. watcher.evaluate();
  9. }
  10. if (Dep.target) {
  11. watcher.depend();
  12. }
  13. return watcher.value
  14. }
  15. },
  16. set: userDef.set || noop
  17. }

当计算属性被调用时便会执行 get 访问函数,从而关联上观察者对象 watcher。执行方法 evaluate。这个方法只有懒惰的观察者才会这样做。

  1. Watcher.prototype.evaluate = function evaluate () {
  2. this.value = this.get();
  3. this.dirty = false;
  4. };

到这里计算属性的初始化就已经完成,那计算属性又是如何根据响应式进行依赖缓存的了?

其实我们不难发现,当 vue 在执行 evaluate 方法时,本质上还是通过 watcher.get 来获取结算结果,当计算属性依赖的数据发生变化时,就会触发 set 方法,通知更新触发 update 方法。这是会将标记 dirty 设置为 ture,当再次调用computed 的时候就会重新计算返回新的值。

  1. Watcher.prototype.update = function update () {
  2. // computed Watcher
  3. if (this.lazy) {
  4. this.dirty = true;
  5. } else if (this.sync) { // watch Watcher
  6. this.run();
  7. } else {
  8. queueWatcher(this);
  9. }
  10. };

Vue3 Computed 原理分析

Vue 3.2.36

为了防止一部分同学对 vue3 的 computed 不是很熟悉,这里也会简单说下使用方式。
第一种使用方式,接受一个 getter 函数,并根据 getter 的返回值返回一个不可变的响应式 ref 对象

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

第二种使用方式,接受一个具有 get 和 set 函数的对象,用来创建可写的 ref 对象。

  1. const count = ref(1)
  2. const plusOne = computed({
  3. get: () => count.value + 1,
  4. set: val => {
  5. count.value = val - 1
  6. }
  7. })
  8. plusOne.value = 1
  9. console.log(count.value) // 0

从使用方式来看,其实 vue3 和 vue2 机会是没有差别的。都是基于响应式依赖进行缓存。

举个例子:

  1. const data = { name: '张三', age: 18 };
  2. const state = reactive(data);
  3. const newAge = computed(() => state.age + 1);
  4. effect(() => {
  5. document.getElementById('app').innerHTML = `${state.name}, 今年刚刚好${newAge.value}岁`
  6. });

vue3 computed依赖reactive/ ref 响应属性的值进行计算,而 effect 依赖 computed 的值进行计算。

  • computed 是 effect
  • 变量 newAge 通过 age 计算而来
  • 变量 age 收集了 computedEffect,而对于 computed 来说它收集渲染 effect。

Vue2.0 / 3.0  Computed - 图1

computed 本身有两种使用方式:
const xxx = computed(() => xxx)
const xxx1 = computed({get: () => {}, set: () => {}})
当我们在调用 computed 方法时,就会在这里需要统一做下区分,同时调用实现类ComputedRefImpl,这个方法比较简单,接下来我们重点分析下类ComputedRefImpl。

  1. function computed(getterOrOptions, ...) {
  2. let getter;
  3. let setter;
  4. const onlyGetter = isFunction(getterOrOptions);
  5. if (onlyGetter) {
  6. getter = getterOrOptions;
  7. setter = () => {
  8. console.warn('Write operation failed: computed value is readonly');
  9. }
  10. ;
  11. }
  12. else {
  13. getter = getterOrOptions.get;
  14. setter = getterOrOptions.set;
  15. }
  16. const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR);
  17. ...
  18. return cRef;
  19. }

ComputedRefImpl 类 :

  1. class ComputedRefImpl {
  2. constructor(getter, _setter, isReadonly, isSSR) {
  3. this._setter = _setter;
  4. this.dep = undefined;
  5. this.__v_isRef = true;
  6. this._dirty = true;
  7. // 1.
  8. this.effect = new ReactiveEffect(getter, () => {
  9. if (!this._dirty) {
  10. this._dirty = true;
  11. triggerRefValue(this);
  12. }
  13. });
  14. this.effect.computed = this;
  15. this.effect.active = this._cacheable = !isSSR;
  16. // 根据传入是否有setter函数来决定是否只读
  17. this["__v_isReadonly" /* IS_READONLY */] = isReadonly;
  18. }
  19. get value() {
  20. const self = toRaw(this);
  21. trackRefValue(self);
  22. if (self._dirty || !self._cacheable) {
  23. self._dirty = false;
  24. self._value = self.effect.run();
  25. }
  26. return self._value;
  27. }
  28. set value(newValue) {
  29. this._setter(newValue);
  30. }
  31. }

这里将 ComputedRefImpl 类分为两块来解读。
第一块就是 **computed** 的初始化,调用 ComputedRefImplconstructor 初始化,主要做两件事情:

  1. 创建 effect 对象,生成 watcher 监听函数并赋值给实例的 effect 属性。将当前 getter 当做监听函数,并附加调度器。
  2. 设置 computed ref 是否只是可读。设置是否可读的依据是:onlyGetter||!setter

不过单单从构造方法来看其实和 computed 没有太大的关系,只是进行了初始化变量的操作,并创建了一个 ComputedRef 实例赋值给我们的调用。
我们发现声明一个 computed 时其实并不会执行 getter 方法,只有在读取 computed 值时才会执行它的 getter 方法,那么接下来我们就要关注 ComputedRefImpl 的 getter 方法。
上面提到的,第二部分就是 getter 方法的执行,getter 方法会在读取 computed 值的时候执行,而在 getter 方法中有一个叫 _dirty 的变量,它的意思是代表脏数据的开关,默认初始化时 _dirty 被设为 true ,在 getter 方法中表示开关打开,需要计算一遍 computed 的值,然后关闭开关,之后再获取 computed 的值时由于 _dirty 是 false 就不会重新计算。这就是 computed 缓存值的实现原理。

  1. get value() {
  2. ...
  3. if (self._dirty || !self._cacheable) {
  4. self._dirty = false;
  5. self._value = self.effect.run();
  6. }
  7. return self._value;
  8. }

那么 computed 是怎么知道要重新计算值的呢?
computed 本身是依赖响应式属性的变化的,如果依赖的响应属性发生改变,会触发 effect 的 scheduler 函数执行。此方法就是 computed 内部依赖的状态变化时会执行的操作。所以最终的流程就是:computed 内部依赖的状态发生改变,执行对应的监听函数,这其中自然会执行 scheduler 里的操作。而在 scheduler 中将 _dirty 设为了 true 。

  1. this.effect = new ReactiveEffect(getter, () => {
  2. // effect 的 scheduler 函数执行
  3. if (!this._dirty) {
  4. this._dirty = true;
  5. triggerRefValue(this);
  6. }
  7. });

也许看到这里有同学还会产生一个疑问,computed 是怎么知道内部依赖产生了变化呢?这是由于在我们第一次获取 computed 值(即执行getter方法)的时候对内部依赖进行了访问,在那个时候就对其进行了依赖收集操作,所以 computed 能够知道内部依赖产生了变化。

注意:上面提到的「第一次获取 computed 值」这里是第一次或者,而不是初始化 computed。

调试 Computed

Vue 3.2 +

在 Vue 3.2 + 的版本中,新增了 computed 调试的功能,computed 可接受一个带有 onTrack 和 onTrigger 选项的对象作为第二个参数:

onTrack 和 onTrigger 仅在开发模式下生效。

  • onTrack 会在某个响应式 property 或 ref 作为依赖被追踪时调用。
  • onTrigger 会在侦听回调被某个依赖的修改触发时调用。
    1. const plusOne = computed(() => count.value + 1, {
    2. onTrack(e) {
    3. // 当 count.value 作为依赖被追踪时触发
    4. debugger
    5. },
    6. onTrigger(e) {
    7. // 当 count.value 被修改时触发
    8. debugger
    9. }
    10. })
    11. // 访问 plusOne,应该触发 onTrack
    12. console.log(plusOne.value)
    13. // 修改 count.value,应该触发 onTrigger
    14. count.value++
    这个调试 computed 在源码实现也比较简单,在 computed 初始化的时候,会将这个两个方法挂载 effect 上。
    1. function computed(getterOrOptions, debugOptions, isSSR = false) {
    2. ...
    3. const cRef = new ComputedRefImpl(...);
    4. if (debugOptions && !isSSR) {
    5. cRef.effect.onTrack = debugOptions.onTrack;
    6. cRef.effect.onTrigger = debugOptions.onTrigger;
    7. }
    8. return cRef;
    9. }
    当 computed 的 getter 被执行时,会触发跟踪依赖属性的 trackRefValue 方法,如果存在 onTrack 就会执行 onTrack 回调。 ```javascript class ComputedRefImpl { … get value() { … trackRefValue(self); … } }

function trackRefValue(ref) { … trackEffects(ref.dep || (ref.dep = createDep()), { target: ref, type: “get” / GET /, key: ‘value’ }); … }

function trackEffects(dep, debuggerEventExtraInfo) { … activeEffect.onTrack(Object.assign({ effect: activeEffect }, debuggerEventExtraInfo)); … } ``` 类似的当依赖的属性被修改时,会触发 onTrigger 方法。

总结

不管在是 Vue 2 还是在 Vue 3 中,对 computed 本身的实现原理基本都是一样的。当使用 computed 计算属性时,组件初始化会对每一个计算属性都创建对应的 watcher , 然后在第一次调用自己的 getter 方法时,收集计算属性依赖的所有 data,那么所依赖的 data 会收集这个订阅者同时会针对 computed 中的 key 添加属性描述符创建了独有的 get 方法,当调用计算属性的时候,这个 get 判断 dirty 是否为 true,为真则表示要要重新计算,反之直接返回 value。当依赖的 data 变化的时候回触发数据的 set 方法调用 update() 通知更新,此时会把 dirty 设置成 true,所以 computed 就会重新计算这个值,从而达到动态计算的目的。
Vue2.0 / 3.0  Computed - 图2