六、vue侦听属性

image

$watch

实际上无论是 $watch 方法还是 watch 选项,他们的实现都是基于 Watcher 的封装。首先我们来看一下 $watch 方法,它定义在 src/core/instance/state.js 文件的 stateMixin 函数中,如下:
侦听属性的初始化也是发生在 Vue 的实例初始化阶段的 initState 函数中,在 computed 初始化之后,执行了:

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) {
    cb.call(vm, watcher.value)
  }
  return function unwatchFn () {
    watcher.teardown()
  }
}

$watch 方法允许我们观察数据对象的某个属性,当属性变化时执行回调。所以 $watch 方法至少接收两个参数,一个要观察的属性,以及一个回调函数。通过上面的代码我们发现,$watch 方法接收三个参数,除了前面介绍的两个参数之后还接收第三个参数,它是一个选项参数,比如是否立即执行回调或者是否深度观测等。我们可以发现这三个参数与 Watcher 类的构造函数中的三个参数相匹配,如下:

export default class Watcher {
  constructor (
    vm: Component,
>     expOrFn: string | Function,
>     cb: Function,
>     options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    // 省略...
  }
}

因为 $watch 方法的实现本质就是创建了一个 Watcher 实例对象。另外通过官方文档的介绍可知 $watch 方法的第二个参数既可以是一个回调函数,也可以是一个纯对象,这个对象中可以包含 handler 属性,该属性的值将作为回调函数,同时该对象还可以包含其他属性作为选项参数,如 immediate 或 deep

假设传递给$watch 方法的第二个参数是一个函数,看看它是怎么实现的,在 $watch 方法内部首先执行的是如下代码:

const vm: Component = this
if (isPlainObject(cb)) {
  return createWatcher(vm, expOrFn, cb, options)
}

定义了 vm 常量,它是当前组件实例对象,接着检测传递给 $watch 的第二个参数是否是纯对象,由于我们现在假设参数 cb 是一个函数,所以这段 if 语句块内的代码不会执行。再往下是这段代码:

options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)

首先如果没有传递 options 选项参数,那么会给其一个默认的空对象,接着将 options.user 的值设置为 true,我们前面讲到过这代表该观察者实例是用户创建的,然后就到了关键的一步,即创建 Watcher 实例对象,多么简单的实现

再往下是一段 if 语句块:

if (options.immediate) {
  cb.call(vm, watcher.value)
}

immediate 选项用来在属性或函数被侦听后立即执行回调,如上代码就是其实现原理,如果发现 options.immediate 选项为真,那么会执行回调函数,不过此时回调函数的参数只有新值没有旧值。同时取值的方式是通过前面创建的观察者实例对象的 watcher.value 属性。我们知道观察者实例对象的 value 属性,保存着被观察属性的值。

最后 $watch 方法还有一个返回值,如下:

return function unwatchFn () {
  watcher.teardown()
}

$watch 函数返回一个函数,这个函数的执行会解除当前观察者对属性的观察。它的原理是通过调用观察者实例对象的 watcher.teardown 函数实现的。我们可以看一下 watcher.teardown 函数是如何解除观察者与属性之间的关系的,如下是 teardown 函数的代码:

export default class Watcher {
  // 省略...
  /**
   * Remove self from all dependencies' subscriber list.
   */
  teardown () {
    if (this.active) {
      // remove self from vm's watcher list
      // this is a somewhat expensive operation so we skip it
      // if the vm is being destroyed.
      if (!this.vm._isBeingDestroyed) {
        remove(this.vm._watchers, this)
      }
      let i = this.deps.length
      while (i--) {
        this.deps[i].removeSub(this)
      }
      this.active = false
    }
  }
}

首先检查 this.active 属性是否为真,如果为假则说明该观察者已经不处于激活状态,什么都不需要做,如果为真则会执行 if 语句块内的代码,在 if 语句块内首先执行的这段代码:

if (!this.vm._isBeingDestroyed) {
  remove(this.vm._watchers, this)
}

每个组件实例都有一个 vm._isBeingDestroyed 属性,它是一个标识,为真说明该组件实例已经被销毁了,为假说明该组件还没有被销毁,所以以上代码的意思是如果组件没有被销毁,那么将当前观察者实例从组件实例对象的 vm._watchers 数组中移除,我们知道 vm._watchers 数组中包含了该组件所有的观察者实例对象,所以将当前观察者实例对象从 vm._watchers 数组中移除是解除属性与观察者实例对象之间关系的第一步。由于这个参数的性能开销比较大,所以仅在组件没有被销毁的情况下才会执行此操作。

将观察者实例对象从 vm._watchers 数组中移除之后,会执行如下这段代码:

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

我们知道当一个属性与一个观察者建立联系之后,属性的 Dep 实例对象会收集到该观察者对象,同时观察者对象也会将该 Dep 实例对象收集,这是一个双向的过程,并且一个观察者可以同时观察多个属性,这些属性的 Dep 实例对象都会被收集到该观察者实例对象的 this.deps 数组中,所以解除属性与观察者之间关系的第二步就是将当前观察者实例对象从所有的 Dep 实例对象中移除,实现方法就如上代码所示。

最后会将当前观察者实例对象的 active 属性设置为 false,代表该观察者对象已经处于非激活状态了:

this.active = false

以上就是 $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)
>   }
  // 省略...
}

当参数 cb 不是函数,而是一个纯对象,则会调用 createWatcher 函数,并将参数透传,注意还多传递给 createWatcher 函数一个参数,即组件实例对象 vm,那么 createWatcher 函数做了什么呢?createWatcher 函数也定义在 src/core/instance/state.js 文件中,如下是 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)
}

其实 createWatcher 函数的作用就是将纯对象形式的参数规范化一下,然后再通过 $watch 方法创建观察者。可以看到 createWatcher 函数的最后一句代码就是通过调用 $watch 函数并将其返回。来看 createWatcher 函数的第一段代码:

if (isPlainObject(handler)) {
  options = handler
  handler = handler.handler
}

因为 createWatcher 函数除了在 $watch 方法中使用之外,还会用于 watch 选项,而这时就需要对 handler 进行检测。总之如果 handler 是一个纯对象,那么就将变量 handler 的值赋给 options 变量,然后用 handler.handler 的值重写 handler 变量的值。举个例子,如下代码所示:

vm.$watch('name', {
  handler () {
    console.log('change')
  },
  immediate: true
})

如果你像如上代码那样使用 $watch 方法,那么对于 createWatcher 函数来讲,其 handler 参数为:

handler = {
  handler () {
    console.log('change')
  },
  immediate: true
}

所以如下这段代码:

if (isPlainObject(handler)) {
  options = handler
  handler = handler.handler
}

等价于:

if (isPlainObject(handler)) {
  options = {
    handler () {
      console.log('change')
    },
    immediate: true
  }
  handler = handler () {
    console.log('change')
  }
}

这样就可正常通过 $watch 方法创建观察者了。另外我们注意 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 除了可以是一个纯对象还可以是一个字符串,当 handler 是一个字符串时,会读取组件实例对象的 handler 属性的值并用该值重写 handler 的值。然后再通过调用 $watch 方法创建观察者,这段代码实现的目的是什么呢?看如下例子就明白了:

watch: {
  name: 'handleNameChange'
},
methods: {
  handleNameChange () {
    console.log('name change')
  }
}

上面的代码中我们在 watch 选项中观察了 name 属性,但是我们没有指定回调函数,而是指定了一个字符串 handleNameChange,这等价于指定了 methods 选项中同名函数作为回调函数。这就是如上 createWatcher 函数中那段高亮代码的目的。

上例中我们使用了 watch 选项,接下来我们就顺便来看一下 watch 选项是如何初始化的,找到 initState 函数,如下:

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

调用 initWatch 函数,这个函数用来初始化 watch 选项,至于判断条件我们就不多讲了,前面的讲解中我们已经讲解过类似的判断条件。至于 initWatch 函数,它就定义在 createWatcher 函数的上方

if (opts.watch && opts.watch !== nativeWatch) {
  initWatch(vm, opts.watch)
}

来看一下 initWatch 的实现,它的定义在 src/core/instance/state.js 中:

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

可以看到 initWatch 函数就是通过对 watch 选项遍历,然后通过 createWatcher 函数创建观察者对象的,需要注意的是上面代码中有一个判断条件,如下高亮代码所示:

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

}
}

通过这个条件我们可以发现 handler 常量可以是一个数组,handler 常量是什么呢?它的值是 watch[key],也就是说我们在使用 watch 选项时可以通过传递数组来实现创建多个观察者,如下:

watch: {
name: [
function () {
console.log('name 改变了1')
},
function () {
console.log('name 改变了2')
}
]
}

总的来说,在 Watcher 类的基础上,无论是实现 $watch 方法还是实现 watch 选项,都变得非常容易,这得益于一个良好的设计。

深度观测的实现

接下来我们将会讨论深度观测的实现,在这之前我们需要回顾一下数据响应的原理,我们知道响应式数据的关键在于数据的属性是访问器属性,这使得我们能够拦截对该属性的读写操作,从而有机会收集依赖并触发响应。思考如下代码:

watch: {
  a () {
    console.log('a 改变了')
  }
}

这段代码使用 watch 选项观测了数据对象的 a 属性,我们知道 watch 方法内部是通过创建 Watcher 实例对象来实现观测的,在创建 Watcher 实例对象时会读取 a 的值从而触发属性 a 的 get 拦截器函数,最终将依赖收集。但问题是如果属性 a 的值是一个对象

data () {
  return {
    a: {
      b: 1
    }
  }
},
watch: {
  a () {
    console.log('a 改变了')
  }
}

如上高亮代码所示,数据对象 data 的属性 a 是一个对象,当实例化 Watcher 对象并观察属性 a 时,会读取属性 a 的值,这样的确能够触发属性 a 的 get 拦截器函数,但由于没有读取 a.b 属性的值,所以对于 b 来讲是没有收集到任何观察者的。这就是我们常说的浅观察,直接修改属性 a 的值能够触发响应,而修改 a.b 的值是触发不了响应的。

深度观测就是用来解决这个问题的,深度观测的原理很简单,既然属性 a.b 中没有收集到观察者,那么我们就主动读取一下 a.b 的值,这样不就能够触发属性 a.b 的 get 拦截器函数从而收集到观察者了吗,其实 Vue 就是这么做的,只不过你需要将 deep 选项参数设置为 true,主动告诉 Watcher 实例对象你现在需要的是深度观测。我们找到 Watcher 类的 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
}

如上高亮代码所示,我们知道 Watcher 类的 get 方法用来求值,在 get 方法内部通过调用 this.getter 函数对被观察的属性求值,并将求得的值赋值给变量 value,同时我们可以看到在 finally 语句块内,如果 this.deep 属性的值为真说明是深度观测,此时会将被观测属性的值 value 作为参数传递给 traverse 函数,其中 traverse 函数的作用就是递归地读取被观察属性的所有子属性的值,这样被观察属性的所有子属性都将会收集到观察者,从而达到深度观测的目的。

traverse 函数来自 src/core/observer/traverse.js 文件,如下:

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

上面的代码中定义了 traverse 函数,这个函数将接收被观察属性的值作为参数,拿到这个参数后在 traverse 函数内部会调用 _traverse 函数完成递归遍历。其中 _traverse 函数就定义在 traverse 函数的下方,如下是 _traverse 函数的签名:

function _traverse (val: any, seen: SimpleSet) {
  // 省略...
}

_traverse 函数接收两个参数:

  • 第一个参数是被观察属性的值
  • 第二个参数是一个 Set 数据结构的实例,可以看到在 traverse 函数中调用 _traverse 函数时传递的第二个参数 seenObjects 就是一个 Set 数据结构的实例,它定义在文件头部:const seenObjects = new Set()。

接下来我们看一下 _traverse 函数是如何遍历访问数据对象的,如下是 _traverse 函数的全部代码:

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 函数将变成如下这个样子:

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 (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 函数的实现,在 _traverse 函数的开头声明了两个变量,分别是 i 和 keys,这两个变量在后面会使用到,接着检查参数 val 是不是数组,并将检查结果存储在常量 isA 中。再往下是一段 if 语句块:

if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
  return
}

递归的终止条件

这段代码是对参数 val 的检查,后面我们统一称 val 为 被观察属性的值,我们知道既然是深度观测,所以被观察属性的值要么是一个对象要么是一个数组,并且该值不能是冻结的,同时也不应该是 VNode 实例(这是Vue单独做的限制)。只有当被观察属性的值满足这些条件时,才会对其进行深度观测,只要有一项不满足 _traverse 就会 return 结束执行。所以上面这段 if 语句可以理解为是在检测被观察属性的值能否进行深度观测,一旦能够深度观测将会继续执行之后的代码,如下:

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

这段代码将检测被观察属性的值是数组还是对象,无论是数组还是对象都会通过 while 循环对其进行遍历,并递归调用 _traverse 函数,这段代码的关键在于递归调用 _traverse 函数时所传递的第一个参数:val[i] 和 val[keys[i]]。这两个参数实际上是在读取子属性的值,这将触发子属性的 get 拦截器函数,保证子属性能够收集到观察者,仅此而已。

现在 _traverse 函数的代码我们就解析完了,但大家有没有想过目前 _traverse 函数存在什么问题?别忘了前面我们删除了一段代码,如下:

if (val.__ob__) {
  const depId = val.__ob__.dep.id
  if (seen.has(depId)) {
    return
  }
  seen.add(depId)
}

这段代码的作用不容忽视,它解决了循环引用导致死循环的问题,为了更好地说明问题我们举个例子,如下:

const obj1 = {}
const obj2 = {}

obj1.data = obj2
obj2.data = obj1

上面代码中我们定义了两个对象,分别是 obj1 和 obj2,并且 obj1.data 属性引用了 obj2,而 obj2.data 属性引用了 obj1,这是一个典型的循环引用,假如我们使用 obj1 或 obj2 这两个对象中的任意一个对象出现在 Vue 的响应式数据中,如果不做防循环引用的处理,将会导致死循环,如下代码:

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

如果被观察属性的值 val 是一个循环引用的对象,那么上面的代码将导致死循环,为了避免这种情况的发生,我们可以使用一个变量来存储那些已经被遍历过的对象,当再次遍历该对象时程序会发现该对象已经被遍历过了,这时会跳过遍历,从而避免死循环,如下代码所示:

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

如上高亮的代码所示,这是一个 if 语句块,用来判断 val.ob 是否有值,我们知道如果一个响应式数据是对象或数组,那么它会包含一个叫做 ob 的属性,这时我们读取 val.ob.dep.id 作为一个唯一的ID值,并将它放到 seenObjects 中:seen.add(depId),这样即使 val 是一个拥有循环引用的对象,当下一次遇到该对象时,我们能够发现该对象已经遍历过了:seen.has(depId),这样函数直接 return 即可。

以上就是深度观测的实现以及避免循环引用造成的死循环的解决方案。

同步执行观察者

通常情况下当数据状态发生改变时,所有 Watcher 都为异步执行,这么做的目的是出于对性能的考虑。但在某些场景下我们仍需要同步执行的观察者,我们可以使用 sync 选项定义同步执行的观察者,如下:

new Vue({
  watch: {
    someWatch: {
      handler () {/* ... */},
      sync: true
    }
  }
})

如上代码所示,我们在定义一个观察者时使用 sync 选项,并将其设置为 true,此时当数据状态发生变化时该观察者将以同步的方式执行。这么做当然没有问题,因为我们仅仅定义了一个观察者而已。

Vue 官方推出了 vue-test-utils 测试工具库,这个库的一个特点是,当你使用它去辅助测试 Vue 单文件组件时,数据变更将会以同步的方式触发组件变更,这对于测试而言会提供很大帮助。大家思考一下 vue-test-utils 库是如何实现这个功能的?我们知道开发者在开发组件的时候基本不太可能手动地指定一个观察者为同步的,所以 vue-test-utils 库需要有能力拿到组件的定义并人为地把组件中定义的所有观察者都转换为同步的,这是一个繁琐并容易引起 bug 的工作,为了解决这个问题,Vue 提供了 Vue.config.async 全局配置,它的默认值为 true,我们可以在 src/core/config.js 文件中看到这样一句代码,如下:

export default ({
  // 省略...

  /**
   * Perform updates asynchronously. Intended to be used by Vue Test Utils
   * This will significantly reduce performance if set to false.
   */
  async: true,

  // 省略...
}: Config)

这个全局配置将决定 Vue 中的观察者以何种方式执行,默认是异步执行的,当我们将其修改为 Vue.config.async = false 时,所有观察者都将会同步执行。其实现方式很简单,我们打开 src/core/observer/scheduler.js 文件,找到 queueWatcher 函数:

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    // 省略...
    // queue the flush
    if (!waiting) {
      waiting = true

>       if (process.env.NODE_ENV !== 'production' && !config.async) {
>         flushSchedulerQueue()
>         return
>       }
      nextTick(flushSchedulerQueue)
    }
  }
}

在非生产环境下如果 !config.async 为真,则说明开发者配置了 Vue.config.async = false,这意味着所有观察者需要同步执行,所以只需要把原本通过 nextTick 包装的 flushSchedulerQueue 函数单独拿出来执行即可。另外通过如上高亮的代码我们也能够明白一件事儿,那就是 Vue.config.async 这个配置项只会在非生产环境生效。

为了实现同步执行的观察者,除了把 flushSchedulerQueue 函数从 nextTick 中提取出来之外,还需要做一件事儿,我们打开 src/core/observer/dep.js 文件,找到 notify 方法,如下:

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

在异步执行观察者的时候,当数据状态方式改变时,会通过如上 notify 函数通知变化,从而执行所有观察者的 update 方法,在 update 方法内会将所有即将被执行的观察者都添加到观察者队列中,并在 flushSchedulerQueue 函数内对观察者回调的执行顺序进行排序。但是当同步执行的观察者时,由于 flushSchedulerQueue 函数是立即执行的,它不会等待所有观察者入队之后再去执行,这就没有办法保证观察者回调的正确更新顺序,这时就需要如上高亮的代码,其实现方式是在执行观察者对象的 update 更新方法之前就对观察者进行排序,从而保证正确的更新顺序。

posted @ 2018-11-29 11:48  快乐~  阅读(2206)  评论(0编辑  收藏  举报