# vm.$watch

先回顾下vm.$watch的用法

vm.$watch( expOrFn, callback, [options] )

参数:

  • {string | Function} expOrFn
  • {Function | Object} callback
  • {Object} [options]
    • {boolean} deep 为了发现对象内部值的变化,可以在选项参数中指定deep: true,监听数组的变动不需要这么做
    • {boolean} immediate 在选项参数中指定immediate: true,将立即以表达式的当前值触发回调

返回值:

{Function} unwatch

用法:

用于观察一个表达式或computed函数在Vue.js实例上的变化。回调函数调用时,会从参数得到新数据(new value)和旧数据(old value)。表达式只接受以点分隔的路径,例如a.b.c。如果是一个比较复杂的表达式,可以用函数代替表达式。

# watch的内部原理

下面是$watch的核心代码

Vue.prototype.$watch = function (expOrFn, cb, options) {
  const vm = this
  options = options || {}
  const watcher = new Watcher(vm, expOrFn, cb, options)
  if (options.immediate) {
    cb.call(vm, watcher.value)
  }
  return function unwatchFn () {
    watcher.teardown()
  }
}

上述代码

  • 调用了new Watcher来实现$watcher的重要功能
  • 当immediate为真的时候立即执行回调函数
  • 返回了一个unwatchFn方法,用于取消观察数据

由于expOrFn可能是函数,我们需要对Watcher类进行修改,以便支持expOrFn为函数的情况。

export default class Watcher {
  constructor (vm, expOrFn, cb) {
    this.vm = vm
    // expOrFn参数支持函数,当expOrFn是函数时,Watcher会同时观察expOrFn函数中读取的所有Vue.js实例上的响应式数据
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
    }
    this.cb = cb
    this.value = this.get()
  }
  ……
}

unwatchFn方法取消数据观察实际执行的是watcher.teardown()方法,本质是把watcher实例从当前正在观察的状态的依赖列表中移除。因此就需要Watcher知道都被哪些Dep收集了

export default class Watcher {
  constructor (vm, expOrFn, cb) {
    this.vm = vm
    // 存放watcher都记录了哪些Dep
    this.deps = []
    // depIds用来判断当前Watcher是否重复订阅Dep
    this.depIds = new Set() // 新增
    this.getter = parsePath(expOrFn)
    this.cb = cb
    this.value = this.get()
  }
  ……
  addDep (dep) {
    const id = dep.id
    // 当依赖发生变化时,会通知Watcher重新读取最新的数据。如果没有这个判断,就会发现每当数据发生了变化,Watcher都会读取最新的数据。而读数据就会再次收集依赖,这就会导致Dep中的依赖有重复。这样当数据发生变化时,会同时通知多个Watcher。为了避免这个问题,只有第一次触发getter的时候才会收集依赖。
    if (!this.depIds.has(id)) {
      this.depIds.add(id)
      // watcher记录了哪些Dep
      this.deps.push(dep)
      // 将watcher记录到Dep中
      dep.addSub(this)
    }
  }
  /**
  * 从所有依赖项的Dep列表中将自己移除
  */
  teardown () {
    let i = this.deps.length
    while (i--) {
      this.deps[i].removeSub(this)
    }
  }
  ……
}

Dep中的修改

// 用于区分不同的Dep
let uid = 0

export default class Dep {
  constructor () {
    this.id = uid++
    this.subs = []
  }
  ……
  depend () {
    if (window.target) {
      // this.addSub(window.target)
      window.target.addDep(this)
    }
  }
  // 把Watcher从sub中删除掉,当数据方法变化的时候不再通知这个watcher
  removeSub (sub) {
    const index = this.subs.indexOf(sub)
    if (index > -1) {
      return this.subs.splice(index, 1)
    }
  }
  ……
}

从上面的代码中可以看出我们存放watcher和dep都是用的数组,dep和watcher是多对多的关系,为什么是多对多的关系呢?

如下,当expOrFn是函数的时候,并且在函数内部使用了多个数据,Watcher就需要收集多个Dep,同时每个Dep也要收集Watcher,这样当任意一个数据变化都会通知Watcher。

this.$watch(function () {
  return this.name + this.age
}, function (newValue, oldValue) {
  console.log(newValue, oldValue)
})

# deep参数的实现原理

vm.$watch中的可选参数deep为了发现对象内部值的变化。实现deep的功能,其实就是除了要触发当前这个被监听数据的收集依赖的逻辑之外,还要把当前监听的这个值在内的所有子值都触发一遍收集依赖逻辑。

export default class Watcher {
  constructor (vm, expOrFn, cb, options) {
    this.vm = vm

    // 新增
    if (options) {
      // 如果用户设置了deep属性
      this.deep = !!options.deep
    } else {
      this.deep = false
    }

    this.deps = []
    this.depIds = new Set()
    this.getter = parsePath(expOrFn)
    this.cb = cb
    this.value = this.get()
  }

  get () {
    window.target = this
    let value = this.getter.call(vm, vm)
    // 如果deep为真
    if (this.deep) {
      // 递归value的所有子值来触发它们收集依赖的功能
      traverse(value)
    }
    window.target = undefined
    return value
  }

  ……
}

如果用户使用了deep参数,则在window.target = undefined之前调用traverse来处理deep的逻辑。

const seenObjects = new Set()

export function traverse (val) {
  _traverse(val, seenObjects)
  seenObjects.clear()
}

// _traverse函数一个递归操作,所以这个value的子值也会触发同样的逻辑,这样就可以实现通过deep参数来监听所有子值的变化。
function _traverse (val, seen) {
  let i, keys
  const isA = Array.isArray(val)
  // val不是Array和Object,或者已经被冻结,那么直接返回
  if ((!isA && !isObject(val)) || Object.isFrozen(val)) {
    return
  }

  if (val.__ob__) {
    // 用这个id保证不重复收集依赖
    const depId = val.__ob__.dep.id
    if (seen.has(depId)) {
      return
    }
    seen.add(depId)
  }
  if (isA) {
    i = val.length
    // 数组中的每一项递归调用 _traverse
    while (i--) _traverse(val[i], seen)
  } else {
    // 循环Object中的所有key,然后执行一次读取操作,再递归子值
    keys = Object.keys(val)
    i = keys.length
    // val[keys[i]] 会触发getter,触发收集依赖的操作
    while (i--) _traverse(val[keys[i]], seen)
  }
}

# vm.$set

基本用法:

vm.$set( target, key, value )

参数:

  • {Object | Array} target target不能是Vue.js实例或者Vue.js实例的根数据对象。
  • {string | number} key
  • {any} value

返回值:{Function} unwatch

用法:在object上设置一个属性,如果object是响应式的,Vue.js会保证属性被创建后也是响应式的,并且触发视图更新。这个方法主要用来避开Vue.js不能侦测属性被添加的限制。

我们来看下vm.$set的核心实现原理

import { set } from '../observer/index'
Vue.prototype.$set = set

export function set (target, key, val) {
  // 如果target是数组且key是有效的数组索引值,设置length属性,然后通过splice方法将val设置到target中的指定位置把这个新增的val转成响应式的。
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    // 数组拦截器会侦测到target发生了变化,将val转为响应式的
    target.splice(key, 1, val)
    return val
  }

  // 如果key已经存在target中,这个key已经被侦测到了,实际相当于修改数据,直接改就好。
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }

  const ob = target.__ob__
  // target._isVue来判断target是不是Vue.js实例
  // ob.vmCount来判断它是不是根数据对象即可,this.$data就是根数据
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  // 如果target身上没有 __ob__ 属性,说明不是响应式数据,直接设置值即可
  if (!ob) {
    target[key] = val
    return val
  }

  // 如果是响应式数据直接调用defineReactive将新增属性转为getter和setter
  defineReactive(ob.value, key, val)
  // 向target依赖触发变化通知
  ob.dep.notify()
  return val
}

# vm.$delete

用法:

vm.$delete( target, key )

参数:

  • {Object | Array} target 目标对象不能是Vue.js实例或Vue.js实例的根数据对象。
  • {string | number} key/index

仅在2.2.0+ 版本中支持Array+index的用法。

用法:删除对象的属性。如果对象是响应式的,需要确保删除能触发更新视图。这个方法主要用于避开Vue.js不能检测到属性被删除的限制,但是你应该很少会使用它。

我们来看下vm.$delete的核心实现原理

import { del } from '../observer/index'
Vue.prototype.$delete = del

export function del (target, key) {
  // 如果是数组直接调用splice删除索引值即可,数组拦截器中会侦测到target的变化
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  const ob = target.__ob__
  // 如果目标对象是Vue.js实例或Vue.js实例的根数据对象直接返回
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
  }

  // 如果taget上不存在这个key属性,也就谈不上删除,直接return
  if (!hasOwn(target, key)) {
    return
  }

  delete target[key]

  // 如果__ob__不存在,说明数据不是响应式的,不用发送通知,直接返回
  if (!ob) {
    return
  }
  ob.dep.notify()
}

# 总结

我们先介绍了vm.$watch的内部实现及其相关参数的实现原理,包括deep、immediate和unwatch。

随后介绍了vm.$set的内部实现。这里介绍了几种情况,分别为Array的处理逻辑,key已经存在的处理逻辑,以及最重要的新增属性的处理逻辑。

最后,介绍了vm.$delete的内部实现原理。