【源码解析】剖析计算属性computed的实现原理

229 阅读6分钟

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

前言

大家好,继续我们的源码解析系列。上一篇文章component源码解析中我们分享了component的用法以及从源码层面分析了其实现原理。今天我们将继续分析vue中另一个比较常用的API - computed(计算属性)。相信大家对这个api应该并不陌生,并且相对于前面分享过的几个API,计算属性算是用的比较频繁的了。还是老套路,在解析源码之前我们先来简单了解下计算属性是干嘛的,为什么要使用计算属性?

了解computed

  • 什么是计算属性 从名称上来看我们应该也能猜出个一二来,所谓的计算属性其实也是组件实例上的一个响应式属性,它与普通属性的区别就在于计算两个字,也就是说通过计算属性可以实现一些简单的逻辑处理等。

  • 使用场景 有些时候我们不想把属性直接绑定在模板上,而是需要做一些特殊的处理之后再绑定,这个时候就可以使用计算属性来处理。比如某个属性msg中存了一串字符串,根据业务要求,需要让msg中的字符串全部转换成小写后再展示。 如果把转换逻辑直接写在模板里也是可以的,但这就违背了Vue简约设计的原则了,并且如果是逻辑比较复杂的话,直接写在模板中也不好维护。因此计算属性就是最好的选择了。

  • 特点

    • 响应式

    • 自动缓存(如果所依赖的属性不变,则不会重新计算)

  • 与method的区别 不管所依赖的属性是否发生变化,method每次都会执行;计算属性则不会重新计算而是直接从缓存中读取,只有依赖属性发生变化才会重新计算

  • 计算属性用法 计算属性有两种使用方式:一种是直接定义成一个函数,另一种是定义为一个包含get和set的对象。

// 定义为函数
computed:{
    toLowerMsg(){
        return msg.toLowerCase();
    }
}

//定义为对象
computed:{
    toLowerMsg:{
        get(){
            return msg.toLowerCase();
        },
        set(){
            ...
        }
    }
}

源码解析

了解了计算属性后,我们再来分析一下它的源码,看看它是如何把一个方法或对象变成一个属性的,如何实现响应式的,又是如何自动缓存并监听依赖属性发生变化才会重新计算的?

initCompted

function initComputed(vm: Component, computed: Object) {
  const watchers = (vm._computedWatchers = Object.create(null));
  const isSSR = isServerRendering();

  for (const key in 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);
    }

    if (!isSSR) {
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      );
    }

    if (!(key in vm)) {
      defineComputed(vm, key, userDef);
    } 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
        );
      } else if (vm.$options.methods && key in vm.$options.methods) {
        warn(
          `The computed property "${key}" is already defined as a method.`,
          vm
        );
      }
    }
  }
}

上面是initComputed方法等源码

  • 该函数接收两个参数:一个是Vue的实例vm,另一个是computed对象
  • 在函数体内首先来创建一个空对象,并分别赋值给实例的_computedWatchers和变量watchers
  • 然后通过for循环遍历computed对象,并取出对象中的计算属性赋值给userDef。const userDef = computed[key];
  • 接着下面的代码userDef是函数还是对象,如果是函数则直接赋值给getter,如果是对象则把对象中的get函数赋值给getter(对应前面提到的计算属性的两种用法)
  • 继续向下,判断如果是非服务端渲染,则创建一个Watcher实例并以计算属性名为key保存到对象watchers中,对应的4个参数分别是Vue的实例vm,计算属性getter,空函数noop,和对象{lazy: true}
  • 最后再判断如果计算属性在实例中不存在则调用defineComputed对计算属性进行数据劫持,否则就说明该计算属性已经在data/props/methods被定义,给出错误提示。

defineComputed

export function defineComputed(
  target: any,
  key: string,
  userDef: Record<string, any> | Function
) {
  const 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 (
    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);
}

defineComputed函数的核心其实就是将计算属性通过Object.defineProperty挂载到vue的实例上,并对该属性的get和set进行数据劫持

  • 该函数接收三个参数分别是target其实就是vue的实例,key计算属性名和userDef计算属性的定义(函数或对象)
  • 接着就是一堆if else判断,其核心就是执行createComputedGetter或createGetterInvoker函数,然后将函数的返回值(也是个函数)赋值给sharedPropertyDefinition对象的get
  • sharedPropertyDefinition对象中保存的就是Object.defineProperty第三个参数中的一些配置项,如: enumerable, configurable,get和set等
  • 最后执行Object.defineProperty将计算属性添加到Vue实例上,并通过sharedPropertyDefinition对象中的配置对get和set进行劫持

createComputedGetter

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作为Object.defineProperty的第三个参数配置项的get函数,因此也就是说当计算属性被访问时就会触发computedGetter函数执行

  • this指向的是Vue的实例,首先根据计算属性名到_computedWatchers对象中取得对应的计算属性
  • 判断如果watcher存在,继续检测watcher的dirty属性,如果为true则执行watcher中的evaluate函数
    • 这里watcher的dirty是哪来的呢?其实这个dirty就是我们上面在new Watcher时传递的第4个参数{lazy:true}
    • 定位到Watcher的构造函数中会发现有如下代码:
    this.lazy = !!options.lazy
    ...
    this.dirty = this.lazy // for lazy watchers
    
    • 因此watcher.dirty对应的就是watcher.lazy

    实际上,这个dirty属性也是计算属性缓存的关键,因为第一次进来dirty值为true,并且会触发watcher的evaluate函数,从而让计算属性原始的逻辑执行,同时将dirty置为false。下次再进来时如果所依赖的属性没有发生变化,则dirty属性一直为false,这时就不再执行evaluate函数,而是将上一次计算好的watcher.value直接返回。直到所依赖的属性发生变化时会将dirty重新置为true,然后重新计算获取新值。

  • 最后再进行依赖收集并将watcher的value返回(这个value值其实就是计算属性的返回值)

watcher.evaluate / watcher.get

 evaluate() {
    this.value = this.get()
    this.dirty = false
  }
  
  get() {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e: any) {
      if (this.user) {
        handleError(e, vm, `getter for watcher "${this.expression}"`)
      } else {
        throw e
      }
    } finally {
      // "touch" every property so they are all tracked as
      // dependencies for deep watching
      if (this.deep) {
        traverse(value)
      }
      popTarget()
      this.cleanupDeps()
    }
    return value
  }
  • 上面的代码就是对应的watcher的evaluate和get。在执行evaluate时就做了两件事:一个是让watcher中的get函数执行并将返回值赋值给value;第二件事则是将dirty属性设置为false。
  • 而watcher的get函数执行时其核心就是this.getter.call(vm, vm)这句,this就是watcher的实例,而getter则是在new Watcher时传递的第二个参数,也就是具体的计算属性的定义,也就是说真正的计算属性的逻辑是在这里执行的

总结

本次分享我们从计算属性的用法、特点和使用场景入手对计算属性做了全面了解,又对其源码进行分析,简单总结如下:

计算属性可以理解成一个惰性watcher。我们在调用某个计算属性的时候就会触发这个属性的get函数,而get函数对应的就是createComputedGetter函数的返回结果,也是一个函数computedGetter,也就是说我们在使用计算属性的时候获取到的其实是computedGetter函数的返回结果,而这个函数的返回结果又是该计算属性对应的那个watcher的value值。 初始使用计算属性的时候会调用用户定义的那个get函数,这个函数中使用的那些vue变量会触发这个属性的依赖收集,收集的时候就会把当前计算属性对应的watcher收集到这些vue变量的subs中,当这些变量更新的时候就会触发它自己的所有的watcher的update,执行update的时候就会把dirty属性设置为true,这样的话当模板重新调用这个计算属性的时候就会触发计算属性对应的watcher的evaluate方法,该方法一执行就会更新当前watcher的value

关于计算属性原理就先到这里了。喜欢的小伙伴们点个赞哦!