响应式原理五:computed

522 阅读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._computedWatcherswatchers

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

获取到计算属性后,通过判断其数据类型获取 getter;如果 getternull,则会在开发环境中抛出告警,此情况存在于 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 实例
  • keycomputed 计算属性键名
  • ·userDef: computed 计算属性定义的回调

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

在定义 getter 时,根据 shouldCache 值来配置不同 getter,如果 shouldCachetrue 时,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}`

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

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

求值完后,调用 popTargetcleanupDeps 做一些清理工作,通过 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 侦听属性