给对象或数组的 observer 添加 dep 收集器来收集依赖

新建一个 dist/arr.html 页面:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <div id="app" style="color: red; background: yellow">{{ arr }}</div>
    <script src="vue.js"></script>
    <!-- <script src="<https://cdn.bootcdn.net/ajax/libs/vue/2.6.14/vue.js>"></script> -->
    <script>
      const vm = new Vue({
        data: {
          arr: [1, 2, 3, {a: 1}],
        },
      });
      vm.$mount("#app");
      // vm.arr[0] = 100; // 这种方式能监控到吗?=> 不能,因为只重写了数组方法
      // vm.arr.length = 100; // 这种也不行 => 没有监控长度变化

      vm.arr.push(100); // 这种才行, 但目前没监控(我们之前只监控了 arr 这个属性,但 push 方法没监控,还需要实现)
    </script>
  </body>
</html>

所以我们需要 src/observe/array.js 中监听并更新:

// 我们希望重写数组种的部分方法

let oldArrayProto = Array.prototype; // 获取数组的原型

export let newArrayProto = Object.create(oldArrayProto); // 这样就可以 newArrayProto.__proto__ = oldArrayProto 取值原来的方法

// 找到所有的能修改原数组的方法
let methods = [
  'push',
  'unshift',
  'pop',
  'shift',
  'splice',
  'reverse',
  'sort'
]; // concat, slice... 都不会改变原数组

methods.forEach(method => {
  // [].push(); this 就是这个 []
  newArrayProto[method] = function(...args) { // 重写了数组的方法
    const result = oldArrayProto[method].call(this, ...args); // 内部调用原来的方法, 函数的劫持(切片函数)

    // 还需要对新增的数据再次进行劫持
    let inserted; // 新增的数组成员
    let ob = this.__ob__;
    switch (method) {
      case 'push':
      case 'unshift': // [].unshift(1,2,3); [].push(1,2,3);
        inserted = args;
        break;
      case 'splice': // [].splice(0,0,1,2);
        inserted = args.slice(2);
      default:
        break;
    }
    if (inserted) { // 对新增的内容再次进行观测
      ob.observeArray(inserted);
    }
    
    **// todo: 这里更新**
    return result;
  }
});

所以为了对已有的复杂对象属性(数组和对象)中的值也进行通知更新,我们还需要在 src/observe/index.js 中为每个对象和数组的 observer 添加 dep 收集器:

import { newArrayProto } from "./array";
import Dep from "./dep";

class Observer {
  constructor(data) {
    **// 给每个对象和数组的观察者 observer 也都增加收集功能
    this.dep = new Dep(); // 所有 ob 都要增加dep**
    // Object.defineProperty 只能劫持已经存在的属性,后增的,或者删除的,不知道
    // vue 里边会为此单独写一些 api ($set $delete)
    // data.__ob__ = this; // 不但让观测的数组能在成员是对象的情况下继续调用 observeArray 方法,还给数据加了一个标识,如果数据上有 __ob__ 则说明这个属性被观测过
    // 但上边这种写法会导致死循环, 因为不管是走下边的 isArray 还是 isObject, 都会因为成员中有 __ob__ 导致无限循环
    // 所以需要通过 Object.defineProperty 来设置 __ob__ 的不可迭代属性
    Object.defineProperty(data, '__ob__', {
      value: this,
      enumerable: false, // 将 __ob__ 变成不可枚举
    })
    if (Array.isArray(data)) {
      // 监听 push、 shift 等能修改数组本身的 7 种方法

      // data.__proto__ = ?; // 需要保留数组原有的特性,只重写部分方法
      data.__proto__ = newArrayProto;

      this.observeArray(data); // 如果数组中放的是对象,可以监控到对象的变化
    } else {
      this.walk(data);
    }
  }
  walk(data) { // 循环对象,对属性依次劫持

    // "重新定义"属性(vue2 的性能瓶颈,性能差)
    Object.keys(data).forEach(key => defineReactive(data, key, data[key])); // 单独导出一个 defineReactive ,后续可以单独使用
  }
  observeArray(data) { // 观测数组
    data.forEach(item => observe(item));
  }
}

// 属性劫持
export function defineReactive(target, key, value) { // 闭包(value),不销毁
  **let childOb** = observe(value); // 有可能 value 还是个对象, 所以需要递归劫持, 达到对所有的对象都进行属性劫持的目的(性能瓶颈)
  let dep = new Dep(); // 每一个属性都有一个dep (这些 dep 收集器是不销毁的, 因为闭包的存在)
  Object.defineProperty(target, key, {
    get() { // 取值的时候,执行 get
      // console.log(`用户取值了${key}`);
      if (Dep.target) {
        dep.depend();
        **if (childOb) { // childOb.dep 用来收集依赖
          childOb.dep.depend(); // 让数组和对象本身也实现依赖收集
        }**
      }
      return value;
    },
    set(newValue) { // 修改的时候,执行 set
      // console.log(`用户设置了${key}为${newValue}`);
      if (newValue === value) return;
      observe(newValue); // 有可能用户这样操作: vm.address = {} 给属性赋值成一个对象或者赋值成一个新对象
      value = newValue;
      dep.notify(); // 通知更新
    }
  })
}

export function observe(data) {
  // 对这个对象进行劫持
  if (typeof data !== "object" || data == null) {
    return; // 只对对象进行劫持
  }
  if (data.__ob__ instanceof Observer) { // 说明这个对象被代理过了
    return data.__ob__;
  }
  // 如果一个对象被劫持过了,那就不需要再被劫持了(要判断一个对象是否被劫持,可以增添一个实例,用实例来判断是否被劫持)
  return new Observer(data);
}

收集完后我们在 src/observe/array.js 中操作数据方法后进行通知:

// 我们希望重写数组种的部分方法

let oldArrayProto = Array.prototype; // 获取数组的原型

export let newArrayProto = Object.create(oldArrayProto); // 这样就可以 newArrayProto.__proto__ = oldArrayProto 取值原来的方法

// 找到所有的能修改原数组的方法
let methods = [
  'push',
  'unshift',
  'pop',
  'shift',
  'splice',
  'reverse',
  'sort'
]; // concat, slice... 都不会改变原数组

methods.forEach(method => {
  // [].push(); this 就是这个 []
  newArrayProto[method] = function(...args) { // 重写了数组的方法
    const result = oldArrayProto[method].call(this, ...args); // 内部调用原来的方法, 函数的劫持(切片函数)

    // 还需要对新增的数据再次进行劫持
    let inserted; // 新增的数组成员
    let ob = this.__ob__;
    switch (method) {
      case 'push':
      case 'unshift': // [].unshift(1,2,3); [].push(1,2,3);
        inserted = args;
        break;
      case 'splice': // [].splice(0,0,1,2);
        inserted = args.slice(2);
      default:
        break;
    }
    if (inserted) { // 对新增的内容再次进行观测
      ob.observeArray(inserted);
    }

    **ob.dep.notify(); // 数组变化了,通知对应的 watcher 实现更新逻辑**
    return result;
  }
});

这样一来,我们对数组进行 push 就能被正确渲染了:

dist/arr.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <div id="app" style="color: red; background: yellow">
      {{ arr }}
    </div>
    <!-- <script src="vue.js"></script> -->
    <script src="<https://cdn.bootcdn.net/ajax/libs/vue/2.6.14/vue.js>"></script>
    <script>
      const vm = new Vue({
        data: {
          arr: [1, 2, 3, {a: 1}], // 给数组本身增加dep 如果数组新增了某一项 我可以触发dep更新
          a: { a: 1 } // 给对象也增加dep, 如果后续用户增添了属性 我可以触发dep更新
        },
      });
      vm.$mount("#app");
      // vm.arr[0] = 100; // 这种方式能监控到吗?=> 不能,因为只重写了数组方法
      // vm.arr.length = 100; // 这种也不行 => 没有监控长度变化

      setTimeout(() => {
        vm.arr.push(999);
      }, 1000);
    </script>
  </body>
</html>

image.png

$set 原理

如果你想要通过索引的方式修改数组,也可以采用 vm.arr.__ob__.dep.notify(); 手动调用数组的 observer 观察者的收集器的通知的方式,换句话说就是找到这对象(或数组)上的 dep ,让它去通知页面更新

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <div id="app" style="color: red; background: yellow">
      {{ arr }}
    </div>
    <script src="vue.js"></script>
    <!-- <script src="<https://cdn.bootcdn.net/ajax/libs/vue/2.6.14/vue.js>"></script> -->
    <script>
      const vm = new Vue({
        data: {
          arr: [1, 2, 3, {a: 1}], // 给数组本身增加dep 如果数组新增了某一项 我可以触发dep更新
          a: { a: 1 } // 给对象也增加dep, 如果后续用户增添了属性 我可以触发dep更新
        },
      });
      vm.$mount("#app");
      // vm.arr[0] = 100; // 这种方式能监控到吗?=> 不能,因为只重写了数组方法
      // vm.arr.length = 100; // 这种也不行 => 没有监控长度变化

      setTimeout(() => {
        vm.arr[0] = 999;
        **vm.arr.__ob__.dep.notify();**
      }, 1000);
    </script>
  </body>
</html>

image.png

这也是 Vue $set 的原理。

递归依赖收集

现在看着功能已经实现,但是如果数组中还有数组,并且改变子数组时,就会发现又无法更新页面了: