vue源码小解系列---1.computed底层

211 阅读5分钟

这一段时间,有好几个同学找我讨论computed的问题,普遍觉得computed在vue中的这块逻辑有点绕,但从网上并没有找到讲的非常详细,或者说没有找到正好自己不懂的那个点的详细讲解。讲完几遍后,我觉得是可以梳理成文,大家可作为参考的一部分,希望能对您的解惑有些帮助。如果对您有所帮助,烦劳您给点个赞哈~ 毕竟纯手打不易,哈哈。

开始正文。

在这篇里,我就不讲我们在data中定义的数据是如何在底层处理的了,大家如果有困惑可以查查别的文章。直接从computed开讲(这个系列将的还是vue2的内容哈)。

要理解computed底层,那就要从computed最开始的地方下手:initState(vm),从而找到 initComputed方法定义。

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

  for (const key in computed) {
    const userDef = computed[key]
    // 这里的getter就是用户自定义计算函数
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    
    // ....
      // 把每个computed属性都初始化一个watcher,并且传入{lazy: true}, 比如; vm._computedWatchers['someComputed'] = new Watcher({...}); lazy的意思是不需要马上执行。
      // 并且把自定义计算函数传入
      watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      )
  
    if (!(key in vm)) {
      // 初始化时,computed中的key是还没有挂载到vm下的,所以会执行下面逻辑,
      // 走到这里,是把真正用户写的计算函数进行挂载
      // userDef -> 计算函数
      defineComputed(vm, key, userDef)
    }
  }
}

ok, 这一段的主思路是:

  1. 遍历我们开发者定义的computed属性, 把每个属性的函数传到Wacther中并实例化,同时,把这些watcher都挂载的 vm._computedWatchers上, 这些watcher在后面会频繁使用,是载体。
  2. 执行defineComputed(),目的是把计算函数进行处理后,通过Object.defineProperty挂载到vm上。

好了,详细看一下 defineComputed()

我会把代码进行简化,只留着主流程,各种边界条件等这里不考虑。

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

export function defineComputed() {
    // ... 
    if (typeof userDef === 'function') {
        // 会进入这里,因为userDef就是开发者写的computed函数
        sharedPropertyDefinition.get = shouldCache
               
              ? createComputedGetter(key)
              : createGetterInvoker(userDef)
            sharedPropertyDefinition.set = noop
    }

    if (process.env.NODE_ENV !== 'production' &&
          sharedPropertyDefinition.set === noop) {
          // 告知开发者computed没有setter,也就是说计算属性无法进行setter操作
        sharedPropertyDefinition.set = function () {
          warn(
            `Computed property "${key}" was assigned to but it has no setter.`,
            this
          )
        }
      }
      // 把computed的key挂载到vm上,以后就可以直接在代码中使用 this.xxx来访问了
      Object.defineProperty(target, key, sharedPropertyDefinition)
}


createComputedGetter里面让我们猜测的话,肯定就是与执行相关的了,比如当在<template>标签中调用计算属性的话,就会首先执行开发者写的计算函数了吧? 当然一般也不会只是这么简单。为什么这么说呢?我先通过一个例子来说明:

    new Vue({
        ...
        computed: {
            // 一般不会写的情况
            initComputed() {
                return '这是初始计算'
            },
            // 正常情况
            normalComputed() {
                return this.aa + this.bb + '这才是正常情况'
            }
        }
    })

从上面这段伪代码来看,initComputed函数返回的就是一个静态字符串,是比较简单,但是如果是这样,我们大可不必使用计算属性来处理。

所以还得看正常的情况,因为我们的函数中还引用了 this.aathis.bb两个变量,当这两个变量任何一个有变化的时候,我们还得更新计算函数,然后再更新页面视图。这没错吧?

所以看懂上面这个小插曲,那如果让我们来看这个createComputedGetter 函数对功能的实现承载了些什么。

好,继续看这个createComputedGetter函数

function createComputedGetter (key) {
  // 当使用计算属性的值的时候,才会执行。
  return function computedGetter () {
    // 还记得前面我们把computed属性都绑定到this._computedWatchers上了么??
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      
      // 如果我们的computed函数中有引用别的变量,那么初始化时dirty一定是true,当引用的变量变了以后,也会最终把dirty置为true。
      // dirty为true,就说明原来数据脏了,该进行更新了
      if (watcher.dirty) {
        // 所以会执行,继而执行开发者定义的计算函数。
        watcher.evaluate()
      }
      // 那么这时Dep.target实际上就是栈顶的另外一个watcher,是谁不一定,需要看业务代码的执行,有可能是render函数
      if (Dep.target) {
        // 从这句话来看,这段createComputedGetter的整体逻辑是:要执行比如某个计算函数时,
        // 1.先把当前这个对应的watcher推入一个Dep维护的栈,然后Dep.target更新为当前watcher,
        // 2. 执行watcher中个getter函数,也就是计算函数,
        // 3. 执行完成后,当前的watcher出栈,并且Dep.target重新指向栈顶的另外一个watcher
        // 4. 把计算函数的结果返回

        // 所以这时,重新进行watcher的depend操作,作用是什么呢? 那么来详细的看一看
        watcher.depend()
      }
      // 把value返回
      return watcher.value
    }
  }
}

先看到 watcher.evaluate(), 可以先不向下看了。需要重点关注这个函数。 evaluate可以翻译为评价,也就是要评价这个watcher要不要进行更新了。

  evaluate () {
    this.value = this.get()
    this.dirty = false
  }
  // 要看get函数做了什么。
  get () {
    
    /**
     * export function pushTarget (target: ?Watcher) {
        targetStack.push(target)
        Dep.target = target
      }
     */
    // 看起来貌似没有用的pushTarget 和popTarget,其实有大用处,后面再细说
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      // 执行开发者定义的computed函数
      value = this.getter.call(vm, vm)
    } catch (e) {
      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
  }

抛开这对 pushTarget 和popTarget, 貌似就让开发者写的函数执行了,你可能感觉也没有什么特别的嘛。但是我相信满满的疑问就上来了,那computed的强大功能是怎么实现的呢?

我可以很负责的告诉你,这一对pushTarget 和popTarget 太重要啦~~ 太重要啦~~

现在带着疑问,我们先执行一下开发者写的计算函数:

    normalComputed() {
        return this.aa + '这才是正常情况'
    }

这时,一定会执行this.aa, 如果没有学习过 initData的知识,建议你深入了解一下data函数的实现(默认大家写的都是函数形式)。

好,进入this.aa逻辑吧~~

Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      // 先搞明白当前的Dep.target是谁,总体来说,哪个watcher调用的data中的值,那么这个
      // Dep.target就是哪个watcher。还记得我上面说的那一堆很很很重要的pushTarget函数么

      // 那么当这个data中的值发生变化的时候,是又怎么通知到computed-watcher进行更新的呢?
      // 原因就在于在执行dep.depend时,最终调用了watcher的addDep方法,这个方法除了把Dep对象赋值到watcher的newDep[]中, 还做了一个很骚的操作,把这个watcher实例又反向赋值给了dep实例的subs[]中
      // 证据在这里:
      /*
        addDep (dep: Dep) {
          //....
          if (!this.depIds.has(id)) {
            // 这里!!!
            dep.addSub(this)
          }
        }
      */

      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      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()
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 意义是:如果新更改的数据变成了非基本类型,那么就需要重新进行observe,否则无法做到响应式
      childOb = !shallow && observe(newVal)
      // 通知更新
      dep.notify()
    }
  })

通过我这段代码中的很细致的解释,大家是不是有些明白了?我讲的再明白一点:

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

pushTarget会把Dep.target设置为当前的watcher,目前就是我们现在这个normalComputed watcher。所以,在上面的getter执行时,会把当前的Dep实例赋给watcher.newDeps数组,而且,还会把当前这个watcher反向赋值给这个Dep实例。 而这个Dep实例是绑定在例子中的this.aa中的。 正如代码中的注释说的一样,我们是有足够证据的:

// dep.depend(),指向 depend函数
// dep.js中
depend () {
    if (Dep.target) {
      // Dep.target是个watcher,这是毋容置疑的
      Dep.target.addDep(this)
    }
  }
 
 // 然后又走到watcher中
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      // 把dep push到watcher的newDeps数组中
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
      // 看到了吧!看到了吧!我说的主要证据就是这!!!
      // 把watcher又反向放入Dep实例中
        dep.addSub(this)
      }
    }
  }

走到这里,我有必要进行一下阶段总结了,防止大家刚明白点,又乱了,这块逻辑确实比较绕,也比较讨巧。

  • 这个dep,是响应式aa中的一个属性
  • 当执行开发者的计算函数时,会执行this.aa, 进而进入aa的响应式逻辑中
  • 响应式逻辑获取到Dep.target,也就是当前的computed watcher, 把dep赋值给watcher的newDeps属性
  • 同时,又把watcher反向赋值给 dep.sub
  • 这样,就形成了双向关系。 当我们执行computed函数时,把dep进行了存储,当 thsi.aa,变化时,会执行setter 中的 dep.notify(), 进而去执行this.sub中的watcher。
  • 这样,就又通知到了computed函数,函数重新执行, 进而再重新更新视图。

怎么样,大家看懂了么?

希望这篇不太长的文字可以帮助到大家。下一篇可能是分析一下watch的底层,但由于比较忙,可能更新较慢。敬请期待吧~~