Vue.js 源码(3)—— Array 的变化侦测

808 阅读5分钟

这是我参与更文挑战的第3天,活动详情查看: 更文挑战

前言

之前我们学了 Object 的侦测变化,那为什么 Array 要单独来讲呢?我们用下面的例子来说明一下:

this.list.push(1)

Object 可以通过 getter/setter 来实现状态的侦测,而数组的 push 方法,无法触发 getter/setter。本文,我们将学习 Array 是如何实现变化侦测的。

如何追踪变化

Object 是通过 setter 来通知依赖 update 的。如果我们能在数组 push 的时候发出通知,就能实现相同的效果。

我们可以将数组的原型指向一个新的对象,这个对象会重写 Array.prototype 上的数组方法。

image.png

拦截器

上面所说的既有数组方法,又能实现通知功能的对象,我们给它取个名字,叫做 拦截器(这也是 代理模式 的一种体现)。

如何实现一个拦截器呢?

我们可以发现 Array.prototype 中 可以改变数组自身内容的方法共有7个: push、pop、shift、unshift、spice 和 reverse

const arrayProto = array.prototype;

export const arrayMethods = Object.create(arrayProto);

[
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
].forEach(function (method){
    // 缓存原型上的方法
    const original = arrayProto[method];
    Object.defineProperty(arrayMethods, method, {
        enumerable: false,
        writable: true,
        configurable: true,
        value: function mutator(...args){
            // ...
            // 调用原型上的方法
            return original.apply(this.args)
        }
    })
})

有了拦截器之后,我们该如何让它生效?暴力的方式是直接修改 Array.prototype,但是这种方式会污染全局的 Array,所以我们要换个方式———把数组实例的原型指向拦截器。

  1. 利用 __proto__
  2. 利用 ES6 的 Object.setPrototypeOf()

考虑兼容性的问题,我们使用第一种,如果连 __proto__ 也不支持的话,就把拦截器的方法直接复制到数组实例上。

function protoAugment(target, src, keys) {
  target.__proto__ = src;
}

function copyAugment(target, src, keys) {
  for (let i = 0, len = keys.length; i < len; i++) {
    const key = keys[i];
    def(target, key, src[key]);
  }
}

function def(target, key, val, enumerable?: boolean) {
  Object.defineProperty(target, key, {
    enumerable: !!enumerable,
    writable: true,
    configurable: true,
    value: val,
  });
}

我们现在重新改造一下 Observer:

const hasProto = '__proto__' in {};
export default class Observer {
  constructor(value) {
    this.value = value;
    if (Array.isArray(value)) {
      const augment = hasProto ? protoAugment : copyAugment;
      augment(value, arrayMethods, arrayKeys);
    } else {
      this.walk(value); // 把对象的所有属性都转成 getter/setter
    }
  }

  walk(obj) {
    Object.keys(obj).forEach((key) => {
      defineReactive(obj, key, obj[key]);
    });
  }
}

如何收集依赖

上面我们实现了拦截器,但是这个拦截器还不具备通知依赖的功能。要实现通知依赖,首先得先实现收集依赖的功能。那么,数组是如何收集依赖的呢?

我们先回顾一下 Object 的依赖是如何收集的。Object 的依赖收集是在 getter 中使用 Dep 实例收集的,每个 key 都有一个 dep 来收集依赖。

其实,数组也是在 getter 中收集依赖的

{
    list: [1,2,3,4]
}

在读取 list 的时候,会触发 list 属性的 getter。

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: function () {
      dep.depend();
      // 收集数组的依赖
      return val;
    },
    set: function (newVal) {
      if (val === newVal) return;
      dep.notify();
      val = newVal;
    },
  });
}

Array 在 getter 中收集依赖,在拦截器中触发依赖

依赖收集在哪里

Vue.js 把 Array 的依赖存放在 Observer 实例上:

export default class Observer {
  constructor(value) {
    this.value = value;
    this.dep = new Dep(); // 数组的依赖收集在这里
    if (Array.isArray(value)) {
      const augment = hasProto ? protoAugment : copyAugment;
      augment(value, arrayMethods, arrayKeys);
    } else {
      this.walk(value); // 把对象的所有属性都转成 getter/setter
    }
  }
}

收集依赖

在 Observer 实例上添加一个 dep 属性后,我们就可以收集依赖了。

function defineReactive(data, key, val) {
  let childOb = observe(val);
  let dep = new Dep();
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      dep.depend();
      // 新增
      if (childOb) {
        childOb.dep.depend();
      }
      return val;
    },
    set: function (newVal) {
      if (val === newVal) return;
      dep.notify();
      val = newVal;
    },
  });
}

function observe(value, asRootData) {
  if (typeof value !== "object") {
    return;
  }
  let ob;
  if (value.hasOwnProperty("__ob__") && value.__ob__ instanceof Observer) {
    ob = value.__ob__;
  } else {
    ob = new Observer(value);
  }
  return ob;
}

通知依赖更新

上面我们已经完成了数组的依赖收集,接下来就差通知依赖了。要想通知依赖,我们需要能拿到 Observer 实例上的 dep,那么想一想如何在拦截器中访问到 dep?

心细的同学可能已经注意到了,在上面的 observe 函数中出现了 __ob__,没错核心就是它。

export default 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, arrayMethods, arrayKeys);
    } else {
      this.walk(value); // 把对象的所有属性都转成 getter/setter
    }
  }
}

我们在 value 上定义一个新属性 __ob__ 指向 Observer 实例,然后我们就可以在拦截器中在 value 上访问到 Observer 实例,也就能访问到它的 dep 属性。

const arrayProto = array.prototype;
export const arrayMethods = Object.create(arrayProto);
[
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
].forEach(function (method){
    constt original = arrayProto[method];
    Object.defineProperty(arrayMethods, method, {
        enumerable: false,
        writable: true,
        configurable: true,
        value: function mutator(...args){
            let ob = value.__ob__; // 新增
            ob.dep.notify() // 新增
            return original.apply(this.args)
        }
    })
})

侦测数组中元素的变化

上面,我们实现了数组的依赖收集和依赖的通知。那么如果数组中存在对象怎么办呢?

如果数组中某个对象的属性发生变化,按照道理也需要发送通知。另外,如果往数组中添加了一个对象,也需要把这个对象转成响应式的对象。所以,我们需要遍历数组,尝试把数组中元素转成响应式的。

export default class Observer {
  constructor(value) {
    this.value = value;
    this.dep = new Dep();
    if (Array.isArray(value)) {
      const augment = hasProto ? protoAugment : copyAugment;
      augment(value, arrayMethods, arrayKeys);
      this.observeArray(value); // 新增
    } else {
      this.walk(value);
    }
  }

  observerArray(list) {
    for (let i = 0, len = list.length; i < l; i++) {
      observe(list[i]); // 每一项都尝试转成响应式的
    }
  }
}

侦测数组新增元素

我们可以在拦截器中,把新增元素传给 __ob__observeArray 方法,就能把新增元素也尝试转成响应式的。

const arrayProto = array.prototype;
export const arrayMethods = Object.create(arrayProto);
[
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
].forEach(function (method){
    const original = arrayProto[method];
    Object.defineProperty(arrayMethods, method, {
        enumerable: false,
        writable: true,
        configurable: true,
        value: function mutator(...args){
            const result = original.apply(this.args)
             let ob = value.__ob__; 
            // 新增, 将新增的元素也尝试转成响应式的
            let inserted;
            switch(metthod){
                case 'push':
                case 'unshift':
                    inserted = args;
                    break;
                case 'splice':
                    inserted = args.slice(2);
                    break;
            }
            if (inserted) ob.serveArray(inserted);
            ob.dep.notify() 
            
            return result;
        }
    })
})

Array 的问题

直接通过下标修改元素,以及 list.length = 0 来清空数组,这样的变化是无法侦测到的。

总结

Array 的变化侦测是如何实现的

  • 与 Object 不同,我们将数组实例的原型指向我们定义的拦截器,在 getter 中收集依赖,在拦截器中通知依赖。
  • 为了在 getter 中收集依赖,我们给 observer 添加了一个实例属性 dep
  • 为了能通知依赖,我们定义 value.__ob__ 属性,指向 observer 实例, 在拦截器中使用 value.__ob__.dep.notify() 来通知依赖。
  • 考虑到数组中元素可能是对象,为了侦测到数组中对象元素的变化,尝试把数组中的元素也转成响应式,在 observer 上新增 observeArray 方法,既能在初始化时将数组中元素转成响应式,也能在数组的拦截器中,调用 value.__ob__.observeArray(inserted),将新增元素也尝试转成响应式的。