响应式原理五:computed

534 阅读5分钟

在平时的项目开发中,经常都会使用到计算属性 computed 和侦听属性 watch,那么在使用 Vue 提供的 API 的同时,是否有考虑过它们是如何实现的呢?理解它们的原理,能让我们在不同的场景采用不同的属性,使用自如。本文先来分析计算属性 computed 是如何实现的?

computed 初始化

在 Vue 框架的实现过程中,对计算属性 computed 初始化有两种方式:

  • 初始化 Vue 实例对其初始化
  • 在定义子组件构造函数过程中对其初始化

那么下文将对这两种方式进行分析,先来看第一种初始化方式。

在初始化 Vue 实例时,函数 initState 对计算属性 computed 做了初始化操作,位于 src/core/instance/init.js,具体实现如下:

export function initState (vm: Component) {
  ...
  if (opts.computed) initComputed(vm, opts.computed)
  ...
}

先判断传进来的 vm 实例是否有属性 computed,如果存在,则调用函数 initComputed,否则执行后续逻辑,具体实现如下:

// src/core/instance/state.js

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()

  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) {
      // create internal watcher for the computed property.
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
    }

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

函数接收两个参数:

  • vm:Vue 实例
  • computed:计算属性对象

首先通过 Object.create(null) 创建空对象,赋值给 vm._computedWatchers 和 watchers。

接着,对 computed 进行遍历,通过 key 获取每个计算属性。而 compted 中每个属性可以是函数,也可以是对象;如果是对象的话,则必须包含 get。

获取到计算属性后,通过判断其数据类型获取 getter;如果 getter 为 null,则会在开发环境中抛出告警,此情况存在于 computed 属性是对象。

然后,为每个计算属性创建 computed watcher,即实例化 watcher,需要传入四个参数:

  • vm:Vue 实例
  • expOrFn:数据类型可以是字符串,也可以是函数,在这里传入是函数,即 getter 或者空函数 noop,作为回调被执行
  • cb:回调函数,传入的是空函数 noop
  • options:可选项,传入的是一个对象 { lazy: true },表示其是一个 computed watcher

在实例化 watcher 时,需要注意的是 getter 没有被立即执行,具体实现如下:

// src/core/observer/watcher.js
export default class Watcher {
  lazy: boolean;

  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
   ...
    // options
    if (options) {
      this.lazy = !!options.lazy
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
    ...
    this.value = this.lazy
      ? undefined
      : this.get()
  }
}

最后判断 key 是否在 vm 实例上,如果不存在,则调用函数 defineComputed 将其转换为响应式属性,并添加到 vm 实例上。需要注意的是在创建子组件时,已经对其 computed 属性定义在组件原型上,此时无需再定义;而我们只需要定义那些在这里实例化的 computed 属性。

除此之外,对于已经定义的 computed 属性,还对其 key 进行校验,即 key 存在于 data 或者 props 或者 methods 时,则在开发环境中抛出告警。

接下来,我们来看下 defineComputed 具体是如何实现的?

// src/core/instance/state.js
export function defineComputed (
  target: any,
  key: string,
  userDef: Object | 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)
}

函数接收三个参数:

  • target Vue 实例
  • key:computed 计算属性键名
  • ·userDef: computed 计算属性定义的回调

核心逻辑是利用 ES5 Object.defineProperty 为计算属性对应的 key 定义 getter 和 setter。

在定义 getter 时,根据 shouldCache 值来配置不同 getter,如果 shouldCache 为 true 时,getter 则为 createComputedGetter,否则为 createGetterInvoker。那么 getter 的触发时机是在执行 render 函数时被调用,这里先知道其触发时机,稍后再对其进行分析。

而对于其 setter,只是简单将其设置为空函数 noop。

第一种初始化方式已分析完,接着来分析第二种初始化方式。

在执行 render 函数,即将 Vue 实例渲染为 VNode,其中会调用 createElement 函数创建 VNode,那么在创建过程中,如果传入的是组件,则会调用 createComponent 函数,具体实现如下:

// src/core/vdom/create-component.js
export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  if (isUndef(Ctor)) {
    return
  }

  // plain options object: turn it into a constructor
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }

...
}

核心代码在 Ctor = baseCtor.extend(Ctor),具体实现如下:

  // src/core/global-api/extend.js
  /**
   * Class inheritance
   */
  Vue.extend = function (extendOptions: Object): Function {
    ...
    const Sub = function VueComponent (options) {
      this._init(options)
    }
    Sub.prototype = Object.create(Super.prototype)
    Sub.prototype.constructor = Sub
    Sub.cid = cid++
    Sub.options = mergeOptions(
      Super.options,
      extendOptions
    )
    Sub['super'] = Super

    // For props and computed properties, we define the proxy getters on
    // the Vue instances at extension time, on the extended prototype. This
    // avoids Object.defineProperty calls for each instance created.
    if (Sub.options.props) {
      initProps(Sub)
    }
    
    if (Sub.options.computed) {
      initComputed(Sub)
    }
    
    ...
    return Sub
  }

从代码实现可看出,如果存在 computed,则会调用 initComputed,具体实现如下:

// src/core/global-api/extend.js
function initComputed (Comp) {
  const computed = Comp.options.computed
  for (const key in computed) {
    defineComputed(Comp.prototype, key, computed[key])
  }
}

最终还是调用 defineComputed,上文已分析过。

createComputedGetter 触发时机

为了理解 computed watcher 触发过程,下面通过一个例子来分析,具体如下:

const vm = new Vue({
  data: {
    username: 'Test',
    age: 18
  },
  computed: {
    message () {
      return `${this.username}-${this.age}`
    }
  }
})

当在执行 render 函数访问到属性 message 时,就会触发计算属性 message getter 函数,即 createComputedGetter 被调用,具体实现如下:

// src/core/instance/state.js
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
    }
  }
}

首先,通过初始化时缓存计算属性变量 _computedWatchers 获取到当前 computed watcher。此时 watcher.dirty 值为 true,调用 computed watcher 方法 evaluate ,具体实现如下:

// src/core/observer/watcher.js
/**
  * Evaluate the value of the watcher.
  * This only gets called for lazy watchers.
  */
evaluate () {
  this.value = this.get()
  this.dirty = false
}

evalutate 调用 get 求值,获取计算属性的值,再 computer watcher 属性 dirty 设置为 false。那么,我们来看下是如何求值的?

// src/core/observer/watcher.js
/**
  * Evaluate the getter, and re-collect dependencies.
*/
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (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
}

调用 pushTarget 将当前 computed watcher 压入栈,并设置 Dep.target 为当前 computed watcher,如下:

// src/core/observer/dep.js
export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

接着会执行 value = this.getter.call(vm, vm),即调用计算属性定义的 getter 函数,在我们这个例子就是执行

return `${this.username}-${this.age}`

由于 username 和 age 是响应式对象,从而会触发它们各自的 getter 函数。

根据之前的分析可知,当前 computed watcher 会订阅它们,即将它们各自持有的 dep 添加到当前 computed watcher ;作用是当 username 和 age 发生变化时,可以通知订阅它们的计算属性更新值。

求值完后,调用 popTarget 、cleanupDeps 做一些清理工作,通过 return value 将值返回;需要注意的是此时 Dep.tagert 是指向渲染 watcher。

分析完 evaluate 实现,回到 createComputedGetter 继续后面逻辑分析。

由于此时 Dep.target 指向渲染 watcher,因此,进入 if 逻辑,即调用 computed watcher 实例方法 depend,具体实现如下:

// src/core/observer/watcher.js
/**
  * Depend on all deps collected by this watcher.
*/
depend () {
  let i = this.deps.length
  while (i--) {
    this.deps[i].depend()
  }
}

遍历数组 deps,调用 dep 实例方法 depend ,具体实现如下:

// src/core/observer/dep.js
depend () {
  if (Dep.target) {
    Dep.target.addDep(this)
  }
}

渲染 watcher 订阅 computed watcher,即 computed watcher dep 收集渲染 watcher 依赖,作用是当 computed watcher 发生变化时,通知渲染 watcher 作出响应的更新。

最后,将获取到的计算属性值返回。

计算属性更新机制

沿着上面所举的例子,进一步来分析计算属性是如何更新的。

当计算属性所依赖的属性发生变化时,由于所依赖的属性是响应式对象,那么,自然而然地就会触发其 setter 函数,关键代码如下:

// src/core/observer/index.js
set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  ...
  dep.notify()
}

从上面的分析可知,计算属性对其所依赖的属性进行订阅,此时会触发其值做出更新;除此之外,渲染 watcher 也订阅响应式属性,那么当响应式属性发生变化时,自然而然地就会通知渲染 watcher 更新视图。

// src/core/observer/index.js

notify () {
  // stabilize the subscriber list first
  const subs = this.subs.slice()
  if (process.env.NODE_ENV !== 'production' && !config.async) {
    // subs aren't sorted in scheduler if not running async
    // we need to sort them now to make sure they fire in correct
    // order
    subs.sort((a, b) => a.id - b.id)
  }
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}

此时,调用各自 watcher 方法 update 进行更新操作,具体如下:

// src/core/observer/watcher.js

/**
  * Subscriber interface.
  * Will be called when a dependency changes.
*/
update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

对于 computed wathcer,则将其属性 dirty 设置为 true,在下一个 tick 对其进行求值;而对于渲染 watcher ,则调用 queueWatcher 来执行更新操作(在《响应式原理三:派发更新》已分析过),在这过程中会执行 render 函数,由于需要获取计算属性的值,从而触发 createComputedGetter 函数被执行,实现对计算属性进行求值。

那么,关于计算属性 computed 相关知识点先分析到这里。

参考链接

计算属性 VS 侦听属性