简析Vue中的MVVM实现原理

1. MVVM

angular - 脏值检测

vue - 数据劫持+发布订阅模式(不兼容低版本:因为其依赖于Object.defineProperty)

2. Object.defineProperty()

1.1 概念

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。定义的这个属性具有使用 Object.defineProperty() 为其附上的特性。

语法:

Object.defineProperty(obj, prop, descriptor)

obj:要在其上定义属性的对象。

prop:要定义或修改的属性的名称。

descriptor:将被定义或修改的属性描述符。

示例:

var obj = { age: 18 }
Object.defineProperty(obj, 'name', {
  value: 'esunr'
});
> obj
< { age: 18, name: "esunr" }

但是当我们使用 delete obj.school; 是无法删除属性的,为了实现删除 objschool 属性,我们需要去使用属性修饰符:

  let obj = {};
  Object.defineProperty(obj, 'school', {
+   configurable: true,
    value: 'esunr'
  });
  delete obj.school;
  console.log(obj);

但是不是使用 Object.defineProperty() 方法定义的对象属性,可以不受限制任意读写,如:

> obj.age = 19;
< 19
> obj
< { age: 19, name: "esunr" }

1.2 属性修饰符

在上面的代码中,valueconfigurable 都属于属性修饰符,使用 Object.defineProperty 时,我们要对每一个值都独立配置这些属性修饰符。

数据描述符和存取描述符均具有以下可选键值:

configurable

当且仅当该属性的 configurable 为 true 时,该属性描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false

enumerable

当且仅当该属性的enumerabletrue时,该属性才能够出现在对象的枚举属性中。默认为 false。(默认不可使用 for..in 循环)

数据描述符同时具有以下可选键值

value

该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined

writable

当且仅当该属性的writabletrue时,value才能被赋值运算符改变。默认为 false

存取描述符同时具有以下可选键值

1.3 get()与set()

get

一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。当访问该属性时,该方法会被执行,方法执行时没有参数传入,但是会传入this对象(由于继承关系,这里的this并不一定是定义该属性的对象)。

默认为 undefined

存在 get() 时,不能存在 value 属性

示例:

let obj = {};
Object.defineProperty(obj, 'name', {
  configurable: true,
  get(){
    // 获取obj.name的值时会调用get方法
    return 'esunr'
  }
});
> obj.name
< "esunr"

set

一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。当属性值修改时,触发执行该方法。该方法将接受唯一参数,即该属性新的参数值。

默认为 [undefined]

示例:

let obj = {};
Object.defineProperty(obj, 'name', {
  configurable: true,
  set(val){
    console.log(val)
  }
});
> obj.name = "xiaoming"
< "xiaoming"

3. 数据劫持

在使用vue时,我们通常将这样定义一个vm实例:

let vm = new Vue({
  el: 'app',
  data: { a: 2 }
})  

实际上,Vue在其内部代码中进行了一些操作:

  1. 将所有vm实例的配置项都转入到变量 $options
  2. 将配置项 data 中的数据进行劫持,存放到vm实例上的 _data 变量中

那么进行数据劫持的这一步就是为了将用户由 data 传入的数据使用 Object.defineProperty() 方法为其每一项数据挂载一个 get()set() 方法,同时如果 data 传入的某一项数据也是一个对象,那么也要在这个对象上面挂载 get()set() 方法。

20190529151814.png

我们来实现Mvvm对象:

function Mvvm(option = {}) {
  this.$options = option; // 将所有属性挂载了$options上
  var data = this._data = this.$options.data;
  observe(data);
}
// vm.$options


// 观察对象给对象增加 ObjectDefineProperty
function Observe(data) { // 这里写我们的主要逻辑
  for (let key in data) { // 把data属性通过object.defineProperty的方式定义属性
    let val = data[key];
    observe(val); // 如果val是一个对象,就使用递归再为其添加一个 get()、set()方法
    Object.defineProperty(data, key, {
      enumerable: true, // 可枚举
      get() {
        return val;
      },
      set(newVal) {
        if (newVal === val) {
          // 如果设置的值和以前一样,就不执行set操作
          return;
        } else {
          val = newVal; // 如果以后再获取值的时候,将刚才设置的值再丢回去
          observe(newVal); // 如果将数据进行重新赋值后,重新赋值的对象也要添加get()和set()
        }
      }
    })
  }
}

function observe(data) {
  if(typeof data !== 'object') return;
  return new Observe(data);
}

实例化一个vm对象:

let vm = new Vue({
  el: 'app',
  data: { a: {a: 1} }
})  

可以看出其数据上都挂载了一个 get() 方法和 set() 方法:

20190529155054.png

4. 数据代理

在Vue中,我们通过 data 添加的数据不仅挂载到了vm实例的 _data 变量中,同时还挂载到了vm实例本身上,并且在我们正常的使用过程中,更多是去调用vm实例本身来获取数据,而并非 _data ,这时候我们就需要通过数据代理,将 _data 中的数据代理到vm实例上。

我们新增原有的核心代码:

function Mvvm(option = {}) {
  this.$options = option;
  var data = this._data = this.$options.data;
  observe(data);
  // 使用this代理_data
  for(let key in data){
    Object.defineProperty(this,key, {
      enumerable: true,
      get(){
        return this._data[key];
      },
      set(newVal){
        this._data[key] = newVal;
      }
    })
  }
}

实现了Vue的两个特点:

  • 不能新增不存在的属性,因为新增的属性没有get和set

  • 深度相应,每次赋予一个新对象时会给这个新对象增加数据劫持

5. 模板编译

在Vue中,我们在文档节点中使用 {{}} 来将vm中的数据渲染到文档中,这就需要有一个模板编译方法来处理文档节点中的文本,来解析并且读取数据

新增一个Compile对象来执行编译,其包含两个参数,一个el为MVVM模式下的文档范围,vm为MVVM实例:

function Compile(el, vm) {
  // el 表示替换的范围
  vm.$el = document.querySelector(el);
  let fragment = document.createDocumentFragment();
  while (child = vm.$el.firstChild) {
    // 将#app中的内容存放到fragment中,存放入内存等待处理
    fragment.appendChild(child);
  }

  // 替换处理fragment中的文本内容(模拟Vue的模板引擎)
  replace(fragment)

  function replace(fragment) {
    // Array.from() 方法从一个类似数组或可迭代对象中创建一个新的数组实例。
    // 遍历每个fragment中存放的节点
    Array.from(fragment.childNodes).forEach(function (node) {
      let text = node.textContent;
      let reg = /\{\{(.*)\}\}/;
      // 如果当前的节点类型是3(文本节点),就对其进行匹配处理
      if (node.nodeType === 3 && reg.test(text)) {
        console.log(RegExp.$1);
        let arr = RegExp.$1.split(".");
        let val = vm;
        arr.forEach(function (k) {
          val = val[k];
        });
        node.textContent = text.replace(/\{\{(.*)\}\}/, val);
      }
      // 如果当前节点不是根节点,就利用递归去深度遍历其内部节点(注意:普通Element节点的根节点都为文本节点)
      if (node.childNodes) {
        replace(node);
      }
    })
  }

  // 将内存中的dom节点重新加载到页面中(不需要渲染)
  vm.$el.appendChild(fragment);
}

在核心代码中启用:

function Mvvm(options = {}) {
  ... ...
  new Compile(options.el, this);
}

6. 数据更新

在Vue中,当vm实例上挂载的数据发生更新时,视图也会随之刷新,他们之间存在着发布订阅关系。

6.1 发布订阅模式

我们再模拟Vue数据更新机制的时候,需要设计一个发布者的构造函数(Dep)和订阅者的构造函数(Watcher)。

发布者内部存放着一个订阅者队列 subArr,同时其原型上挂载了一个 addSub() 方法用来向订阅者队列中添加订阅者,还有一个 carry() 方法,执行该方法后,会遍历订阅者队列,执行每个订阅者身上挂载的 update() 方法。

每个订阅者内部都传入了一个 fn ,是一个方法函数。同时其原型上挂载了一个 update() 方法,在其方法内部执行了实例化订阅者时传入的方法函数 fn

当发布者发布事件时,只需要调用挂载在其身上的 carry() 方法,就可以将所有订阅者的 update() 方法执行。

20190530111309.png

发布订阅模式的构造如下:

// 1. 构造发布者
function Dep() {
  this.subArr = [];
}
Dep.prototype.addSub = function (sub) {
  this.subArr.push(sub);
}
Dep.prototype.carry = function () {
  this.subArr.forEach(sub => {
    sub.update();
  });
}

// 2. 构造订阅者
function Watcher(fn) {
  this.fn = fn;
}
Watcher.prototype.update = function () {
  this.fn();
}

6.2 模拟Vue中的发布订阅模式

在Vue中创建一个发布订阅机制我们需要考虑以下几个问题:

  • 在哪里创建订阅者 (实例化一个Watcher对象)
  • 在哪里创建发布者 (实例化一个Dep对象)
  • 在哪里添加订阅 (执行发布者的 addSub() 方法)
  • 在哪里发布事件 (执行发布者的 carry() 方法)

每一个渲染出的文本节点对应一个订阅者,一旦发生了数据更新,所有的订阅者的update方法都会被执行,也就是说所有需要解析的文本节点都会被渲染。

6.2.1 订阅者

Vue数据更新机制的订阅者是 Compile 编译器,当数据发生了变更时,编译器需要对模板重新编译渲染。在编译器中,执行了模板替换的方法语句是 node.textContent = text.replace(/\{\{(.*)\}\}/, val); ,那么我们再创建订阅者时,传入其内部的方法就是这条语句:

  function Compile(el, vm) {
    ... ...

    // 替换处理fragment中的文本内容(模拟Vue的模板引擎)
    replace(fragment)

    function replace(fragment) {
      Array.from(fragment.childNodes).forEach(function (node) {
        ... ...
        if (node.nodeType === 3 && reg.test(text)) {
          ... ...
+         new Watcher(function () {
+           node.textContent = text.replace(/\{\{(.*)\}\}/, val);
+         })
          // 替换的逻辑
          node.textContent = text.replace(/\{\{(.*)\}\}/, val);
        }
        if (node.childNodes) {
          ... ...
        }
      })
    }

    // 将内存中的dom节点重新加载到页面中(不需要渲染)
    vm.$el.appendChild(fragment);
  }

这样就达成了一个目的:在页面加载完成后实例化 Compile 时,在执行模板编译的过程中,为每个文本节点对象都渲染出一个订阅者实例,去观察其对应的数据是否变动,如果数据变动,就触发当前文本节点的重新渲染。

我们先不讨论实例化的订阅者何时被调用挂载于其身上的 update() 方法,先假设一旦数据发生了变化,传入订阅者实例的方法就会被执行,即 node.textContent = text.replace(/\{\{(.*)\}\}/, val) 被执行。但我们会发现,内部参数 val 仍是一个旧值(因为Compile只执行一次,在其内部的变量val肯定是不会动态变更的)。我们在重新渲染文本节点时,需要去将旧文本替换成新文本。

那么问题就是如何获取更新后的新值?

我们需要改动代码,在实例化订阅者对象的时候传入三个值,vm 为Mvvm实例,RegExp.$1 是当前文本节点中匹配的原始待编译字符(也就是 {{}} 包裹的内容),第三个参数时传入的执行函数:

- new Watcher(function () {
-   node.textContent = text.replace(/\{\{(.*)\}\}/, val);
- })

+ new Watcher(vm, RegExp.$1, function (newVal) {
+   node.textContent = text.replace(/\{\{(.*)\}\}/, newVal);
+ })

那么传入的这些参数在构造对象 Watcher 中如何使用?

首先我们要接受传入的参数

  function Watcher(vm, exp, fn) {
+   this.fn = fn;
+   this.vm = vm;
+   this.exp = exp;
  }

这时候就可以考虑如何将订阅者添加到发布者的 subArr 中了。

首先我们要清楚实例化发布者的位置应该是在 Observe 中,因为其负责了构建每一个数据。所以我们可以去尝试通过访问数据对象上的 get() 方法,来将订阅者添加到其数据上的发布者。

  function Watcher(vm, exp, fn) {
    this.fn = fn;
    this.vm = vm;
    this.exp = exp;
+   Dep.target = this;
+   let val = vm;
+   let arr = exp.split('.');
+   arr.forEach(function (k) {
+     val = val[k];
+   })
+   Dep.target = null;
  }

其中 Dep.target 是为了存放当前的订阅者对象,在数据的 get() 方法中将订阅者添加到发布者的 subArr 中。 forEach 是为了深度遍历,因为如果当前的数据值是一个对象,那么需要去深度查找这个值中对象的 get()set() 方法。

同样,当数据被重新赋值时,会调用其 set() 方法,所以最终我们在 Observe 中为数据添加 get()set() 方法的代码中要加上如下额外步骤:

  function Observe(data) {
+   let dep = new Dep();
    for (let key in data) {
      let val = data[key];
      observe(val);
      Object.defineProperty(data, key, {
        enumerable: true,
        get() {
+         Dep.target && dep.addSub(Dep.target);
          return val;
        },
        set(newVal) {
          if (newVal === val) {
            return;
          } else {
            val = newVal;
            observe(newVal);
+           dep.carry();
          }
        }
      })
    }
  }

但是正如最初我们提到的,执行订阅者的 update() 方法去执行传入订阅者内部的函数时,需要获取新值 newVal,那么我们需要去更改一下 update() 方法,由于其执行前已经对数据进行了重新赋值,所以只要查找该订阅者对应的值就可以获取 newVal 了。

Watcher.prototype.update = function () {
  let val = this.vm;
  let arr = this.exp.split('.');
  arr.forEach(function (k) {
    val = val[k];
  })
  this.fn(val);
}

7. 数据的双向绑定

为了实现数据的双向绑定,要点在编译模板时,去审查每个Document节点元素身上有没有挂载 v-model 属性,如果有,就获取其 value,为其添加一个订阅,来当数据更新时连带更新输入框的内容,同时添加一个监听方法,当在其内部输入时,触发绑定数据的 set() 方法来变更数据的值:

function Compile(el, vm) {
  ... ...
  function replace(fragment) {
    Array.from(fragment.childNodes).forEach(function (node) {
      ... ...
      if (node.nodeType === 1) {
        let nodeAttrs = node.attributes;
        Array.from(nodeAttrs).forEach(function (attr) {
          let name = attr.name;
          let exp = attr.value;
          // 默认以 "v-" 开头的为 "v-model"
          if (name.indexOf('v-') === 0) {
            node.value = vm[exp];
          }
          new Watcher(vm, exp, function (newVal) {
            node.value = newVal;
          })
          node.addEventListener('input', function (e) {
            let newVal = e.target.value;
            vm[exp] = newVal;
          })
        })
      }
      ... ...
    })
  }
}

8. 计算属性

在Vue中,计算属性可以被缓存到vm实例上:

function initComputed() { // 具有缓存功能
  let vm = this;
  let computed = this.$options.computed;
  // Object.keys()方法可以将一个对象的key存放在一个数组数组中
  Object.keys(computed).forEach(function (key) {
    Object.defineProperty(vm, key, {
      get: computed[key]
    })
  })
}
发布了48 篇原创文章 · 获赞 28 · 访问量 4万+

猜你喜欢

转载自blog.csdn.net/u012925833/article/details/90705273