Vue2.0源码阅读计划(三)——三类Watcher

1,765 阅读8分钟

——走得慢点,走得远些

前言

很多人问我:为什么已经Vue3.0时代了,我却还在搞2.0
我给出的回答正如上面的警句一样走得慢点,走得远些。在我看来,2.0版本持续了很长一段时间,我用它做了很多东西出来,它陪了我很久很久,我很熟悉它,但3.0来了它突然间就老了,未来它走不下去了,我想知道过去关于它的一切,我想知道它到底是如何为我服务的,这样在3.0的时候我才能知道它为什么老了,又在哪里,3.0究竟带来了哪些新的改变,我最终会喜欢上3.0

Tip

在写完上篇文章Vue2.0源码阅读计划(二)——响应式原理的时候,我去源码群里问了一个关于nextTick的问题,可能很多人在看源码的时候都卡在这里:

我问:nextTick在浏览器支持的情况下就是个微任务(promise),我所理解的事件循环是宏任务=>微任务=>页面渲染=>下一个宏任务...,这才微任务阶段,页面都没重新渲染,怎么就能在$nextTick中拿到更新后的dom

最终答案:这个简单的问题被搞得复杂化了,导致大家讨论了很久。我们都知道JS引擎线程GUI渲染线程互斥,所以就应该理解更新dom的操作是立即执行的,但页面渲染得等到本轮事件循环结束才执行,所以在微任务阶段我们可以拿到更新后的dom,不用关心页面是否渲染,这是两码事,over。

正文

Watcher: 观察者对象 , 实例分为计算属性 watcher (computed watcher)侦听器 watcher(user watcher)渲染 watcher (render watcher)三种

看到有些文章还分deep watchersync watcher,那只不过是在定义user watcher的时候设置了专用的参数,所以本质上还是属于user watcher。而本文要解析的三类watcher,本质也是在创建watcher实例的时候,传入的参数不同,调用的时机不同区别开来的。

下面我们依次来看:

computed watcher

computed watcherinitComputed的时候生成,对于computed我的理解就是多个属性影响一个属性的时候使用,它最大的特点就是计算的结果会被缓存,具体是怎么实现的,下面具体分析:

initComputed

const computedWatcherOptions = { lazy: true }

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

initComputed首先在实例上定义属性_computedWatchers为一个安全的空对象,这里采用连等赋值给到watchers同样的定义,共用这一个初始值对象(连等赋值是从右往左赋值)。isSSR用来判断是否为服务端渲染环境,继续往下遍历传入的computed属性,对当前属性值进行判断,若是函数则直接拿,否则去拿属性值中的get属性,如果都拿不到就在开发环境下报错,接着在非服务端渲染环境时创建一个watcher的实例,注意第4个参数为{ lazy: true }。最下面对当前属性的重复性进行判断,如果属性不存在于dataprops(在initPropsinitData阶段属性已经被代理到实例上),调用defineComputed方法。

defineComputed

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

首先定义了常量shouldCache,在非服务端渲染环境时该值为true,代表应该缓存。然后对userDef进行判断,因为该值是对象的情况下可能存在自定义set。判断中都对sharedPropertyDefinition重新设置了getset属性,sharedPropertyDefinition的定义如下,就是一个访问器描述对象:

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

代码的最后就是将处理后的sharedPropertyDefinition代理到实例属性上,与proxy的实现原理相同。 我们注意到上面在对sharedPropertyDefinition重新设置get的时候都会调用createComputedGetter方法,一会再来分析这个方法。我们现在知道了在获取computed属性值的时候最终调用的是createComputedGetter方法,下面用vue官网的例子来分析。

用例分析

var vm = new Vue({
  el: '#demo',
  data: {
    firstName: 'Foo',
    lastName: 'Bar'
  },
  computed: {
    fullName: function () {
      return this.firstName + ' ' + this.lastName
    }
  }
})

我们在模板中使用了{{ this.fullName }},在执行vm._render函数的时候去获取computed属性值调用createComputedGetter方法,该方法定义如下:

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
    }
  }
}

一个闭包高阶函数,这里就要用到一开始在initComputed函数中创建的watcher实例了,一个属性对应一个computed watcher。这里我们需要回到生成watcher实例的地方去看一下它的构造函数:

constructor (
  vm: Component,
  expOrFn: string | Function,
  cb: Function,
  options?: ?Object,
  isRenderWatcher?: boolean
) {
  // ......
  this.dirty = this.lazy // for lazy watchers
  // ......
  this.value = this.lazy
      ? undefined
      : this.get()
}  

此时watcher.dirty会为true(计算属性的缓存就是通过这个属性来判断的),所以此时 computed watcher 会并不会立刻求值,实例创建好后,继续执行走进watcher.evaluate()

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

evaluate内部调用get求值,然后将this.dirty再设置为false,在我们下次再次使用这个computed属性求值的时候就不会再执行watcher.evaluate(),而是直接返回值,这就是computed具有缓存的原因所在。

那当computed中依赖的数据项改变了之后,比如上面的例子中this.firstNamethis.lastName发生了改变,this.fullName是怎样重新求值的呢?我们先来分析一下在watcher.evaluate()内部调用this.get()的时候发生了什么:

get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      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(this)设置全局唯一依赖Dep.target,除了这还在targetStackpush了当前的computed watcher,此时targetStack中应该有两个watcher:[render watcher, computed watcher](注意此时我们是在走vm.render生成vnode的过程中),pushTarget代码如下:

const targetStack = []

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

然后我们执行this.getter.call(vm, vm)求值,以上面的例子来说getter就是我们定义的this.fullName函数,执行这个函数的过程中会触发this.firstNamethis.lastNamegetter收集依赖,将当前computed watcher收集进去,所以this.firstNamethis.lastName就订阅了这个computed watcher,这个过程中也会在computed watcher实例的newDeps中将它们俩各自在defineReactive闭包中的dep实例收集进来。

接着走popTarget():

function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

这里popTarget()所做的事就是将Dep.target重置为render watcher。接着执行this.cleanupDeps():

cleanupDeps () {
    let i = this.deps.length
    while (i--) {
      const dep = this.deps[i]
      if (!this.newDepIds.has(dep.id)) {
        dep.removeSub(this)
      }
    }
    let tmp = this.depIds
    this.depIds = this.newDepIds
    this.newDepIds = tmp
    this.newDepIds.clear()
    tmp = this.deps
    this.deps = this.newDeps
    this.newDeps = tmp
    this.newDeps.length = 0
}

在这里我们只需注意this.deps = this.newDeps这一行,this.deps中此时包含了this.firstNamethis.lastNamedep实例(thiscomputed watcher的实例)。

watcher.evaluate()走完了,Dep.target存在,逻辑走进watcher.depend()中:

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

很简单,依次触发computed watcherdeps上的firstNamelastName这两个dep实例的depend方法:

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

注意这里的Dep.target还是render watcher,执行addDep:

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

firstNamelastName这两个dep实例就将render watcher收集了进去。watcher.depend()这是很关键的一步,因为如果模板中没有用到firstNamelastName,那么通过这里给它们的dep实例收集到render watcher,如果一开始就在模板中用到了,firstNamelastName一开始就收集到了render watcher,这里再添加时走addDepaddDep做了过滤,不会重复收集,所以watcher.depend()很重要。

关于watcher.depend()这里有个双向保存的概念,这可能有些绕,watcher 会收集 deps, 同样 dep 也会收集 watcher 集合,从我上面的分析过程就可以看出。watcher 收集 deps的目的是为了防止重复收集,从addDep方法可以看出,dep收集watcher是为了通知订阅、派发更新。

当我们更新firstNamelastName的时候,执行setter触发render watchercomputed watcher的更新,computed watcher的更新如下:

update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
}

只简单的将this.dirty = true设置为true,它是同步的,而render watcher是异步的,在render的过程中获取computed属性的值就会触发getter,从而再次执行watcher.evaluate()重新求值,拿到新值后继续重新渲染界面。

总结一下,计算属性本质上就是一个 computed watcher,在computed在运行的时候,整体思路就是将当前computed watcher添加到依赖数据的dep中,当依赖数据发生更新的时候触发render watchercomputed watcher,从而做到视图的更新。

user watcher

user watcherinitWatch方法中初始化:

initWatch

function initWatch (vm: Component, watch: Object) {
  for (const key in watch) {
    const handler = watch[key]
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

我们还是拿官网的例子来分析:

var vm = new Vue({
  data: {
    a: 1,
    b: 2,
    c: 3,
    d: 4,
    e: {
      f: {
        g: 5
      }
    }
  },
  watch: {
    a: function (val, oldVal) {
      console.log('new: %s, old: %s', val, oldVal)
    },
    // methods选项中的方法名
    b: 'someMethod',
    // 深度侦听,该回调会在任何被侦听的对象的 property 改变时被调用,不论其被嵌套多深
    c: {
      handler: function (val, oldVal) { /* ... */ },
      deep: true
    },
    // 该回调将会在侦听开始之后被立即调用
    d: {
      handler: 'someMethod',
      immediate: true
    },
    // 调用多个回调
    e: [
      'handle1',
      function handle2 (val, oldVal) { /* ... */ },
      {
        handler: function handle3 (val, oldVal) { /* ... */ },
      }
    ],
    // 侦听表达式
    'e.f': function (val, oldVal) { /* ... */ }
  }
})
vm.a = 2 // => new: 2, old: 1

initWatch中首先遍历对象,拿到属性值后,因为属性值可能为数组(例子中的e属性值),所以做了判断,最终都会调用createWatcher

createWatcher

function createWatcher (
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

这里面又执行了几个判断,最终的目的都是拿到函数类型的handler,此时expOrFnkeyoptions存在时为我们最开始定义的watcher属性值(包含handler,immediate,deep等属性),然后再执行vm.$watch

$watch

Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    const vm: Component = this
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {}
    options.user = true
    const watcher = new Watcher(vm, expOrFn, cb, options)
    if (options.immediate) {
      try {
        cb.call(vm, watcher.value)
      } catch (error) {
        handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
      }
    }
    return function unwatchFn () {
      watcher.teardown()
    }
}

这里会在options上添加属性usertrue,表明这是一个user watcher,然后创建Watcher实例,在实例生成的时候会执行this.get,内部会访问我们监听的属性触发getter进行依赖收集,这样就将user watcher收集到我们监听放数据的dep中去了,一旦我们 watch 的数据发生了变化,它就会触发setter派发更新,最终会执行 watcherrun 方法,执行回调函数 cb。接着判断如果传入的options上的属性immediatetrue,就立即将回调执行一遍。最后返回了一个 unwatchFn 方法,它会调用 teardown 方法去移除这个 watcher,这块很好理解,就不展开分析了。

这就是user watcher的基本实现。接下来我们再来分析一下更细的划分:deep watchersync watcher

deep watcher

我们以上面官方例子中的c属性为例来分析,在定义了deep属性为true时,会开启深层监听,当我们监听数据的子数据发生改变时,也能被监听到。这是怎么实现的,下面具体分析:

get() {
  let value = this.getter.call(vm, vm)
  // ...
  if (this.deep) {
    traverse(value)
  }
}

上面的代码是Watcher类中的get方法,在实例化user watcher的时候,会执行这个方法,开启deep时会执行traverse方法:

const seenObjects = new Set()

/**
 * Recursively traverse an object to evoke all converted
 * getters, so that every nested property inside the object
 * is collected as a "deep" dependency.
 */
export function traverse (val: any) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}

function _traverse (val: any, seen: SimpleSet) {
  let i, keys
  const isA = Array.isArray(val)
  if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
    return
  }
  if (val.__ob__) {
    const depId = val.__ob__.dep.id
    if (seen.has(depId)) {
      return
    }
    seen.add(depId)
  }
  if (isA) {
    i = val.length
    while (i--) _traverse(val[i], seen)
  } else {
    keys = Object.keys(val)
    i = keys.length
    while (i--) _traverse(val[keys[i]], seen)
  }
}

traverse 的逻辑也很简单,它实际上就是对一个对象做深层递归遍历,因为遍历过程中就是对一个子对象的访问,会触发它们的 getter 过程,这样就可以收集到依赖,也就是订阅user watcher,这个函数实现还有一个小的优化,遍历过程中会把子响应式对象通过它们的 dep id 记录到 seenObjects,避免以后重复访问。

所以在执行了 traverse 后,我们再对 watch 的对象内部任何一个值做修改,也会调用 user watcher 的回调函数了。

sync watcher

sync watcher的同步体现在派发更新时:

update () {
  if (this.computed) {
    // ...
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

这里会直接执行run方法,执行回调函数,而不会去开启异步队列机制。当我们需要 watch 的数据的变化到执行 user watcher 的回调函数是一个同步过程的时候才会去设置该属性为 true,很少用到。

render watcher

render watcher在上一篇Vue2.0源码阅读计划(二)——响应式原理中我们已经分析过了,发生在vue实例挂载阶段,这里不再赘述。

总结

现在我们对这三类Watcher都有了深入的理解,就使用场景而言,当某个数据是依赖了其它的响应式数据甚至是计算属性计算而来的时候,使用计算属性(多个影响一个);当观测某个值的变化去完成一段复杂的业务逻辑的时候,使用侦听属性(一个影响多个)。

欢迎关注兔兔的公众号 长耳朵兔兔进击前端 ,一起学习交流分享。