Vue的MVVM模式响应式原理——如何追踪变化之Dep、Watcher、Observer

350 阅读3分钟

最近疲于面试,但是总算告一段落了,特地把这篇文章补上~

前言

Vue的MVVM模式响应式原理之observe、Observer和defineReactive

Vue的MVVM模式响应式原理——数组的特殊处理之偷梁不换柱

本章内容主要是衔接上面两篇的,感兴趣的可以先看看~

  • 老规矩先上结论和图

结论

  1. new Watcher():是一个入口函数,它需要指定对象、表达式,回调;在表达式变化时触发响应的回调;
  2. get():在读取【响应式】数据时,收集依赖,这个依赖是一个 Watcher 的实例;
  3. set():在设置【响应式】数据时,通知依赖,这个通知是 Watcher实例的一个方法 notify
    执行时机:
    1. 在模板解析阶段,解析 {{obj.a}} 时读取 obj.a 的值触发get();
    2.使用 watch API时;
  • 流程图

既然所有层次的数据都已经是响应式的了。
那如何在数据发生变化时,通知所有用到该数据的组件呢

【题内话】回忆一下Vue中 watch的用法,它的原理正是本章要阐述的。

watch
类型:{ [key: string]: string | Function | Object | Array }

详细:
一个对象,键是需要观察的表达式,值是对应回调函数。
值也可以是方法名,或者包含选项的对象。
Vue 实例将会在实例化时调用 $watch(),遍历 watch 对象的每一个 property。

{
  data: {
    a: 1,
  },
  watch:{
    a: function (val, oldVal) {
       console.log('a被改动我就打印', val, oldVal)
    },
  }
  /* 模拟操作 */ 
  data.a = 2
  /* 控制台 */ 
  'a被改动我就打印', 2, 1
}

观察wacht的运用,思考一下它内部是如何实现的呢?这里先抛出问题引起思考,现在来一步一步解答。

一、Vue中运用了发布订阅模式,在读取数据时收集依赖,在更改数据时通知更新

export default function defineReactive(obj, key, value) {
  let childOb = observe(value)
+ let dep = new Dep()
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
+     if (Dep.target) {
+       dep.depend()
+     }
    }
      console.log('访问数据触发get' + '您试图访问' + key + '属性', value)
      return value
    },
    set(newVal) {
      if (newVal === value) {
        return
      }
      console.log('修改数据触发set' + '您试图修改' + key + '属性', newVal)
      value = newVal
+     dep.notify()
    }
  })
}

在get和set中加入这部分的逻辑。并且发现收集依赖的前提是Dep.target存在。

二、Dep和Watcher登场——谁收集依赖,依赖是谁;

Dep依赖收集器

Dep是一个,由Dep类的实例来收集依赖;可以看到每一个【响应式】的属性,都会对应一个dep

let uid = 0;
/**
 * Dep.target 全局的一个标记,在Vue中一次只能处理一个 watcher
 * 每次使用的时候 让Dep.target = watcher , 用完了再指向 null
 * */
Dep.target = null;
/**
 * @this.id   每个Dep实例身上都有一个 id 用于标记自身
 * @this.subs 每个Dep实例身上都有一个 subs 数组用于存放 watcher
 * */
function Dep() {
  this.id = uid++;
  this.subs = [];
}
/**
 * addSub 将 watcher 添加到 dep 中;
 * 由 Dep 的实例调用
 * @sub Watcher的实例
 * */
Dep.prototype.addSub = function (sub) {
  console.log("addSub");
  this.subs.push(sub);
};
/**
 * 在get() 中接调用
 * depend 将 dep 添加到 watcher 中;
 * */
Dep.prototype.depend = function () {
  console.log("在getter中收集依赖,depend");
  Dep.target.addDep(this);
};
/**
 * 在set() 中接调用
 * 调用subs中每一个watcher的update方法。也就是Component Render Function
 * */
Dep.prototype.notify = function () {
  console.log("notify");
  const subs = this.subs.slice();
  for (let i = 0, l = subs.length; i < l; i++) {
    subs[i].update();
  }
};

export { Dep };

Watcher 依赖(订阅者)

Watcher是一个类,它的实例就是一个依赖。为什么又称为订阅者,是因为它会订阅某一个数据,比如{{data.a}},这就是一个订阅。

import { Dep } from "./Dep";

/**
 * @uid2 是一个外部变量,用来标识每个 watcher 的id
 * */
let uid2 = 0;

function Watcher(vm, exp, cb) {
  this.vm = vm; // 🔴🔴🔴🔴🔴🔴🔴🔴 vm 在本文中就指 data 🔴🔴🔴🔴🔴🔴🔴🔴
  this.exp = exp; // 🔴🔴🔴🔴🔴🔴🔴🔴 exp 表达式 比如 data.a 🔴🔴🔴🔴🔴🔴🔴🔴
  this.cb = cb; // 🔴🔴🔴🔴🔴🔴🔴🔴 cb 回调函数 当exp的值变化时调用🔴🔴🔴🔴🔴🔴🔴🔴
  this.id = uid2++;
  this.depIdsAnddeps = {};
  this.getter = parsePath(this.exp);
  this.value = this.get();🔴🔴🔴🔴🔴🔴🔴🔴 每当new Watcher时就会 touch 🔴🔴🔴🔴🔴🔴🔴🔴
}

Watcher.prototype.update = function () {
  /**
   *  update方法会执行视图发生变化时候的回调 cb
   *  这个方法会真正的触发视图的改变
   * */
  let value = this.get();
  let oldValue = this.value
  this.cb.call(this,value,oldValue);
};
/**
 * 将dep添加到watcher中
 * */
Watcher.prototype.addDep = function (dep) {
  if (!this.depIdsAnddeps.hasOwnProperty(dep.id)) {
    console.log("addDep");
    this.depIdsAnddeps[dep.id] = dep;
    dep.addSub(this);
  }
};
Watcher.prototype.get = function () {
  let vm = this.vm;
  Dep.target = this;
  console.log("被触摸 Dep.target=", Dep.target);
  let value = this.getter.call(this, vm);
  Dep.target = null;
  console.log("触摸结束 Dep.target=", Dep.target);
  return value;
};

三、Dep.target使每一个watcher精确地对应dep

  • 仔细看完上面的代码,可以发现,如果不进行 new Watcher() 那么就意味着没有依赖可以收集。因为Dep.target这个标识会一直为null,这就导致get时无法通过if判断;

  • 当new Watcher()后会触发this.get()方法,全局变量Dep.target先一步被赋值为当前Watcher的实例。此时if判断就会通过,那么dep.depend就会收集到该watcher

  • 当set()触发的时,内部会再次进行属性值的访问,因此又会触发get(),而有了这个if判断,就可以避免重复收集了。

四、来做一个假设

1、通过 touch 数据来触发get()

  1. 现在我们所有的数据都已经是【响应式】的了。

  2. 此时Vue内部执行到了解析模板的阶段,那么它势必会解析到这样的内容。 {{data.a}}{{data.b}} 等等..

  3. 此时它的内部就会执行 new Watcher(vm,表达式data.a,cb回调函数..) or new Watcher(vm,表达式data.b,cb回调函数..)

  4. 从而去读取 data.a 和 data.b 。这样一来就会触发data.a 和 data.b 的get()从而建立一个相互对应的关系。

  • 用一张图来说明这个过程

2、通过 修改 数据来触发set()

  1. {{data.a}}的dep和watcher已经建立好了对应关系

  2. 此时操作 data.a = 2

  3. 触发 set() ,那么data.a的dep就会调用notify()方法,这个方法会遍历dep中储存的所有watcher,并调用update方法。

  4. 绑定的回调函数就会被触发,也就是Component Render Function,此时视图就会更新。

(这块内容涉及到Compile,可以手动绑定一个以便理解)

new Watcher(obj, "data.a", function () {
  console.log("Component" + "Render Function" + "视图更新的回调");
});

控制台输出 => "Component" + "Render Function" + "视图更新的回调" , 2 , 1

  • 用一张图来说明这个过程

五、 现在再回过头来看开头的图

回过头来说 Vue 中的 watch 其实就是手动 new Watcher() ,并传入指定的表达式和回调。和本文示例的用法是一致的。

本文源码在我的GitHub仓库中,欢迎访问。mvvm-webpack-demo