VueJS核心-computed属性源码解析与面试参考回答

1,069 阅读6分钟

导读

今天就来聊聊我们非常熟悉的计算属性吧,这也是前端面试常见的一个题目了,而且是属于稍微有点难度的题目, 如果你答得很好那就是一个很棒的加分点,要答好这个问题,那至少都要做到从源码的角度去分析计算属性的原理。

本文就从源码的角度去分析计算属性,适合那些了解响应式原理这部分源码的同学看,如果没学习过,那就得去补一补这块的知识啦。

computed的使用方法

先来看一下计算属性的用法,直接看官网的示例:

<div id="example">
  <p>Original message: "{{ message }}"</sp>
  <p>Computed reversed message: "{{ reversedMessage }}"</p>
</div>
var vm = new Vue({
  el: '#example',
  data: {
    message: 'Hello'
  },
  computed: {
    // 计算属性的 `getter`
    reversedMessage: function () {
      // `this` 指向 vm 实例
      return this.message.split('').reverse().join('')
    }
  }
})

从上面的例子中我们可以知道,计算属性可以是一个函数,除此之外,我们还可以用对象的形式表示:

computed: {
  reversedMessage: {
    get: function () {
      return this.message.split('').reverse().join('')
    },
    // setter
    set: function (newValue) {
       // dosomething
    }
  }
}

以上例子中我们手动给reversedMessage定义了一个gettersetter。所以计算属性可以有两种定义方式:函数和对象自定义gettersetter方法。实际上,VueJS规定每一个计算属性都必须要有一个getter方法,如果使用函数形式,那这个函数会被当作计算属性的getter

计算属性的特点

计算属性是基于响应式历来进行缓存但只在相关响应式依赖发生改变时它们才会重新求值

也就是说上面的case中只要 message 还没有发生改变,多次访问 reversedMessage 计算属性会立即返回之前的计算结果,而不必再次执行函数。而方法会每次都进行求值。在一些性能开销比较大的地方,可以使用计算属性。通俗点说就是computed是一个懒求值的响应式数据。

计算属性的源码分析

还是得从new Vue开始讲,实例化的过程中会进行计算属性的初始化,也就是调用initComputed方法,它被定义在src/instance/state.js

function initComputed (vm: Component, computed: Object) {
  // 创建watchers和vm._computedWatchers用于保存watchers
  const watchers = vm._computedWatchers = Object.create(null)

  const isSSR = isServerRendering() // 判断是否是服务器渲染环境
  
  // 遍历computed属性
  for (const key in computed) { 
    const userDef = computed[key] 
    // computed属性有两种方式,一种是function,一种是getter、setter,获取getter
    const getter = typeof userDef === 'function' ? userDef : userDef.get

    if (!isSSR) { // 如果不是服务器渲染环境
      // 给每一个属性实例化一个computed watcher
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions // const computedWatcherOptions = { lazy: true }
      )
    }

    // 判断key是否已经存在于vm上
    if (!(key in vm)) {
      // 如果是新的值,调用defineComputed函数将它挂载到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) // 这个属性已经定义在data中了
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm) // 这个属性已经定义在prop中了
      }
    }
  }
}

首先创建了一个空对象watchers和私有变量保存watchers,这里巧妙利用了对象的引用性质,也将这些实例保存在vm._computedWatchers上,然后遍历计算属性,取每一个属性的getter,为每一个属性创建一个Watcher实例保存到watchers中,这是一个特殊的Watcher实例,就叫它computedWatcher,最后一个参数const computedWatcherOptions = { lazy: true },这个是计算属性的一个很重要的标志,我们先来分析一下实例化一个computedWatcher发生了什么。

实例化computedWatcher

// src/core/observer/watcher.js
class Watcher {
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    // options
    if (options) {
      //...
      this.lazy = !!options.lazy // 代表这是一个computedWatcher
      //...
    } else {
      this.deep = this.user = this.lazy = this.sync = false
    }
    //...
    this.dirty = this.lazy // for lazy watchers true
    //...

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

这里我只保留了核心的部分,让我门一行一行代码分析,先看这一行this.lazy = !!options.lazy,还记得我们传进来的最后一个参数const computedWatcherOptions = { lazy: true }吗?其实这个就是computedWatcher的标志,所有的computedWatcherlazy属性都是true

接下来就到this.dirty = this.lazy,这个变量字面意思就是表示值是否脏了,当dirtytrue的时候就应该进行重新求值,false的时候什么都不做,这就是计算属性缓存的原理。

最后一行this.value = this.lazy ? undefined : this.get()我们都知道实例化Watcher的时候会进行第一次的触发数据的getter进行依赖的收集,但这行代码中lazy使computedWatcher实例化的时候不进行第一次的求值。那么第一次求值发生在什么地方,请继续往下看。

defineComputed(vm, key, userDef)

if (!(key in vm)) {
    // 如果是新的值,调用defineComputed函数将它挂载到vm上
    defineComputed(vm, key, userDef)
}

实例化computedWatcher后,我们现在回到defineComputed这个方法,它也是被定义在state.js中:

function defineComputed (
  target: any,
  key: string,
  userDef: Object | Function
) {
  // ...
  if (typeof userDef === 'function') {
    sharedPropertyDefinition.get = createComputedGetter(key)
    sharedPropertyDefinition.set = noop
  } else {
    sharedPropertyDefinition.get = userDef.get ? createComputedGetter(key) : noop
    sharedPropertyDefinition.set = userDef.set || noop
  }
  //...
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

这个方法目的也很明确,前面我们说过计算属性接受两种定义形式:函数和对象的gettersetter,所以先判断userDef是不是函数,如果是,执行sharedPropertyDefinition.get = createComputedGetter(key)sharedPropertyDefinition是一个对象:

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

很明显这是一个Object.definePropertydescriptor对象。然后我们看createComputedGetter(key)这个方法,同样也是在同一个文件里:

function createComputedGetter (key) {
  return function computedGetter () {
   //...
  }
}

这是一个高阶函数,返回一个叫computedGetter的函数,这个函数被传递给sharedPropertyDefinition.get,这里的逻辑我们下面再分析,我们先回到前面,sharedPropertyDefinition.set = noop这表明函数式的计算属性不能设置setternoop是一个空函数。

如果userDef不是函数形式,先判断是否有getter,如果有也是调用createComputedGetter(key),否则设置为空函数。而对象形式的计算属性可以设置setter,所以下面的一句检查用户是否自定义了setter

sharedPropertyDefinition.get = userDef.get ? createComputedGetter(key) : noop
sharedPropertyDefinition.set = userDef.set || noop

接下来执行Object.defineProperty(target, key, sharedPropertyDefinition),这个大家应该都很熟悉了,这里的targetvm,也就是当前的Vue实例,实际上这个方法的作用就是将每一个计算属性代理到vm上,这样我们就可以通过vm.xxx访问到计算属性了。,但此时不会访问到真实的getter,而是会访问到computedGetter

然后我们现在来讲讲computedGetter,还是使用文章开头的那个case:<p>Computed reversed message: "{{ reversedMessage }}"</p>,在渲染为真实DOM的过程中会访问到vm.reversedMessage进行取值,此时会触发computedGetter,而不是触发reversedMessage函数代码如下:

function createComputedGetter (key) {
  return function computedGetter () {
    // vm._computedWatchers存在,取vm._computedWatchers[key]
    const watcher = this._computedWatchers && this._computedWatchers[key]

    if (watcher) {
      // 只有值改变了,才会重新获取
      if (watcher.dirty) {
        watcher.evaluate()
      }
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

此时会通过vm._computedWatcherskey(假如访问vm.xxx,那么key就是xxx)获取到当前的computedWatcher实例,前面我们讲过:实例化computedWatcher的时候不会进行第一次的求值,仅仅将dirty设置为true,所以这时候就会调用watcher.evaluate()方法,这个方法定义在watcher.js

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

非常简单对吧,就是调用this.get()获取最新的值更新watcher.value,此时就是调用真实的getter了,也就是reversedMessage函数,而调用函数过程中,会触发函数内所有响应式数据的getter,进行依赖的收集,依赖收集的结果是将当前的computedWatcher添加到响应式数据的dep中。这里得注意,Dep.target现在应该指向computedWatcher,最后将dirty设置为false

接下来到这里:

if (Dep.target) {
  watcher.depend()
}

这里的作用就是将一些watcher收集到到响应式数据的依赖中,用以接收数据更新的通知。来分析一下这个过程。首先判断Dep.target是否为nullDep.target有两种可能,renderWatcher或者$watcher()的普通watcher

watcher.depend()是调用computedWatcher上的depend方法,如下:

//...
depend () {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
}
//...

watcher上的deps是一个数组,用来保存所有它所观察的响应式数据的dep实例。先获取一个deps的长度,然后遍历deps数组,依次调用this.deps[i].depend(),这个方法被定义在dep.js文件:

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

这里将dep实例当作参数调用了Dep.target.addDep(this),定义在watcher.js,

addDep (dep: Dep) {
  const id = dep.id
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id)
    this.newDeps.push(dep)
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
  }
}

newDepIdsdepIds是ES6的Set实例,newDepsdeps是数组,设计得这么复杂目的是为了避免重复收集依赖。首先将dep实例保存到了newDeps中,然后又调用了dep.addSub(this)watcher添加到depsubs中。所以绕来绕去的目的就是renderWatcher或者$watcher()收集到计算属性观察的所有响应式数据的依赖中,以便接收到数据更新的通知。,最后就是返回计算属性的值return watcher.value,就可以获取到计算属性当前的值了。

计算属性重新计算过程分析

前面分析了计算属性值缓存的原理,就是利用dirty属性去控制是否重新计算值,那么什么情况下会重新计算值呢?当计算属性所依赖的响应式数据发生变化的时候就会进行计算属性的重新计算,下面我们来分析这个过程:

我们知道,当响应式数据改变的时候,会调用dep.notify()去通知它收集的所有的watcher进行更新,也就是调用watcher.update方法。

update () {
    /* istanbul ignore else */
  if (this.lazy) {
    // 计算属性更新
    this.dirty = true
  } else if (this.sync) {
    // 如果是sync watcher,直接执行
    this.run()
  } else {
    // 否则先放到队列里,nextTick再执行
    queueWatcher(this)
  }
}

如果是一个computedWatcher,只会将dirty设置为true。前面提到,使用计算属性的watcher也会收到通知,就假设是renderWatcher也会收到通知,而且是比computedWatcher收到通知的时机要晚一点的,因此。当renderWatcher收到了通知,会发起dom的重新渲染,此时访问到了计算属性,触发了computedGetter,这时候dirtytrue,所以会进行重新求值watcher.evaluate()和重新收集依赖watcher.depend()

面试回答参考模版

面试中这种问题的回答要把握好,太详细显得啰嗦,太简单又没深度,但要把整个过程描述清楚并不简单。我的建议是分步去描述,这里提供一个参考答案,不建议大家背答案,最好能够有自己的学习理解和总结,一定要总结归纳一些常见问题的答案,多练习打好腹稿,尤其是口才不好的同学,这样面试的时候才会流畅、清晰。

Q:能给我简单讲讲Vue的计算属性吗?

A:

  1. 首先从新建一个Vue实例开始吧,新建Vue实例会调用initComputed进行计算属性的初始化过程
  2. 初始化过程中会遍历计算属性,分别新建一个computedWatcher实例,这是一个特殊的Watcherlazy属性为true,实例化的时候并不会进行第一次求值,而是仅将dirty属性为truelazydirty就是计算属性的核心
  3. 然后会使用definedComputed方法将拦截方法代理到vm上,每次读取计算属性就会触发computedGetter方法,这个方法主要有个作用:重新求值和收集依赖
  4. DOM首次渲染会访问到计算属性,触发computedGetter方法,此时dirtytrue,调用evaluate方法进行计算属性第一次求值,拿到值后会将dirty设置为false,此时会进行收集依赖,computedWatcher会观察计算属性方法内所用到的所有响应式数据,然后会将renderWatcher也观察计算属性方法内所用到的所有响应式数据,这样就可以接收到数据改变时发出的通知了
  5. 当数据改变的时候,首先会通知到computedWatcher,因为lazytrue,只会将dirty设置为true,然后RenderWatcher也接到了通知,进行rerender,又触发了computed getter,此时dirty为true,调用evaluate方法计算取得最新值,并调用depend方法进行重新收集依赖。

总结

以上就是本文的全部内容,希望可以帮助到大家~