Vue中computed的本质—lazy Watch

6,514 阅读4分钟

两个月前我曾在掘金翻译了一篇关于Vue中简单介绍computed是如何工作的文章,翻译的很一般所以我就不贴地址了。有位我非常敬佩的前辈对文章做了评价,内容就是本文的标题“感觉原文并没有讲清楚 computed 实现的本质- lazy watcher”。上周末正好研究一下Vue的源码,特意看了computed,把自己看的成果和大家分享出来。

Tips:如果你之前没有看过Vue的源码或者不太了解Vue数据绑定的原理的话,推荐你看我之前的一篇文章简单易懂的Vue数据绑定源码解读,或者其他论坛博客相关的文章都可以(这种文章网上非常多)。因为要看懂这篇文章,是需要这个知识点的。

一. initComputed 

首先,先假设传入这样的一组computed

//先假设有两个data: data_one 和 data_two
computed:{
    isComputed:function(){
        return this.data_one + 1;
    },
    isMethods:function(){
        return this.data_two + this.data_one;
    }
}

我们知道,在new Vue()的时候会做一系列初始化的操作,Vue中的data,props,methods,computed都是在这里初始化的:

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props) //初始化props
  if (opts.methods) initMethods(vm, opts.methods) //初始化methods
  if (opts.data) {
    initData(vm) //初始化data
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed) //初始化computed
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch) //初始化initWatch
  }
}

我在数据绑定的那边文章里,详细介绍了initData()这个函数,而这篇文章,我则重点深入initComputed()这个函数。

const computedWatcherOptions = { lazy: true } //用于传入Watcher实例的一个对象

function initComputed (vm: Component, computed: Object) {
  //声明一个watchers,同时挂载到Vue实例上
  const watchers = vm._computedWatchers = Object.create(null)
  //是否是服务器渲染
  const isSSR = isServerRendering()

  //遍历传入的computed
  for (const key in computed) {
    //userDef是computed对象中的每一个方法
    const userDef = computed[key]
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(
        `Getter is missing for computed property "${key}".`,
        vm
      )
    }
    
    //如果不是服务端渲染的,就创建一个Watcher实例
    if (!isSSR) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

    if (!(key in vm)) {
      //如果computed中的key没有在vm中,通过defineComputed挂载上去
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      //后面都是警告computed中的key重名的
      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)
      }
    }
  }
}

initComputed之前,我们看到声明了一个computedWatcherOptions的对象,这个对象是实现"lazy Watcher"的关键。

接下来看initComputed,它先声明了一个名为watchers的空对象,同时在vm上也挂载了这个空对象。之后遍历计算属性,并把每个属性的方法赋给userDef,如果userDef是function的话就赋给getter,接着判断是否是服务端渲染,如果不是的话就创建一个Watcher实例。Watcher实例我也在上一篇文章分析过,就不逐行分析了,不过需要注意的是,这里新建的实例中我们传入了第四个参数,也就是computedWatcherOptions,这时,Watcher中的逻辑就有变化了:

//这段代码在Watcher类中,文件路径为vue/src/core/observer/watcher.js
if (options) {
    this.deep = !!options.deep
    this.user = !!options.user
    this.lazy = !!options.lazy
    this.sync = !!options.sync
 } else {
    this.deep = this.user = this.lazy = this.sync = false
 }

这里的options指的就是computedWatcherOptions,当我们走initData的逻辑的时候,options并不存在,所以this.lazy = false,但当我们有了computedWatcherOptions后,this.lazy = true。同时,后面还有这样一段代码:this.dirty = this.lazydirty的值也为true了。

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

这段代码我们可以知道,当lazyfalse时,返回的是undefined而不是this.get()方法。也就是说,并不会执行computed中的两个方法:(请看我开头写的computed示例)

function(){
  return this.data_one + 1;
}
function(){
  return this.data_two + this.data_one;
}

这也就意味着,computed的值还并没有更新。而这个逻辑也就暂时先告一段落。

二. defineProperty

让我们再回到initComputed函数中来:

if (!(key in vm)) {
   //如果computed中的key没有在vm中,通过defineComputed挂载上去
   defineComputed(vm, key, userDef)
} 

可以看到,当key值没有挂载到vm上时,执行defineComputed函数:

//一个用来组装defineProperty的对象
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

export function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  //是否是服务端渲染,注意这个变量名 => shouldCache
  const shouldCache = !isServerRendering()
  if (typeof userDef === 'function') {
    //如果userDef是function,给sharedPropertyDefinition.get也就是当前key的getter
    //赋上createComputedGetter(key)
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : userDef
    sharedPropertyDefinition.set = noop
  } else {
    //否则就使用userDef.get和userDef.set赋值
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : userDef.get
      : noop
    sharedPropertyDefinition.set = userDef.set
      ? userDef.set
      : noop
  }
  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
      )
    }
  }
  //最后,我们把这个key挂载到vm上
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

defineComputed中,先判断是否是服务端渲染,如果不是,说明计算属性是需要缓存的,即shouldCache是为true 。接下来,判断userDef是否是函数,如果是就说明是我们常规computed的用法,将getter设为createComputedGetter(key)的返回值。如果不是函数,说明这个计算属性是我们自定义的,需要使用userDef.getuserDef.set来为gettersetter赋值了,这个else部分我就不详细说了,不会到自定义computed的朋友可以看文档计算属性的setter。最后,将computed的这个key挂载到vm上,当你访问这个计算属性时就会调用getter。

function createComputedGetter (key) {
  return function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

最后我们来看createComputedGetter这个函数,他返回了一个函数computedGetter,此时如果watcher存在的情况下,判断watcher.dirty是否存在,根据前面的分析,第一次新建Watcher实例的时候this.dirty是为true的,此时调用watcher.evaluate()

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

this.get()实际上就是执行计算属性的方法。之后将this.dirty设为false。另外,当我们执行this.get()时是会为Dep.target赋值的,所以还会执行watcher.depend(),将计算属性的watcher添加到依赖中去。最后返回watcher.value,终于,我们获取到了计算属性的值,完成了computed的初始化。

三. 计算属性的缓存——lazy Watcher

不过,此时我们还并没有解决本文的重点,也就是"lazy watcher"。还记得Vue官方文档是这样形容computed的:

我们可以将同一函数定义为一个方法而不是一个计算属性。两种方式的最终结果确实是完全相同的。然而,不同的是计算属性是基于它们的依赖进行缓存的。计算属性只有在它的相关依赖发生改变时才会重新求值。这就意味着只要 message 还没有发生改变,多次访问 reversedMessage 计算属性会立即返回之前的计算结果,而不必再次执行函数。

回顾之前的代码,我们发现只要不更新计算属性的中data属性的值,在第一次获取值后,watch.lazy始终为false,也就永远不会执行watcher.evaluate(),所以这个计算属性永远不会重新求值,一直使用上一次获得(也就是所谓的缓存)的值。

一旦data属性的值发生变化,根据我们知道会触发update()导致页面重新渲染(这部分内容有点跳,不清楚的朋友一定先弄懂data数据绑定的原理),重新initComputed,那么this.dirty = this.lazy = true,计算属性就会重新取值。

OK,关于computed的原理部分我就说完了,不过这篇文章还是留了个坑,在createComputedGetter函数中有这样一行代码:

const watcher = this._computedWatchers && this._computedWatchers[key]

根据上下文我们可以推测出this._computedWatchers中肯定保存着initComputed时创建的watcher实例,但什么时候把这个实例放到this._computedWatchers中的呢?我还没有找到,如果有知道的朋友请留言分享,大家一起讨论,非常感谢!