vue watch原理解析

379 阅读5分钟

一、疑问

项目过程中经常遇到使用@watch的场景:当监听的属性发生变化时,执行相应的回调。那么watch是如何工作的呢?于是带着以下问题去看源码:

  1. 什么时候初始化watch?

  2. 怎么对设置的key进行监听?

  3. 监听的数据改变时,watch如何工作?

  4. 设置immediate时,watch 如何工作?

  5. 设置了deep时,watch 如何工作?

二、watch

  1. watch对象:
  • 类型{ [key: string]: string | Function | Object | Array }
  • 详细:一个对象,键是需要观察的表达式,值是对应回调函数。值也可以是方法名,或者包含选项的对象。Vue 实例将会在实例化时调用 $watch(),遍历 watch 对象的每一个属性。(详见:cn.vuejs.org/v2/api/#wat…
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)
    },
    // 方法名
    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) { /* ... */ },
        /* ... */
      }
    ],
    // watch vm.e.f's value: {g: 5}
    'e.f': function (val, oldVal) { /* ... */ }
  }
})
vm.a = 2 // => new: 2, old: 1

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

参数

  1. exOrFn:要监视的$data中的属性,类型string | Function,观察 Vue 实例变化的一个表达式或计算属性函数,表达式只接受监督的键路径。回调函数得到的参数为新值和旧值。对于更复杂的表达式,用一个函数取代。

  2. callback:数据变化后执行的函数,类型Function | Object

  3. options:可选的选项,类型Object。常用监听选项如下:

  • deep: 布尔类型,深度监听

  • immediate: 布尔类型,是否立即执行一次回调函数

举例

// 键路径
vm.$watch('a.b.c', function (newVal, oldVal) {
  // do something
})
// 函数
vm.$watch(
  function () {
    // 表达式 `this.a + this.b` 每次得出一个不同的结果时
    // 处理函数都会被调用。
    // 这就像监听一个未被定义的计算属性
    return this.a + this.b
  },
  function (newVal, oldVal) {
    // do something
  }
)

三、源码解析

vue-watch.png (注:红色标注的是data数据更新会触发的流程)

3.1,初始化

调用 Vue 创建实例过程中,会去处理各种选项。在beforeCreate之后,created之前,会进行监听初始化。

export function initMixin (Vue: Class<Component>) {
    // ... 其他处理
    initState(this)
    // ...解析模板,生成DOM 插入页面
}  

export function initState (vm: Component) {
  vm._watchers = []

  /** options对象的可选属性包括五大类:数据、DOM、生命周期钩子、资源、组合,详情可参考:https://cn.vuejs.org/v2/api/#watch */
  const opts = vm.$options
  
  /** 处理 data,props,computed 等数据 */ 
  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)

  /** 初始化watch */
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

3.2,initWatch

初始化监听:遍历watch对象,根据key和handler创建watcher。

function initWatch (vm: Component, watch: Object) {
  
  /** key: 监听属性,类型string */
  /** handler: 监听回调,类型有string | Function | Object | Array四种情况 */
  for (const key in watch) {
    /** watch的定义详见:https://cn.vuejs.org/v2/api/#watch */
    const handler = watch[key]

    /** 处理handler为数组的情况 */
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      createWatcher(vm, key, handler)
    }
  }
}

3.3,createWatcher

获取到监听回调(如果handler是个对象,就取handler.handler字段;如果handler是字符串,就从实例上获取函数),然后调用watch。

function createWatcher (
  /** vm: vue实例 */
  /** expOrFn: 监听属性 */
  /** handler: 监听回调,可能是对象(包含handler,deep,immediate),也可能是回调函数(string | Function) */
  /** options: 监听相关的选项,包括deep,immediate等 */
  vm: Component,
  expOrFn: string | Function,
  handler: any,
  options?: Object
) {
  /** 监听回调是一个对象,包含handler,deep,immediate。(就是把监听回调再剥一层) */
  if (isPlainObject(handler)) {
    options = handler
    handler = handler.handler
  }
  /** 监听回调是一个方法名,则从 vm 获取回调函数 */
  if (typeof handler === 'string') {
    handler = vm[handler]
  }
  return vm.$watch(expOrFn, handler, options)
}

3.4,watch

每个watch配发watcher,判断是否立刻执行监听回调。

Vue.prototype.$watch = function (
    /** expOrFn 是监听的key,cb是监听回调,opts是监听选项 */
    expOrFn,
    cb,
    options
  ) {
    var vm = this;
    
    /** 如果监听回调还是对象,就调用createWatcher函数。取到cb.handler后再调用watch函数(相当于把监听回调再剥一层) */
    if (isPlainObject(cb)) {
      return createWatcher(vm, expOrFn, cb, options)
    }
    options = options || {};
    options.user = true;
    
    /** 为每个监听配发watcher实例 */
    var 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) + "\""));
      }
    }

     /** Remove self from all dependencies' subscriber list. */
     return function unwatchFn () {
     watcher.teardown();
    }
  };

3.5,watcher

Watcher是Vue中的观察者类,主要任务是:观察Vue组件中的属性,当属性更新时作相应的操作,即实例化时传入的回调函数。在Vue对属性做响应式处理时,会收集每个属性的依赖,即每个属性所依赖的watcher,当属性更新时,通知watcher执行更新dom操作。

Watcher有三种类型:计算属性computed创建的computedWatcher、侦听器watch创建的userWatcher、用于渲染更新dom的renderWatcher。一个组件只有一个renderWatcher,有多个computedWatcher和userWatcher。

通过Vue初始化过程可以知道,组件执行initState处理数据时,处理顺序是props => methods => data => computed => watch,然后最后在$mount阶段才实例化了渲染watcher,所以组件内watcher创建顺序是:computed Watcher => user Watcher(即监听器watcher) => renderWatcher

watcher可获取监听的key、监听回调 (Watch 中的cb)、监听配置。这里面也是依赖收集和更新的触发点。

var Watcher = function Watcher (
  vm,
  expOrFn,
  cb,
  options,
  isRenderWatcher
) {
  this.vm = vm;
  if (isRenderWatcher) {
    vm._watcher = this;
  }
  vm._watchers.push(this);
  // options
  if (options) {
    this.deep = !!options.deep;
    this.user = !!options.user;
    this.lazy = !!options.lazy;
    this.sync = !!options.sync;
    this.before = options.before;
  } else {
    this.deep = this.user = this.lazy = this.sync = false;
  }
  this.cb = cb;
  this.id = ++uid$2; // uid for batching
  this.active = true;
  this.dirty = this.lazy; // for lazy watchers
  this.deps = [];
  this.newDeps = [];
  this.depIds = new _Set();
  this.newDepIds = new _Set();
  this.expression = process.env.NODE_ENV !== 'production'
    ? expOrFn.toString()
    : '';           
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn;
  } else {
    /** 传进来的expOrFn是watch对象的键值,如果键值是'a.b.c.d',那么需要parsePath方法将d从实例上一层一层的取出来,包装成一个函数function(){return d}。如果不进行这层包装,下面方法会报错 */
    this.getter = parsePath(expOrFn);
    if (!this.getter) {
      this.getter = noop;
      process.env.NODE_ENV !== 'production' && warn(
        "Failed watching path: \"" + expOrFn + "\" " +
        'Watcher only accepts simple dot-delimited paths. ' +
        'For full control, use a function instead.',
        vm
      );
    }
  }
  /** 当前watcher的值,为执行getter函数的结果值 */
   this.value = this.lazy
    ? undefined
    : this.get();
};


/** 参数 obj 是 vm 实例,segments 是解析后的键值数组,循环去获取每项键值的值,触发它们的“数据劫持get”。接着触发 dep.depend 收集依赖(依赖就是挂在 Dep.target 的 Watcher) */
export function parsePath (path: string): any {
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

3.6,get

watcher.get函数作用:获取对应属性的值、与观察的属性建立关系的过程。

Watcher.prototype.get = function get () {
  /** pushTarget将当前的“Watcher”(即当前实例this)挂到Dep.target上。在收集依赖时,找的就是Dep.target。 */
  pushTarget(this);
  var value;
  var 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
};

function pushTarget (target) {
  targetStack.push(target);
/**
 * A dep is an observable that can have multiple
 * directives subscribing to it.
 */
  Dep.target = target;
}

Watch 在结尾会立即执行一次watcher.get,在get()中调用this.getter,根据监听的key,去vm实例上读取属性并返回,存放在watcher.value上。

3.7,深度监听

  1. depId 是每一个被观察属性都会有的唯一标识;
  2. 去重,防止相同属性重复执行逻辑;
  3. 根据数组和对象使用不同的策略,最终目的是递归获取每一项属性,触发它们的“数据劫持get”收集依赖,和 parsePath 的效果是异曲同工。

从这里能得出,深度监听利用递归进行监听,肯定会有性能损耗。因为每一项属性都要走一遍依赖收集流程,所以在业务中尽量避免这类操作。

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

function _traverse (val, seen) {
  var i, keys;
  var isA = Array.isArray(val);

  if ((!isA && !isObject(val)) || Object.isFrozen(val) || val instanceof VNode) {
    return
  }
  if (val.__ob__) {
    var depId = val.__ob__.dep.id;
    if (seen.has(depId)) {
      return
    }
    seen.add(depId);
  }

  /** 如果val是数组,循环遍历 */
  if (isA) {
    i = val.length;
    // val[i] 就是读取值了,然后值的对象就能收集到 watch-watcher
    while (i--) { _traverse(val[i], seen); }
  } else {
    /** 递归遍历val的每一个属性 */
    keys = Object.keys(val);
    i = keys.length;
    // val[keys[i]] 就是读取值了,然后值的对象就能收集到 watch-watcher
    while (i--) { _traverse(val[keys[i]], seen); }
  }
}

3.8,更新

在更新时首先触发的是“数据劫持set”,调用 dep.notify 通知每一个 watcher 的 update 方法。接着就走 queueWatcher 进行异步更新,这里先不讲异步更新。只需要知道它最后会调用的是 run 方法。

Watcher.prototype.update = function update () {   
/* 
update函数触发时机:watcher所观察的属性 触发更新
update函数执行流程:
  1、如果this.lazy为true,即当前watcher属于computedWatcher,只是设置dirty属性
  2、如果this.sync, 执行run函数
  3、否则将当前watcher入队,后面在异步更新时,会遍历执行watcher的run方法
*/
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    /** 依赖收集的时候当数据发生改变时会触发这个方法  */
    this.run();
  } else {
    queueWatcher(this);
  }
};


Watcher.prototype.run = function run () {
  if (this.active) {
    var value = this.get();
    if (
      value !== this.value ||
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated.
      isObject(value) ||
      this.deep
    ) {
      // set new value
      var oldValue = this.value;
      this.value = value;
      if (this.user) {
        try {
          this.cb.call(this.vm, value, oldValue);
        } catch (e) {
          handleError(e, this.vm, ("callback for watcher \"" + (this.expression) + "\""));
        }
      } else {
        this.cb.call(this.vm, value, oldValue);
      }
    }
  }
};


Dep.prototype.notify = function notify () {
  // stabilize the subscriber list first
  var subs = this.subs.slice();
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
};

三、解答困惑

  1. watch什么时候初始化?

在beforeCreate之后,created之前,会进行监听初始化。

  1. 怎么对设置的key进行监听?

每个监听的key创建watcher实例时,会执行watcher.get,其中便会执行 getter,便会根据你监听的key,去实例上读取并返回,存放在 watcher.value 上。详见以上第2.5及2.6节部分(watcher、get函数源码)。

  1. 监听的数据改变的时,watch 如何工作?
  • watch 在一开始初始化的时候,会读取一遍监听的数据的值,于是,此时那个数据的dep就收集到 watch-watcher 了;

  • watcher中存放了监听的数据的值、监听回调、监听选项,具有update()方法;

  • 当数据改变时,会通过Dep.notify通知watcher进行更新(执行watcher.update()函数),于是,设置的 handler 就被调用了。

  1. 设置 immediate 时,watch 如何工作?

当设置了 immediate 时,就不需要在数据改变的时候才触发监听回调。而是在初始化watch时,在读取了监听的数据的值之后,便立即调用一遍你设置的监听回调,然后传入刚读取的值。

  1. 设置了 deep 时,watch 如何工作?
  • 初始化watcher时,会读取监听的data的属性,watch-watcher 被收集在这个属性的依赖收集器中

  • 在读取data 属性的时候,发现设置了 deep 而且值是一个对象,会递归遍历这个值,把内部所有属性逐个读取一遍,于是属性和它的对象值内每一个属性都会收集到 watch 的 watcher

  • 无论对象嵌套多深的属性,只要改变了,就会通知相应的 watch-watcher 去更新,从而触发监听回调