Vue2源码学习笔记之计算属性

下面看下 Vue 的计算属性的实现,计算属性收集依赖和触发依赖的方式和普通的响应式数据是不一样的。 回到 initState 函数中,在 core/instance/state.js 文件中,看下面的代码。 如果设置了 computed 选

目录
  1. initComputed
  2. defineComputed
    1. createComputedGetter
    2. createGetterInvoker
  3. 计算属性和普通属性的差异
    1. 普通属性收集依赖
    2. 计算属性收集依赖
    3. 计算属性的值的更新
  4. 计算属性的更新流程
  5. 参考资料

下面看下 Vue 的计算属性的实现,计算属性收集依赖和触发依赖的方式和普通的响应式数据是不一样的。

回到initState函数中,在core/instance/state.js文件中,看下面的代码。

if (opts.computed) initComputed(vm, opts.computed);

如果设置了computed选项,就调用initComputed初始化选项。

initComputed#

看下initComputed函数的代码

// ! 初始化计算属性选项
function initComputed(vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = (vm._computedWatchers = Object.create(null)); // ! 维护一个常量存储计算属性
  // computed properties are just getters during SSR
  const isSSR = isServerRendering();
  // ...
}

首先声明一个常量watchers存储vm._computedWatchers属性,默认值是空对象,vm._computedWatchers属性存储实例对象的计算属性观察者。

声明常量isSSR存储isServerRendering函数的执行结果,即判断当前的环境是不是服务端渲染。

继续看下面的代码。

// ! 遍历计算属性
for (const key in computed) {
  const userDef = computed[key]; // ! 获取每个计算属性的函数或者对象
  const getter = typeof userDef === "function" ? userDef : userDef.get; // ! 获取 getter
 
  // ! 计算属性没有 getter 发出警告
  if (process.env.NODE_ENV !== "production" && getter == null) {
    warn(`Getter is missing for computed property "${key}".`, vm);
  }
}

遍历计算属性选项computed,声明常量userDef存储每一个计算属性。

声明常量getter存储计算属性的getter,首先判断userDef是不是函数类型,如果是的话,直接赋值。如果不是的话,把它的get属性赋值给getter

如果获取到的getter不存在,在非生产环境中会报错,警告计算属性缺少getter值。

继续看下面的代码。

if (!isSSR) {
  // create internal watcher for the computed property.
  // ! 创建计算属性的观察者
  watchers[key] = new Watcher(
    vm,
    getter || noop,
    noop,
    computedWatcherOptions // ! 计算属性选项 { lazy: true }
  );
}

如果不是服务端渲染,创建计算属性的观察者,并把它存储到watchers中,观察者对应的名称就是key

其中传入上面获取的getter作为第二个参数expOrFn传入,如果没有getter就传入一个空函数。第四个参数options传入的是常量computedWatcherOptions,它的值是{ lazy: true }

继续看剩下的代码。

if (!(key in vm)) {
  defineComputed(vm, key, userDef); // ! 实现计算属性
 
  // ! 如果计算属性名称在 data 和 props 被使用了,发出警告
} else if (process.env.NODE_ENV !== "production") {
  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);
  }
}

判断计算属性的key是否存在组件实例中,如果不存在,使用defineComputed函数把计算属性代理到实例中,并且把它变成响应式数据。

另外,判断计算属性的key是否存在于data或者props选项中,如果存在,在非生产环境中会报错,提示计算属性的key值已经被占用了。

defineComputed#

下面看下defineComputed函数的代码。

// ! 实现计算属性
export function defineComputed(
  target: any,
  key: string,
  userDef: Object | Function
) {
  const shouldCache = !isServerRendering(); // ! 是否为服务端渲染,服务端渲染不需要缓存
  // ...
}

声明常量shouldCache,它表示是否是需要缓存,它的值和服务端渲染互斥,因为服务端渲染不需要缓存。

继续看下面的代码。

// ! 计算属性是函数时
if (typeof userDef === "function") {
  sharedPropertyDefinition.get = shouldCache
    ? createComputedGetter(key) // ! 创建计算属性的 getter
    : createGetterInvoker(userDef);
  sharedPropertyDefinition.set = noop; // ! setter 为空函数
}

当获取的计算属性userDef是函数时,调用createComputedGetter函数创建计算属性的getter,否则调用createGetterInvoker函数创建计算属性的getter,这两个方法创建的getter是不一样的,最后把getter赋值给sharedPropertyDefinitionget属性。

因为用户定义的计算属性是函数时是没有setter的,所以sharedPropertyDefinition对象的set为空函数。

看下sharedPropertyDefinition对象的代码。

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

这是一个对象,用来设置Object.defineProperty中的属性描述符,getset初始值是空函数,需要后面进行重写。

下面看下createComputedGetter函数和createGetterInvoker函数是怎么创建getter函数的。

createComputedGetter#

先看createComputedGetter函数的代码,这里传入的参数是key,它是字符串类型。

// ! 创建计算属性 getter 的函数,需要根据 dirty 更新值和收集依赖
// ! 返回一个新的 computedGetter 函数
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key]; // ! 获取计算属性观察者
    if (watcher) {
      // ! 如果 dirty 为 true 时,表示计算属性观察者可以执行更新
      if (watcher.dirty) {
        watcher.evaluate(); // ! 调用 evaluate 更新计算属性的值
      }
      // ...
    }
  };
}

因为getter是一个函数,所以这里也返回一个函数。

先尝试通过key值在vm._computedWatchers中获取到我们定义的计算属性观察者,如果能获取到,判断观察者对象的dirty属性是不是true,如果是的话,说明这个计算属性观察者可以执行更新,然后调用观察者的evaluate方法更新计算属性的值。

看下evaluate方法的代码,在core/observer/watcher.js文件中。

evaluate() {
  this.value = this.get() // ! 获取计算属性的新值,并触发它所依赖的响应式对象收集这个计算属性观察者
  this.dirty = false // ! 更新计算属性的值后重新设置为 false,这时再次访问计算属性时,它就不会再求值了
}

通过调用get方法获取新值,因为get方法中会调用this.getter的方法,也就是我们创建观察者时传入的第二个参数,这样就会重新求值,在求值的过程中会访问到计算属性依赖的那个响应式数据,并触发这个数据收集当前的计算属性观察者作为依赖。

最后重新将dirty设置为false,这时候,如果再次访问计算属性,就不会再调用evaluate更新计算属性的值。因为计算属性依赖的响应式数据没有更新值,重新求值也还是和以前的值一样,浪费性能。只有在计算属性观察者所依赖的响应性数据更新时,才会触发计算属性观察者的update方法,把dirty重新设置为true

update() {
  if (this.lazy) {
    this.dirty = true
  }
}

继续看createComputedGetter函数下面的代码。

if (Dep.target) {
  watcher.depend(); // ! 计算属性收集依赖
}
return watcher.value;

判断Dep.target是否有值,如果有值,计算属性观察者收集依赖,这里才是计算属性本身收集依赖,是通过它的观察者来收集依赖的。最后返回观察者对象的value值,即上面evaluate获取的值。

createGetterInvoker#

下面看下createGetterInvoker函数的代码,它是创建服务器渲染时的getter

它的参数是一个函数,如果userDef是函数,直接传入它。如果userDef是对象,传入的是userDef.get属性,也就是getter函数。

// ! 创建计算属性调用钩子函数,自己调用函数,用于服务端渲染中
function createGetterInvoker(fn) {
  return function computedGetter() {
    return fn.call(this, this);
  };
}

服务端渲染中创建的getter,也是返回一个函数。但是这里不用收集依赖和处理dirty属性,而是直接调用传入的getter函数。

下面看下defineComputed是如何处理计算属性是对象类型的。

// ! 计算属性是对象时
else {
  sharedPropertyDefinition.get = userDef.get
    ? shouldCache && userDef.cache !== false
      ? createComputedGetter(key)
      : createGetterInvoker(userDef.get)
  	: noop
  sharedPropertyDefinition.set = userDef.set || noop // ! setter 为 set 函数
}

先判断userDef是否存在get属性,如果不存在,sharedPropertyDefinition.get为空函数。如果存在,再判断shouldCache是否为真且用户没有定义过cache属性为假时,调用createComputedGetter函数创建getter,否则调用createGetterInvoker函数创建getter,这里传入的参数是userDef.get

这两个函数在前面已经学习过了,这里就不再赘述了。

因为userDef是对象,里面一般是有set属性的,如果有set属性,直接赋值给sharedPropertyDefinition.set的属性,否则赋值为空函数。

继续看下面的代码。

if (
  process.env.NODE_ENV !== "production" &&
  sharedPropertyDefinition.set === noop
) {
  sharedPropertyDefinition.set = function () {
    warn(
      `Computed property "${key}" was assigned to but it has no setter.`,
      this
    );
  };
}
Object.defineProperty(target, key, sharedPropertyDefinition); // ! 把计算属性代理到实例对象中

在非生产环境中,如果sharedPropertyDefinition.set的值经过上面的逻辑之后还是一个空函数,也就是说用户没有设置setter。这时会把sharedPropertyDefinition.set赋值成一个警告函数,当你修改计算属性的值时,会提醒你这个计算属性没有设置setter,不能修改它的值。

最后使用Object.defineProperty定义targetkey,而它的属性描述符就是上面代码中的定义的sharedPropertyDefinition对象,这里的target就是 Vue 的实例对象,把计算属性代理到实例对象中。

使用计算属性示例代码,这里使用官方文档的例子。

// 计算属性是函数,这个函数必须有返回值,是一个 getter
computed: {
  fullName: function () {
      return this.firstName + ' ' + this.lastName
    }
  }
}
 
// 计算属性是对象,这个对象里面一般是 getter 和 setter
computed: {
  fullName: {
    // getter
    get: function () {
      return this.firstName + ' ' + this.lastName
    },
    // setter
    set: function (newValue) {
      var names = newValue.split(' ')
      this.firstName = names[0]
      this.lastName = names[names.length - 1]
    }
  }
}

计算属性和普通属性的差异#

这里比较一下计算属性和普通属性的差异

普通属性收集依赖#

普通属性是在创建观察者实例对象时收集依赖的。在创建观察者的过程中,会执行观察者的构造函数方法,构造函数方法的最后会调用get方法获取观察目标的值,获取值的同时会触发观察目标的gettergetter就会把观察者收集到dep中。

计算属性收集依赖#

计算属性不是在创建观察者实例的过程中收集依赖。

在创建计算属性观察者时,会传入一个选项options,它的值是{ lazy: true }

watchers[key] = new Watcher(
  vm,
  getter || noop,
  noop,
  computedWatcherOptions // ! 计算属性选项 { lazy: true }
);

然后在观察者的构造函数的最后一行代码中,会判断this.lazy的值是否为true,如果为true,不会调用get函数获取值和收集依赖,而是返回一个undefined值。

this.lazy = !!options.lazy;
this.value = this.lazy ? undefined : this.get();

那计算属性什么时候收集依赖呢?其实是在访问计算属性的时候收集依赖的。

看下它在非服务端渲染时是怎么创建getter的。

function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key]; // ! 获取计算属性观察者
    if (watcher) {
      // ! 如果 dirty 为 true 时,表示计算属性的依赖已经更新,现在可以更新计算属性的值
      if (watcher.dirty) {
        watcher.evaluate(); // ! 调用 evaluate 更新计算属性的值
      }
      if (Dep.target) {
        watcher.depend(); // ! 计算属性收集依赖
      }
      return watcher.value; // ! 返回计算属性的值
    }
  };
}

首先获取到计算属性的观察者,然后判断观察者的dirty属性的值是否为真,如果为真,说明计算属性的所依赖的响应式数据已经更新,然后调用观察者的evaluate方法更新观察者的值,也就是更新计算属性的值。然后判断Dep.target是否有值,如果有值的话,调用观察者的depend收集依赖。

计算属性的值的更新#

我们看下evaluate是如何更新计算属性的值的。

evaluate() {
  this.value = this.get() // ! 获取计算属性的新值,并触发它所依赖的响应式对象收集这个计算属性观察者
  this.dirty = false // ! 更新计算属性的值后重新设置为 false,这时再次访问计算属性时,它就不会再求值了
}

还是调用观察者的get方法来更新值,更新后重新把this.dirty设置为false。之后,再访问计算属性后,就不会执行evaluate求值了,因为它的值还是一样的。

只有在它所依赖的响应式对象发现变化时,触发计算属性观察者的update函数,把dirty设置为true。这时候再访问计算属性,它就会调用evaluate方法重新求值。

小结:计算属性不但是收集依赖是在访问属性的时候,更新值也是在访问属性的时候。

计算属性的更新流程#

在初始化computed选项的时候,创建计算属性的观察者,并传入选项options,它的值为{ lazy: true }

watchers[key] = new Watcher(
  vm,
  getter || noop,
  noop,
  computedWatcherOptions // ! 计算属性选项 { lazy: true }
);

在创建过程中会执行观察者的构造函数的代码,在代码最后一行是下面这样的。

this.value = this.lazy ? undefined : this.get();

所以计算属性创建之后没有求值,它的值是undefined

只有在我们访问计算属性的时候,触发它的getter,它才会求值。

return function computedGetter() {
  const watcher = this._computedWatchers && this._computedWatchers[key]; // ! 获取观察者
  if (watcher) {
    // ! 如果 dirty 为 true 时,表示计算属性观察者可以更新
    if (watcher.dirty) {
      watcher.evaluate(); // ! 调用 evaluate 更新计算属性的值
    }
    if (Dep.target) {
      watcher.depend(); // ! 计算属性收集依赖
    }
    return watcher.value; // ! 返回计算属性的值
  }
};

这个求值也有条件的,这也是计算属性性能好的地方。

什么条件?就是计算属性观察者的dirty属性必须为true

在我们创建计算属性观察者的时候,dirty属性的值和传进来的lazy值相同的,它的值为true

this.lazy = !!options.lazy;
this.dirty = this.lazy; // for lazy watchers

所以初始化computed选项后,里面的计算属性都有一次求值机会,看下evaluate函数代码。

evaluate() {
  this.value = this.get() // ! 获取计算属性的新值,并触发它所依赖的响应式对象收集这个计算属性观察者
  this.dirty = false // ! 更新计算属性的值后重新设置为 false,这时再次访问计算属性时,它就不会再求值了
}

通过调用get方法更新计算属性的值,同时会触发它所依赖的响应式对象收集这个计算属性观察者。更新计算属性的值之后,会重新把dirty属性设置为false。之后,我们再次访问计算属性时,因为dirty的值为false就不会通过观察者函数的evaluate方法重写求值。

因为计算属性的更新值是在访问它的时候,不可能每次访问都去求值,这会非常影响性能。所以每次访问的时候,如果它所依赖的响应性数据没有更新,计算属性就一直是那个值。

那什么时候计算属性的值会更新呢?只在它所依赖的响应性对象发现变化后才会更新。这又是什么原理呢?

因为计算属性观察者已经是计算属性所依赖的响应式数据的依赖,当这个数据发生更新时,会触发setter,执行所有的依赖更新,这包括了这个计算属性观察者的更新,观察者更新是会调用update方法的,而update方法前面的代码会把dirty属性重新设置为true

update() {
  /* istanbul ignore else */
  // ! 如果启动懒惰模式  => 更新 dirty 为 true,说明可以更新计算属性的值
  if (this.lazy) {
    this.dirty = true
  }
  // ...
}

因为计算属性观察者的lazy属性是true,所以这里设置dirtytrue

然后,你再访问计算属性时,触发它的getter,此时dirty的值为true,就会调用evaluate函数更新计算属性的值。

function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key];
    if (watcher) {
      // ! 如果 dirty 为 true 时,表示计算属性观察者可以更新
      if (watcher.dirty) {
        watcher.evaluate(); // ! 调用 evaluate 更新计算属性的值
      }
      // ...
      return watcher.value; // ! 返回计算属性的值
    }
  };
}
 
evaluate() {
  this.value = this.get();
  this.dirty = false;
}

参考资料#