14

手写Vue源码(七) - 实现Watch属性

 3 years ago
source link: https://zhuanlan.zhihu.com/p/344313916
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

手写Vue源码(七) - 实现Watch属性

Enjoy what you are doing!

watch

对于watch的用法,在Vue文档 中有详细描述,它可以让我们观察data中属性的变化。并提供了一个回调函数,可以让用户在属性值变化后做一些事情。

watch对象中的value分别支持函数、数组、字符串、对象,较为常用的是函数的方式,当想要观察一个对象以及对象中的每一个属性的变化时,便会用到对象的方式。

下面是官方的一个例子,相信在看完之后就能对watch的几种用法有大概的了解:

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)
    },
    // string method name
    b: 'someMethod',
    // the callback will be called whenever any of the watched object properties change regardless of their nested depth
    c: {
      handler: function (val, oldVal) { /* ... */ },
      deep: true
    },
    // the callback will be called immediately after the start of the observation
    d: {
      handler: 'someMethod',
      immediate: true
    },
    // you can pass array of callbacks, they will be called one-by-one
    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

初始化watch

在了解了watch的用法之后,我们开始实现watch

在初始化状态initState时,会判断用户在实例化Vue时是否传入了watch选项,如果用户传入了watch,就会进行watch的初始化操作:

// src/state.js
function initState (vm) {
  const options = vm.$options;
  if (options.watch) {
    initWatch(vm);
  }
}

initWatch中本质上是为每一个watch中的属性对应的回调函数都创建了一个watcher

// src/state.js
function initWatch (vm) {
  const { watch } = vm.$options;
  for (const key in watch) {
    if (watch.hasOwnProperty(key)) {
      const userDefine = watch[key];
      if (Array.isArray(userDefine)) { // userDefine是数组,为数组中的每一项分别创建一个watcher
        userDefine.forEach(item => {
          createWatcher(vm, key, item);
        });
      } else {
        createWatcher(vm, key, userDefine);
      }
    }
  }
}

createWatcher中得到的userDefine可能是函数、对象或者字符串,需要分别进行处理:

function createWatcher (vm, key, userDefine) {
  let handler;
  if (typeof userDefine === 'string') { // 字符串,从实例上取到对应的method
    handler = vm[userDefine];
    userDefine = {};
  } else if (typeof userDefine === 'function') { // 函数
    handler = userDefine;
    userDefine = {};
  } else { // 对象,userDefine中可能会包含用户传入的deep,immediate属性
    handler = userDefine.handler;
    delete userDefine.handler;
  }
  // 用处理好的参数调用vm.$watch
  vm.$watch(key, handler, userDefine);
}

createWatcher中对参数进行统一处理,之后调用了vm.$watch,在vm.$watch中执行了Watcher的实例化操作:

export function stateMixin (Vue) {
  // some code ...
  Vue.prototype.$watch = function (exprOrFn, cb, options) {
    const vm = this;
    const watch = new Watcher(vm, exprOrFn, cb, { ...options, user: true });
  };
}

此时new Watcher时传入的参数如下:

  • vm: 组件实例
  • exprOrFn: watch选项对应的key
  • cb: watch选项中key对应的value中提供给用户处理逻辑的回调函数,接收keydata中的对应属性的旧值和新值作为参数
  • options: {user: true, immediate: true, deep: true}, immediatedeep属性当key对应的value为对象时,用户可能会传入

Watcher中会判断options中有没有user属性来区分是否是watch属性对应的watcher:

class Watcher {
  constructor (vm, exprOrFn, cb, options = {}) {
    this.user = options.user;
    if (typeof exprOrFn === 'function') {
      this.getter = this.exprOrFn;
    }
    if (typeof exprOrFn === 'string') { // 如果exprFn传入的是字符串,会从实例vm上进行取值
      this.getter = function () {
        const keys = exprOrFn.split('.');
        // 后一次拿到前一次的返回值,然后继续进行操作
        // 在取值时,会收集当前Dep.target对应的`watcher`,这里对应的是`watch`属性对应的`watcher`
        return keys.reduce((memo, cur) => memo[cur], vm);
      };
    }
    this.value = this.get();
  }

  get () {
    pushTarget(this);
    const value = this.getter();
    popTarget();
    return value;
  }

  // some code ...  
}

这里有俩个重要的逻辑:

  • 由于传入的exprOrFn是字符串,所以this.getter的逻辑就是从vm实例上找到exprOrFn对应的值并返回
  • watcher实例化时,会执行this.get,此时会通过this.getter方法进行取值。取值就会触发对应属性的get方法,收集当前的watcher作为依赖
  • this.get的返回值赋值给this.value,此时拿到的就是旧值

当观察的属性值发生变化后,会执行其对应的set方法,进而执行收集的watch对应的watcherupdate方法:

class Watcher {

  // some code ...
  update () {
    queueWatcher(this);
  }

  run () {
    const value = this.get();
    if (this.user) {
      this.cb.call(this.vm, value, this.value);
      this.value = value;
    }
  }
}

和渲染watcher相同,update方法中会将对应的watch watcher去重后放到异步队列中执行,所以当用户多次修改watch属性观察的值时,并不会不停的触发对应watcher 的更新操作,而只是以它最后一次更新的值作为最终值来执行this.get进行取值操作。

当我们拿到观察属性的最新值之后,执行watcher中传入的回调函数,传入新值和旧值。

下面画图来梳理下这个过程:

deepimmdediate属性

当用户传入immediate属性后,会在watch初始化时便立即执行对应的回调函数。其具体的执行位置是在Watcher实例化之后:

Vue.prototype.$watch = function (exprOrFn, cb, options) {
  const vm = this;
  const watcher = new Watcher(vm, exprOrFn, cb, { ...options, user: true });
  if (options.immediate) { // 在初始化后立即执行watch
    cb.call(vm, watcher.value);
  }
};

此时watcher.value是被观察的属性当前的值,由于此时属性还没有更新,所以老值为undefined

如果watch观察的属性为对象,那么默认对象内的属性更新,并不会触发对应的回调函数。此时,用户可以传入deep选项,来让对象内部属性更新也调用对应的回调函数:

class Watcher {
  // some code ...
  get () {
    pushTarget(this);
    const value = this.getter();
    if (this.deep) { // 继续遍历value中的每一项,触发它的get方法,收集当前的watcher
      traverse(value);
    }
    popTarget();
    return value;
  }
}

当用户传入deep属性后,get方法中会执行traverse方法来遍历value中的每一个值,这样便可以继续触发value中属性对应的get方法,为其收集当前的watcher作为依赖。这样在value 内部属性更新时,也会通知其收集的watch watcher进行更新操作。

traverse的逻辑只是递归遍历传入数据的每一个属性,当遇到简单数据类型时便停止递归:

// traverse.js
// 创建一个Set,遍历之后就会将其放入,当遇到环引用的时候不会行成死循环
const seenObjects = new Set();

export function traverse (value) {
  _traverse(value, seenObjects);
  // 遍历完成后,清空Set
  seenObjects.clear();
}

function _traverse (value, seen) {
  const isArr = Array.isArray(value);
  const ob = value.__ob__;
  // 不是对象并且没有被观测过的话,终止调用
  if (!isObject(value) || !ob) {
    return;
  }
  if (ob) {
    // 每个属性只会有一个在Observer中定义的dep
    const id = ob.dep.id;
    if (seen.has(id)) { // 遍历过的对象和数组不再遍历,防止环结构造成死循环
      return;
    }
    seen.add(id);
  }
  if (isArr) {
    value.forEach(item => {
      // 继续遍历数组中的每一项,如果为对象的话,会继续遍历数组的每一个属性,即对对象属性执行取值操作,收集watch watcher
      _traverse(item, seen);
    });
  } else {
    const keys = Object.keys(value);
    for (let i = 0; i < keys.length; i++) {
      // 继续执行_traverse,这里会对 对象 中的属性进行取值
      _traverse(value[keys[i]], seen);
    }
  }
}

需要注意的是,这里利用Set来存储每个属性对应的depid。这样当出现环时,Set中已经存储过了其对应depid,便会终止递归。

本文一步步实现了Vuewatch属性,并对内部的实现逻辑提供了笔者相应的理解 。

希望小伙伴在读完本文后,能够在Vue项目中使用到watch属性时,从源码的角度理解它到底帮我们做了些什么!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK