温故而知新,Vue2/3 Computed 的源码解析

3,956 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情

前言

不管是 Vue2 中还是在 Vue 3 中,计算属性 computed 都是我们在计算复杂逻辑的一把利器。原因在于计算属性会基于它们的响应式依赖进行缓存的。只在相关响应式依赖发生改变时它们才会重新求值。 在对赋值求值或者大数据求值会起到一定性能优化的作用。

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

计算属性本质是一个惰性求值的观察者,具有缓存性,只有当依赖发生改成时,才会重新求值。侦听器是当依赖数据发生改变时就会执行回调。

在使用场景上,计算属性适合在一个数据被多少数据影响时使用,而侦听器适合在一个数据影响多个数据。接下来我们就通过源码来看看 computed 的实现原理。

Vue2 Computed 原理分析

Vue 2.6.11

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

function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    var vm = this;
    ...
    // 初始化props,methods,data,computed,watch
    initState(vm);    
    ...
  };
  }

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

function initState (vm) {
  ...
  // 初始化数据
  if (opts.data) {
    initData(vm);
  } else {
    observe(vm._data = {}, true /* asRootData */);
  }
  // 初始化计算属性
  if (opts.computed) { initComputed(vm, opts.computed); }
  // 初始化监听
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch);
  }
  ...
  }

接着调用 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,用于后续求值时标记是否需要重新求值 。

function initComputed (vm, computed) {
  // $flow-disable-line
  var watchers = vm._computedWatchers = Object.create(null);
  // computed properties are just getters during SSR
  var isSSR = isServerRendering();
  // 遍历 computed 对象,为每一个属性进行依赖收集
  for (var key in computed) {
    // 1. 
    var userDef = computed[key];
    // 获取 get
    var getter = typeof userDef === 'function' ? userDef : userDef.get;
    
    if (!isSSR) {
      // 2. 
      watchers[key] = new Watcher(
        vm, // vm 实例
        getter || noop, // getter 求值函数或者是一个空函数
        noop, // 空函数 function noop(a, b, c) {}
        computedWatcherOptions // computedWatcherOptions 常量对象 { lazy: true };
      );
    }
    if (!(key in vm)) {
      // 3. 
      defineComputed(vm, key, userDef);
    } else {
      
      if (key in vm.$data) {
        warn(("The computed property "" + key + "" is already defined in data."), vm);
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(("The computed property "" + key + "" is already defined as a prop."), vm);
      }
    }
  }
  }

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

defineComputed 定义计算属性。

if (!(key in vm)) {
  defineComputed(vm, key, userDef);
} else {
  ...
}
  
  function defineComputed (
  target,
   key,
   userDef
  ) {
    ...
    Object.defineProperty(target, key, sharedPropertyDefinition);
  }

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

var sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
  };

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

var shouldCache = !isServerRendering();
if (typeof userDef === 'function') {
  sharedPropertyDefinition.get = shouldCache
    ? createComputedGetter(key)
  : createGetterInvoker(userDef);
  sharedPropertyDefinition.set = noop;
} else {
  sharedPropertyDefinition.get = userDef.get
    ? shouldCache && userDef.cache !== false
    ? createComputedGetter(key)
  : createGetterInvoker(userDef.get)
  : noop;
  sharedPropertyDefinition.set = userDef.set || noop;
}
if (sharedPropertyDefinition.set === noop) {
  sharedPropertyDefinition.set = function () {
    warn(
      ("Computed property "" + key + "" was assigned to but it has no setter."),
      this
    );
  };
}

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

sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: function computedGetter () {
    var watcher = this._computedWatchers && this._computedWatchers[key];
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate();
      }
      if (Dep.target) {
        watcher.depend();
      }
      return watcher.value
    }
  },
  set: userDef.set || noop
}

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

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

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

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

Watcher.prototype.update = function update () {
  // computed Watcher
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) { // watch Watcher
    this.run();
  } else { 
    queueWatcher(this);
  }
  };

Vue3 Computed 原理分析

Vue 3.2.36

为了防止一部分同学对 vue3 的 computed 不是很熟悉,这里也会简单说下使用方式。

第一种使用方式,接受一个 getter 函数,并根据 getter 的返回值返回一个不可变的响应式 ref 对象

const count = ref(1)
const plusOne = computed(() => count.value + 1)

console.log(plusOne.value) // 2

plusOne.value++ // 错误

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

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

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

举个例子:

const data = { name: '张三', age: 18 };
const state = reactive(data);

const newAge = computed(() => state.age + 1);

effect(() => {
  document.getElementById('app').innerHTML = `${state.name}, 今年刚刚好${newAge.value}岁`
});

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

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

computed 本身有两种使用方式:

const xxx = computed(() => xxx)

const xxx1 = computed({get: () => {}, set: () => {}})

当我们在调用 computed 方法时,就会在这里需要统一做下区分,同时调用实现类ComputedRefImpl,这个方法比较简单,接下来我们重点分析下类ComputedRefImpl。

function computed(getterOrOptions, ...) {
  let getter;
  let setter;
  const onlyGetter = isFunction(getterOrOptions);
  if (onlyGetter) {
    getter = getterOrOptions;
    setter = () => {
      console.warn('Write operation failed: computed value is readonly');
    }
    ;
  }
  else {
    getter = getterOrOptions.get;
    setter = getterOrOptions.set;
  }
  const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR);
  ...
  return cRef;
}

ComputedRefImpl 类 :

class ComputedRefImpl {
  constructor(getter, _setter, isReadonly, isSSR) {
    this._setter = _setter;
    this.dep = undefined;
    this.__v_isRef = true;
    this._dirty = true;
    // 1.
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._dirty) {
        this._dirty = true;
        triggerRefValue(this);
      }
    });
    this.effect.computed = this;
    this.effect.active = this._cacheable = !isSSR;
    // 根据传入是否有setter函数来决定是否只读
    this["__v_isReadonly" /* IS_READONLY */] = isReadonly;
  }
  get value() {
    const self = toRaw(this);
    trackRefValue(self);
    if (self._dirty || !self._cacheable) {
      self._dirty = false;
      self._value = self.effect.run();
    }
    return self._value;
  }
  set value(newValue) {
    this._setter(newValue);
  }
}

这里将 ComputedRefImpl 类分为两块来解读。

第一块就是 computed 的初始化,调用 ComputedRefImpl constructor 初始化,主要做两件事情:

  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 缓存值的实现原理。

get value() {
  ...
  if (self._dirty || !self._cacheable) {
    self._dirty = false;
    self._value = self.effect.run();
  }
  return self._value;
}

那么 computed 是怎么知道要重新计算值的呢?

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

this.effect = new ReactiveEffect(getter, () => {
  
  // effect 的 scheduler 函数执行
  if (!this._dirty) {
    this._dirty = true;
    triggerRefValue(this);
  }
});

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

注意:上面提到的「第一次获取 computed 值」,这里并不是在初始化 computed 完成,因为初始化是不会调用 getter 。

调试 Computed

Vue 3.2 +

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

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

  • onTrack 会在某个响应式 property 或 ref 作为依赖被追踪时调用。
  • onTrigger 会在侦听回调被某个依赖的修改触发时调用。
const plusOne = computed(() => count.value + 1, {
  onTrack(e) {
    // 当 count.value 作为依赖被追踪时触发
    console.log(1, e);
  },
  onTrigger(e) {
    // 当 count.value 被修改时触发
    console.log(2, e);
  }
})
// 访问 plusOne,应该触发 onTrack
console.log(plusOne.value)
// 修改 count.value,应该触发 onTrigger
count.value++

这个调试 computed 在源码实现也比较简单,在 computed 初始化的时候,会将这个两个方法挂载 effect 上。

function computed(getterOrOptions, debugOptions, isSSR = false) {
  ...
  const cRef = new ComputedRefImpl(...);
  if (debugOptions && !isSSR) {
    cRef.effect.onTrack = debugOptions.onTrack;
    cRef.effect.onTrigger = debugOptions.onTrigger;
  }
  return cRef;
}

当 computed 的 getter 被执行时,会触发跟踪依赖属性的 trackRefValue 方法,如果存在 onTrack 就会执行 onTrack 回调。

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 就会重新计算这个值,从而达到动态计算的目的。

参考