Vue侦测数组变化的原理


如何追踪变化

Object的变化是靠setter来追踪的,只要一个数据发生了变化,一定会触发setter。

Array是通过push等数组原型的操作来改变数组的内容,因此只要在用户使用push操作数组的时候得到通知,就能实现同样目的。

可以利用一个拦截器覆盖Array.prototype,每当使用Array原型上的方法操作数组时,其实执行的都是拦截器中提供的方法。然后,在拦截器中使用原始Array的原型方法去操作数组。

拦截器

拦截器其实就是一个和Array.prototype一样的Object,里面包含的属性一模一样,只不过这个Object中某些可以改变数组自身内容的方法使我们处理过的。

Array原型中可以改变自身的方法有:push、pop、shift、unshift、splice、sort和reverse

      // 拦截器
      const changeSelf = [
        "push",
        "pop",
        "shift",
        "unshift",
        "splice",
        "sort",
        "reverse",
      ];
      const arrayProto = Array.prototype;
      // arrayMethod的原型为arrayProto,即arrayMethod._proto===arrayProto
      const arrayMethod = Object.create(arrayProto);
      // 捕获数组原型上的方法,添加到arrayMethod上
      changeSelf.forEach((method) => {
        const original = arrayProto[method];
        Object.defineProperty(arrayMethod, method, {
          value: function mutator(...args) {
              return original.apply(this, args);
          },
          enumerable: false,
          configurable: true,
          writable: true,
        });
      });

当使用push方法时,其实调用的是arrayMethod.push,而arrayMethod.push是函数mutator,也就是实际上执行的是mutator函数。

使用拦截器覆盖Array原型

有了拦截器,想让他生效,就需要去覆盖Array原型,但是直接覆盖会污染全局的Array,这并不是我们所希望的结果。
我们只希望拦截器只覆盖那些响应式数组的原型。

可以通过Observer,在Observer中使用拦截器覆盖那些即将被转换成响应式Array数据的原型就好了:

     class Observer {
        constructor(value) {
          this.value = value;
          if (Array.isArray(value)) {
            value.__proto__ = arrayMethod;
          } else {
            this.walk(value);
          }
        }
     }

将拦截器方法挂载到数组的属性上

并不是所有浏览器都支持__proto__,因此,需要处理不能使用__proto__的情况。

Vue的做法是直接将arrayMethod身上的方法设置到被侦测的数组上:

      const hasproto = "__proto__" in {};
      const arraykeys = Object.getOwnPropertyNames(arrayMethod);

      class Observer {
        constructor(value) {
          this.value = value;
          if (Array.isArray(value)) {
            const augment = hasproto ? protoAugment : copyAugment;
            augment(value, arrayMethod, arraykeys);
          } else {
            this.walk(value);
          }
        }
      }
      function protoAugment(target, src, keys) {
        target.__proto__ = src;
      }
      function copyAugment(target, src, keys) {
        for (let i = 0; i < keys.length; i++) {
          def(target, keys[i], src[key[i]]);
        }
      }

如何收集依赖

前面我们创建了拦截器,我们之所以创建拦截器,本质上是为了得到一种能力,一种当数组的内容发生变化时得到通知的能力。

前面介绍Object时,是通知Dep中的依赖(watcher),数组也是如此,通知给Dep中的依赖。

而Object的依赖是如何收集的呢?

Object的依赖前面介绍过,是在defineReactive中的getter里使用Dep收集的,每个key都会有一个对应的Dep列表来存储依赖。
简单地说,就是在getter中收集依赖,依赖被存储在Dep里。

而Array的依赖和Object的依赖一样,也是在defineReactive中收集:

    function defineReactive(data, key, val){
      if(typeof val === 'object') new Observer(val)
      let dep = new Dep();
      Object.defineProperty(data,key,{
        enumerable: true,
        configurable: true,
        get(){
          dep.depend();
          return val;
        },
        set(newVal){
          if(val===newVal){
            return
          }
          dep.notify();
          val = newVal
        }
      })
    }

所以,Array是在getter中收集依赖,在拦截器中触发依赖。

依赖列表存在哪儿

Vue.js把Array的依赖存放在Observer中:

      class Observer {
        constructor(value) {
          this.value = value;
          this.dep = new Dep();

          if (Array.isArray(value)) {
            const augment = hasproto ? protoAugment : copyAugment;
            augment(value, arrayMethod, arraykeys);
          } else {
            this.walk(value);
          }
        }
      }

数组在getter中收集依赖,在拦截器中触发依赖,因此它必须在getter和拦截器中都可以访问到。之所以把Dep保存在Observer实例上,是因为在getter中可以访问到Observer实例,同时在Array拦截器中也可以访问到Observer实例。

收集依赖

把Dep实例保存在Observer的属性上之后,我们可以在getter中像下面这样访问来收集依赖:

      function defineReactive(data, key, val) {
        let childOb = observe(val);
        let dep = new Dep();
        Object.defineProperty(data, key, {
          enumerable: true,
          configurable: true,
          get() {
            dep.depend();

            if(childOb){
              childOb.dep.depend();
            }
            return val;
          },
          set(newVal) {
            if (val === newVal) {
              return;
            }
            dep.notify();
            val = newVal;
          },
        });
      }
      /* 
      为value创建一个Observer实例
      如果创建成功,直接返回新创建的实例
      如果value已经存在一个Observer实例,则直接返回它
      */
      function observe(value, asRootData){
        if(typeof value !== 'object'){
          return
        }
        let ob
        if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
          ob = value.__ob__;
        }else{
          ob = new Observer(value);
        }
        return ob;
      }

上述代码中,新增了observe函数,该函数的作用是创建一个响应式的Observer实例,如果value已经是响应式数据,无需再次创建,直接返回已经创建好的Observer实例即可,避免了重复侦测value变化的问题。

通过这种方式,我们可以实现在getter中将依赖收集到Observer实例的Dep中,即:为数组收集依赖

在拦截器中获取Observer实例

因为Array拦截器是对原型的一种封装,所以可以在拦截器中访问到this(当前正在被操作的数组)。
而dep保存在Observer中,所以需要在this上读到Observer的实例:

      // 工具函数
      function def(obj,key,val,enumerable){
        Object.defineProperty(obj, key, {
          value: val,
          enumerable: !!enumerable,
          configurable: true,
          writable: true
        })
      }

      class Observer {
        constructor(value) {
          this.value = value;
          this.dep = new Dep();
          def(value, '__ob__', this);

          if (Array.isArray(value)) {
            const augment = hasproto ? protoAugment : copyAugment;
            augment(value, arrayMethod, arraykeys);
          } else {
            this.walk(value);
          }
        }
      }

上述代码中def工具函数的作用是,在value上新增一个不可枚举的属性__ob__,这个属性的值就是当前Observer实例。

这样我们就可以通过数组数据的__ob__属性拿到Observer实例,从而拿到__ob__上的dep。

__ob__的作用不仅是为了在拦截器器中访问Observer实例,还可以用来标记当前value是否已经被Observer转换成响应式数据。

所有被侦测了变化的数据身上都会有一个__ob__属性来表示他们是响应式的,上一节的observe函数就是通过__ob__属性来判断:如果value是响应式的,就直接返回__ob__;如果不是,则使用new Observer来将数据转换成响应式的。

向数组的依赖发送通知

      ["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
        (method) => {
          const original = arrayProto[method];
          def(arrayMethod, method, function mutator(...args) {
            const result = original.apply(this, args);
            const ob = this.__ob__;
            ob.dep.notify();
            return result;
          });
        }
      );

侦测数组中元素的变化

所有响应式数据的子数据都要侦测,不论是Object中的数据还是Array中的数据,如果用户使用了push往数组中新增了数据,那么这个元素的变化也需要侦测。

这里先介绍如何侦测所有数据子集的变化:

      class Observer {
        constructor(value) {
          this.value = value;
          this.dep = new Dep();
          def(value, "__ob__", this);

          if (Array.isArray(value)) {
            const augment = hasproto ? protoAugment : copyAugment;
            augment(value, arrayMethod, arraykeys);

            this.observeArray(value);
          } else {
            this.walk(value);
          }
        }
        // 侦测数组的子数据
        observeArray(items) {
          for (let i = 0; i < items.length; i++) {
            observe(items[i]);
          }
        }
        // 侦测对象的所有属性
        walk(obj) {
          const keys = Object.keys(obj);
          keys.forEach((key) => {
            defineReactive(obj, key, obj[key]);
          });
        }
      }

侦测新增元素的变化

  1. 获取新增元素
      ["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
        (method) => {
          const original = arrayProto[method];
          // 向数组的依赖发送通知
          def(arrayMethod, method, function mutator(...args) {
            const result = original.apply(this, args);
            const ob = this.__ob__;
            let inserted;
            switch (method) {
              case "push":
              case "unshift":
                inserted = args;
                break;
              case "splice":
                inserted = args.slice(2);
                break;
            }
            ob.dep.notify();
            return result;
          });
        }
      );
  1. 使用Observer侦测新增元素
      ["push", "pop", "shift", "unshift", "splice", "sort", "reverse"].forEach(
        (method) => {
          const original = arrayProto[method];
          // 向数组的依赖发送通知
          def(arrayMethod, method, function mutator(...args) {
            const result = original.apply(this, args);
            const ob = this.__ob__;
            let inserted;
            switch (method) {
              case "push":
              case "unshift":
                inserted = args;
                break;
              case "splice":
                inserted = args.slice(2);
                break;
            }
            if (inserted) ob.observeArray(inserted);
            ob.dep.notify();
            return result;
          });
        }
      );

关于Array的问题

  1. 修改数组中第一个元素的值时,无法侦测到数组的变化;
    this.list[0] = 2
  2. 通过length清空数组的操作也无法侦测到数组的变化。
    this.list.length = 0

总结

  1. Array是通过方法来改变内容的,所以我们通过创建拦截器去覆盖数组原型的方式来追踪变化。
  2. 为了不污染全局Array.prototype,在Observer里只针对那些需要侦测变化的数组使用__proto__来覆盖原型方法,对于不支持__proto__属性的浏览器,直接循环拦截器,把拦截器中的方法直接设置到数组身上来拦截Array.prototype上的原生方法。
  3. Array在getter中收集依赖,在拦截器中向依赖发消息,因此依赖保存在了Observer实例上。
  4. 对每个侦测了变化的数据都标上印记__ob__,并把this(Observer实例)保存在__ob__上,一方面为了标记数据是否被侦测了变化(保证一个数据只被侦测一次),另一方面可以通过数据取到__ob__,从而拿到Observer实例上保存的依赖。当拦截到数组发生变化时,向依赖发送通知。

源自《深入浅出Vue.js》

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Vue 中,可以使用 `watch` 监听数组变化。但是由于 JavaScript 的限制,使用 `watch` 监听数组变化时只能监听到数组的长度变化数组内部元素的引用变化,而不能监听到数组内部元素的属性变化。 如果需要监听数组内部元素的属性变化,可以使用 Vue 提供的 `$set` 或者 `splice` 方法来修改数组,并且在修改后使用 `watch` 监听数组变化。 示例代码如下: ```html <template> <div> <ul> <li v-for="(item, index) in list" :key="index">{{ item.title }}</li> </ul> </div> </template> <script> export default { data() { return { list: [ { title: "Vue.js 实战教程" }, { title: "JavaScript 高级编程" }, { title: "Python 基础教程" }, ], }; }, mounted() { // 监听数组变化 this.$watch("list", (newVal, oldVal) => { console.log("list changed:", newVal, oldVal); }, { deep: true }); // 使用 deep 选项可以监听到数组内部元素的属性变化 }, methods: { changeTitle(index, newTitle) { // 修改数组内部元素的属性 this.$set(this.list[index], "title", newTitle); }, addBook(title) { // 使用 splice 方法添加新元素 this.list.splice(this.list.length, 0, { title }); }, }, }; </script> ``` 在上面的示例中,我们使用 `$set` 方法来修改了数组内部元素的属性,并使用 `splice` 方法来添加了新元素,然后在 `mounted` 钩子中使用 `watch` 监听了数组变化。注意,为了监听到数组内部元素的属性变化,我们需要将 `deep` 选项设置为 `true`。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值