结合源码彻底理解Vue计算属性原理

738 阅读7分钟

基本介绍

计算属性不是API,它是Watcher类的最后也是最复杂的一种实例化的使用。本文其实主要就是分析计算属性为何可以做到当它的依赖项发生改变时才会进行重新的计算,否则当前数据是被缓存的。

在Vue中watcher类主要有三种:渲染(render)wather、用户(user)watcher以及计算(computed)watcher。

基本使用

<!DOCTYPE html>
<html>
 <head>
  <meta charset="UTF-8">
  <title>computed</title>
  <script type="text/javascript" src="../js/vue.js" ></script>
  <script>
   window.onload = () => {
    new Vue({
     el : 'div',
     data : {
       firstName : 'code',
       lastName'Lee'
     },
     computed : {
      name() {
       return this.firstName + this.lastName;
      }
     } 
    });
   }
  </script>
 </head>
 <body>
  <div>
   姓名: {{name}}
  </div>
 </body>
</html>

当firstName或lastName的值发生变化时,name的值会自动更新,并且会自动同步更新DOM部分。

computed和watch的差异区别:

  1. computed 是计算一个新的属性,并将该属性挂载到 vm(Vue 实例)上,而 watch 是监听已经存在且已挂载到 vm 上的数据,所以用 watch 同样可以监听 computed 计算属性的变化。
  2. computed 本质是一个惰性求值的观察者,具有缓存性,只有当依赖变化后,第一次访问 computed 属性,才会计算新的值,而 watch 则是当数据发生变化便会调用执行函数。
  3. 从使用场景上说,computed 适用一个数据被多个数据影响,而 watch 适用一个数据影响多个数据。

源码解析

在分析computed源码之前我们先得对Vue的响应式系统有一个基本的了解。

Vue响应系统,其核心有三点:observe、watcher、dep:

  • observe:订阅者,遍历 data 中的属性,使用 Object.defineProperty 的 get/set 方法对其进行数据劫持;
  • dep:每个属性拥有自己的消息订阅器 dep,用于存放所有订阅了该属性的观察者对象;
  • watcher:观察者(对象),通过 dep 实现对响应属性的监听,监听到结果后,主动触发自己的回调进行响应。

响应式源码写在了defineReactive这个函数中:

export function defineReactive (
  obj: Object,
  keystring,
  val: any,
  customSetter?: Function
) {
  /*在闭包中定义一个dep对象*/
  const dep = new Dep()
  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  /*如果之前该对象已经预设了getter以及setter函数则将其取出来,新定义的getter/setter中会将其执行,保证不会覆盖之前已经定义的getter/setter。*/
  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set

  /*对象的子对象递归进行observe并返回子节点的Observer对象*/
  let childOb = observe(val)
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    getfunction reactiveGetter () {
      /*如果原本对象拥有getter方法则执行*/
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        /*进行依赖收集*/
        dep.depend()
        if (childOb) {
          /*子对象进行依赖收集,其实就是将同一个watcher观察者实例放进了两个depend中,一个是正在本身闭包中的depend,另一个是子元素的depend*/
          childOb.dep.depend()
        }
        if (Array.isArray(value)) {
          /*是数组则需要对每一个成员都进行依赖收集,如果数组的成员还是数组,则递归。*/
          dependArray(value)
        }
      }
      return value
    },
    setfunction reactiveSetter (newVal) {
      /*通过getter方法获取当前值,与新值进行比较,一致则不需要执行下面的操作*/
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      if (setter) {
        /*如果原本对象拥有setter方法则执行setter*/
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      /*新的值需要重新进行observe,保证数据响应式*/
      childOb = observe(newVal)
      /*dep对象通知所有的观察者*/
      dep.notify()
    }
  })
}

把一个普通的JavaScript对象传给Vue实例的data选项时,Vue将遍历此对象所有的属性,并使用Object.defineProperty把这些属性全部转为getter/setter,get方法中会进行依赖收集,放在了dep中,在属性被访问和修改时(set方法会触发)通知变化(调用notify方法),相应dep里的依赖watcher会依次执行,调用update方法去异步渲染。

图解

图解

对Vue响应式原理有了一定了解后,我们再回过头接着分析计算属性。 首先我们找到计算属性的初始化是在src/core/instance/state.js 文件中的initState函数中完成的:

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

调用了initComputed函数,函数包含两个参数:vm实例和opt.computed开发者定义的computed 选项。

我们接着看initComputed函数里面做了什么(源码位置:/src/core/instance/state.js ):

const computedWatcherOptions = { computed: true }
function initComputed (vm: Component, computed: Object) {
  // $flow-disable-line
  const watchers = vm._computedWatchers = Object.create(null)
  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
      )
    }
    watchers[key] = new Watcher(vm, getter, () => {}, { lazy: true })
    // component-defined computed properties are already defined on the
    // component prototype. We only need to define computed properties defined
    // at instantiation here.
    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)
      }
    }
  }
}

这段源码比较多,我们分几个部分来分析:

  1. 获取计算属性的getter求值函数:
const userDef = computed[key]
const getter = typeof userDef === 'function' ? userDef : userDef.get

定义一个计算属性有两种写法,一种是直接跟一个函数,另一种是添加 set 和 get 方法的对象形式,所以这里首先获取计算属性的定义 userDef,再根据 userDef 的类型获取相应的 getter 求值函数。

  1. 给每一个computed创建一个computed watcher。大家都知道computed是有缓存的,所以创建watcher的时候,会传一个配置{ lazy: true },同时也可以区分这是computed watcher,然后到watcher里面接收到这个对象。
watchers[key] = new Watcher(vm, getter, () => {}, { lazytrue })
  1. defineComputed定义计算属性函数
if (!(key in vm)) {
  defineComputed(vm, key, userDef)
}

因为 computed 属性是直接挂载到实例对象中的,所以在定义之前需要判断对象中是否已经存在重名的属性,defineComputed 传入了三个参数:vm实例、计算属性的 key 以及 userDef 计算属性的定义(对象或函数)。在往下找defineComputed定义处前,我们需要回过头先看看第二点Watcher类里面有些什么:

class Watcher{
    this.vm = vm
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn
    } else {
      this.getter = parsePath(exprOrFn) // computed的回调函数
    }
    if (options) {
      this.lazy = !!options.lazy // 
      this.user = !!options.user // 
    } else {
      this.user = this.lazy = false
    }
    this.dirty = this.lazy  // dirty为标记位,表示是否对computed计算
    this.cb = cb
    this.options = options
    this.id = wId++
    this.deps = []
    this.depsId = new Set() // 
    this.value = this.lazy ? undefined : this.get()
  ...
}
export default class Dep {
  static target: ?Watcher;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

Dep.target = null

Dep精简了部分代码,我们观察 Watcher 和 Dep 的关系,用一句话总结:

watcher 中实例化了 dep 并向 dep.subs 中添加了订阅者,dep 通过 notify 遍历了 dep.subs 通知每个 watcher 更新。

这里实例化已经结束了。并没有和之前render-watcher以及user-watcher那般,执行get方法,这是为什么?我们接着分析为何如此,defineComputed的方法(源码位置:/src/core/instance/state.js ):

function defineComputed(target: any,keystring,userDef: Object | Function) {
  ...
  Object.defineProperty(target, key, {
    enumerable: true,
    configurable: true,
    get: createComputedGetter(key),
    set: noop
  })
}

这个方法的作用就是让computed成为一个响应式数据,并定义它的get属性,也就是说当页面执行渲染访问到computed时,才会触发get然后执行createComputedGetter方法,看下get方法是怎么定义的(源码位置:/src/core/instance/state.js):

// 创建computedGetter函数
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) { // 在实例化watcher时为true,表示需要计算
        watcher.evaluate()
      }
      // 把渲染watcher 添加到属性的订阅里面去,这很关键
      if (Dep.target) { // 当前的watcher,这里是页面渲染触发的这个方法,所以为render-watcher
        watcher.depend() // 收集当前watcher
      }
      return watcher.value // 返回求到的值或之前缓存的值
    }
  }
}

初始化computed-watcher时,dirty传入的为true,这个时候是没有缓存的,所以需要计算。调用watcher.evaluate()进行计算。createComputedGetter方法里面涉及到了watcher的evaluate()和depend()方法。

class Watcher {
  ...
  
  get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      value = this.getter.call(vm, vm)
    } catch (e) {
      
    } finally {
      popTarget()
    }
    return value
  }
  
  update () {
    if (this.lazy) {
      // 计算watcher渲染
      this.dirty = true 
    } else if (this.sync) 
      // 同步渲染
      this.run()
    } else {
      // 渲染watcher异步渲染
      queueWatcher(this)
    }
  }

  evaluate () {
    this.value = this.get()  //  计算属性求值
    this.dirty = false  // 表示计算属性已经计算,不需要再计算
  }

  depend () {
    if (this.dep && Dep.target) {
      this.dep.depend()
    }
  }
}

这里的computed-watcher实例里面提供了两个属性定义的方法,在执行evaluate方法进行求值的过程中又会触发computed内可以访问到的响应式数据的get,它们会将当前的computed-watcher作为依赖收集到自己的dep里,计算完毕之后将dirty置为false,表示已经计算过了。

分析完源码之后,我们最后做个总结:

计算属性原理和响应式原理都是大同小异的,同样的是使用数据劫持以及依赖收集,不同的是计算属性有做缓存优化,只有在依赖属性变化时才会重新求值,其它情况都是直接返回缓存值。服务端不对计算属性缓存。

计算属性更新的前提需要“渲染Watcher”的配合,因此依赖属性的 subs 中至少会存储两个Watcher。