vue学习—基于vue的Mvvm知识点(从0到1手撸mvvm实现)

一、Vue2知识点

1、MVVM是什么

1.简述

MVVM是Model(模型)-View(视图)-ViewModel(视图模型)的简写。它本质上就是MVC 的改进版。MVVM 就是将其中的View 的状态和行为抽象化,让我们将视图 UI 和业务逻辑分开。

2.MVVM模式的组成部分

模型:代表内容的数据访问层(以数据为中心)。
视图:就像在MVC和MVP模式中一样,视图是用户在屏幕上看到的结构、布局和外观(UI)。
视图模型:是View的抽象,负责View与Model之间信息转换,将View的Command传送到Model。
绑定器:声明性数据和命令绑定隐含在MVVM模式中。在Microsoft解决方案堆中,绑定器是一种名为XAML的标记语言。绑定器使开发人员免于被迫编写样板式逻辑来同步视图模型和视图。在微软的堆之外实现时,声明性数据绑定技术的出现是实现该模式的一个关键因素。

3.View与ViewModule连接可以通过下面的方式

Binding Data:实现数据的传递
Command:实现操作的调用
AttachBehavior:实现控件加载过程中的操作

4.MVVM优点

MVVM模式和MVC模式一样,主要目的是分离视图(View)和模型(Model),有几大优点

  1. 低耦合。视图(View)可以独立于Model变化和修改,一个ViewModel可以绑定到不同的"View"上,当View变化的时候Model可以不变,当Model变化的时候View也可以不变。
  2. 可重用性。你可以把一些视图逻辑放在一个ViewModel里面,让很多view重用这段视图逻辑。
  3. 独立开发。开发人员可以专注于业务逻辑和数据的开发(ViewModel),设计人员可以专注于页面设计,使用Expression Blend可以很容易设计界面并生成xaml代码。
  4. 可测试。界面素来是比较难于测试的,测试可以针对ViewModel来写。

2、手写Mvvm(基于Vue)

在这里插入图片描述
思路: 要实现mvvm的双向绑定,就必须要实现以下几点:
1、实现一个数据监听器Observer,能够对数据对象的所有属性进行监听;
2、实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数;
3、实现一个订阅者Dep,如有监听数据对象属性值有变动可拿到最新值并通知订阅者;
4、实现一个观察者Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图;
5、mvvm入口函数,整合以上三者。

1.创建Mvvm构造函数

用于初始化一个mvvm模块,提供控制的区域和挂载的数据。

function Mvvm(options = {}) {// 设置默认值,防止不传报错
  this.$options = options;// 将所有属性挂载到$options上
  let data = this._data = this.$options.data; // this._data 这里也和Vue一样
  observe(data);// 观察数据的变化
}
// 将控制区域和响应式数据挂载到mvvm实例上
let mvvm = new Mvvm({
  el: '#app',
  data: {
    singer: '张芸京',
    song: '偏爱',
    about: {
      phone: '17773635475',
      address: '地球'
    }
  }
});

2.数据劫持

Obeject.defineProperty()来监听属性变动 那么将需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter和getter 这样的话,给这个对象的某个值赋值,就会触发setter,就能监听到数据变化。

function Observe(data) {
  console.log('数据劫持');
  for (let key in data) {
    let val = data[key];// 当前属性值
    observe(val);// 递归数据,深度数据劫持
    Object.defineProperty(data, key, {
      configurable: true,// 设置该属性可删除
      get() {// 获取属性值
        return val;
      },
      set(newVal) {// 获取属性值
        if (val === newVal) {// 设置的值一样则不管(性能优化)
          return;
        }
        val = newVal;
        observe(newVal);// 设置新值后也需要定义成属性
      }
    })
  }
}
// 外面再写一个函数
// 不用每次调用都写个new
// 也方便递归调用
function observe(data) {
  if (!data || typeof data !== 'object') {// 防止递归栈溢出
    return;
  }
  return new Observe(data);// 每次new都是一个新的方法
}

此时数据已经挂载到mvvm实例上,并且监听数据的获取和更新。
看到这里大家应该知道为什么vue中不能新增不存在的属性了吗?
因为不存在的属性没有get和set

// 重写数组的部分非纯函数方法
const arrayProto = Array.prototype;//拿到数组的原型
const arrayMethods = Object.create(arrayProto);
/**
 * @description:对会改变数组的方法重构,从而弥补defineProperty的缺陷
 */
['push', 'pop', 'shift', 'unshift' ,'sort', 'splice', 'reverse'].forEach(method => {
  arrayMethods[method] = function () {
    arrayProto[method].call(this, ...arguments);
    render();
  }
})
/**
 * @description:对象的添加,弥补defineProperty的缺陷
 */
function $set (data, key, value) {
  if(Array.isArray(data)) {//如果是数组,调用重构的splice方法
    data.splice(key, 1, value);
    return value;
  }
  defineReactive(data, key, value);//同理,给新增的对象属性增加get,set描述符
  render();
  return value;
}
/**
 * @description:对象的删除,弥补defineProperty的缺陷
 */
function $delete(data, key) {
  if(Array.isArray(data)) {//如果是数组,调用重构的splice方法
    data.splice(key, 1);
    return;
  }
  delete data[key];
  render();
}

如图被标记的地方就是通过递归observe(val)进行数据劫持添加上了get和set,递归继续向about里面的对象去定义属性,所以输出了 “数据劫持” 两次。
在这里插入图片描述

可能有读者不明白的为什么设置新值也需要observe(newVal) ,如果我不添加该代码,然后对数据进行操作呢?

对比前面的图,发现新设置的about对象并没有被数据劫持到。
在这里插入图片描述

3.数据代理(性能优化)

我们在使用vue时,明显不是每次通过mvvm._data.about这样去设置我们的挂载的数据的。(新增的代码使用+标记)
数据代理就是让我们每次拿data里的数据时,不用每次都写一长串,直接在当前this对象下拿取即可。

function Mvvm(options = {}) {// 设置默认值,防止不传报错
  this.$options = options;// 将所有属性挂载到$options上
  let data = this._data = this.$options.data; // this._data 这里也和Vue一样
  // this代理this._data
+ for(let key in data){// data和this._data是一样的
    Object.defineProperty(this, key, {// 用this代理
      configurable: true,
      get() {
        return this._data[key];// this即this._data
      },
      set(newVal) {
        this._data[key] = newVal;
      }
    });
+ }
  observe(data);// 观察数据的变化
}

在这里插入图片描述

4.数据编译

在这里插入图片描述
compile主要做的事情是解析模板指令,将模板中的变量( {{}}插值表达式 )替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数。

// MVVM
function Mvvm(options = {}) {// 设置默认值,防止不传报错
  this.$options = options;// 将所有属性挂载到$options上
  let data = this._data = this.$options.data; // this._data 这里也和Vue一样
  // this代理this._data
  for (let key in data) {// data和this._data是一样的
    Object.defineProperty(this, key, {
      configurable: true,
      get() {
        return this._data[key];
      },
      set(newVal) {
        this._data[key] = newVal;
      }
    });
  }
  observe(data);// 观察数据的变化
+ new Compile(options.el, this);// 此时已经this代理了this._data,接着就是数据替换更新{{}}
}
// 数据编译渲染
function Compile(el, vm) {
  vm.$el = document.querySelector(el);// 将dom元素挂载到实例上(注意querySelector非实时性)
  let fragment = document.createDocumentFragment();// 创建了一虚拟的节点对象,节点对象包含所有属性和方法
  while (child = vm.$el.firstChild) {
    fragment.appendChild(child);// 将el中的元素依次放入虚拟节点中
  }
  function replace(frag) {
    Array.from(frag.childNodes).forEach(node => {// 遍历挂载区域的子节点
      let text = node.textContent;// 节点的文本内容
      let reg = /\{\{(.*?)\}\}/g;// 匹配vue{{}}的插值表达式
      if ((node.nodeType === 3) && reg.test(text)) {// 节点为文本节点且有插值表达式
        let arr = RegExp.$1.split('.');// RegExp.$1指与正则表达式匹配的第一个,即{{ ** }}中的值,注意拆分的字符可能有空格
        let val = vm;// 将实例赋值,前面已经用this代理了this._data
        arr.forEach(key => {
          val = val[key.trim()];// 获取this代理的_data,注意去除空格
          // 如果是{{}}中about.phone,会先拿到vm[about],再about[phone]
        });
        // 用trim方法去除一下首尾空格
        node.textContent = text.replace(reg, val).trim();// 替换文本
      }
      // 如元素节点还有子节点,继续递归replace,直到拿到文本节点的{{}}插值表达式
      if (node.childNodes && node.childNodes.length) {
        replace(node);
      }
    });
  }
  replace(fragment);  // 寻找{{}}插值表达式并替换内容
  vm.$el.appendChild(fragment);   // 再将文档碎片放入el中
}

在这里插入图片描述
此时我们发现,我们改变数据时,页面并没有响应式渲染呐?因为你的Compile数据编译渲染只是在new Mvvm时执行了一次,后面改变数据当然不会从新渲染呐。( 下面我们就来看看怎么处理,其实这里就用到了特别常见的设计模式,发布订阅模式
在这里插入图片描述

5.发布订阅

发布订阅主要靠的就是数组关系,订阅就是放入函数,发布就是让数组里的函数执行。

消息订阅器
维护一个数组,用来收集订阅者,数据变动触发notify,从而调用订阅者的update方法

// 消息订阅器,订阅和发布
function Dep() {
  this.subs = [];// 维护一个数组(存放订阅者函数的事件池)
}
Dep.prototype = {
  adddSub() {// 收集订阅者
    this.subs.push(...arguments);// 不设置参数,想传多少个都可以添加进函数事件池
  },
  notify() {// 数据变动触发notify
    this.subs.forEach(sub => sub.update());// 给每个订阅者绑定update
  }
};

订阅者

Watcher订阅者作为Observer和Compile之间通信的桥梁,主要做的事情是:
1、在自身实例化时往属性订阅器(dep)里面添加自己
2、自身必须有一个update()方法
3、待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调,则功成身退。

// 监听函数
function Watcher(fn) {
  this.fn = fn;// 将fn放到实例上
}
Watcher.prototype.update = function() {// 通过Watcher这个类创建的实例,都拥有update方法
  this.fn();// 调用update就会执行监听的函数
};
// 示例
let watcher1 = new Watcher(()=>{console.log(1)});
let watcher2 = new Watcher(()=>{console.log(2)});
let dep = new Dep();
dep.adddSub(watcher1,watcher2);// 订阅者 订阅消息
dep.notify();// 数据变动订阅器 通知订阅者(发布)

在这里插入图片描述
接下来我们要使用发布订阅,当数据改变需要重新刷新视图,这就需要在replace替换的逻辑里来处理。

6.数据更新视图

1.前面的发布订阅只是模型,这里根据实际情况对监听函数进行完善

// 监听函数
function Watcher(vm, exp, fn) {
  this.fn = fn;// 订阅者需要执行的函数
  this.vm = vm;// 挂载的数据
  this.exp = exp;// 匹配到的{{}}内容
  // 添加一个事件
  // 这里我们先定义一个属性
  Dep.target = this;// 通过Dep定义一个全局target属性,暂存watcher
  let arr = exp.split('.');
  let val = vm;
  arr.forEach(key => {
    val = val[key.trim()];// 触发了get方法,从而添加订阅者
  });
  Dep.target = null;// 添加完订阅者移除watcher
}

2.在数据编译渲染函数中调用监听函数(增加的代码用+标记)

// 数据编译渲染
function Compile(el, vm) {
  vm.$el = document.querySelector(el);// 将dom元素挂载到实例上(注意querySelector非实时性)
  let fragment = document.createDocumentFragment();// 创建了一虚拟的节点对象,节点对象包含所有属性和方法
  while (child = vm.$el.firstChild) {
    fragment.appendChild(child);// 将el中的元素依次放入虚拟节点中
  }
  function replace(frag) {
    Array.from(frag.childNodes).forEach(node => {// 遍历挂载区域的子节点
      let text = node.textContent;// 节点的文本内容
      let reg = /\{\{(.*?)\}\}/g;// 匹配vue{{}}的插值表达式
      if ((node.nodeType === 3) && reg.test(text)) {// 节点为文本节点且有插值表达式
        let arr = RegExp.$1.split('.');// RegExp.$1指与正则表达式匹配的第一个,即{{ ** }}中的值,注意拆分的字符可能有空格
        let val = vm;// 将实例赋值,前面已经用this代理了this._data
        arr.forEach(key => {
          val = val[key.trim()];// 获取this代理的_data,注意去除空格
          // 如果是{{}}中about.phone,会先拿到vm[about],再about[phone]
        });
        // 用trim方法去除一下首尾空格
        node.textContent = text.replace(reg, val).trim();// 替换文本
        // 实例化一个订阅者,等待数据变化传入一个更新页面的函数
+       new Watcher(vm, RegExp.$1, newVal => {
+         node.textContent = text.replace(reg, newVal).trim();
+       });
      }
      // 如元素节点还有子节点,继续递归replace,直到拿到文本节点的{{}}插值表达式
      if (node.childNodes && node.childNodes.length) {
        replace(node);
      }
    });
  }
  replace(fragment);  // 寻找{{}}插值表达式并替换内容
  vm.$el.appendChild(fragment);   // 再将文档碎片放入el中
}

3.当获取值的时候就会自动调用get方法,设置值的时候就会自动调用set方法,于是我们去找一下数据劫持。

// 数据劫持
function Observe(data) {
  let dep = new Dep();
  for (let key in data) {
    let val = data[key];// 当前属性值
    observe(val);// 递归数据,深度数据劫持
    Object.defineProperty(data, key, {
      configurable: true,// 设置该属性可删除
      get() {// 获取属性值
        Dep.target && dep.addSub(Dep.target);// target存在,将watcher添加到订阅事件中
        return val;
      },
      set(newVal) {// 获取属性值
        if (val === newVal) {// 设置的值一样则不管
          return;
        }
        val = newVal;
        observe(newVal);// 设置新值后也需要定义成属性
        dep.notify();// 更新值后让所有watcher的update方法执行即可
      }
    });
  }
}

当set修改值的时候执行了dep.notify方法,这个方法里是执行watcher的update方法。

// 数据更新,更新视图
Watcher.prototype.update = function () {// 通过Watcher这个类创建的实例,都拥有update方法
  // notify的时候值已经更改了
  // 再通过vm, exp来获取新的值
  let arr = this.exp.split('.');
  let val = this.vm;
  arr.forEach(key => {
    val = val[key.trim()];   // 通过get获取到新的值
  });
  this.fn(val);// 调用update就会执行传入的函数更新页面
};

7.优化

到这里其实我们我们已经完整写了一个简单地mvvm实现,但是细心的你会发现,在数据编译渲染函数Compile中其实我们只能匹配到文本节点的第一个{{}}插值表达式(没发现的再回去看看哦)。

// 数据编译渲染
function Compile(el, vm) {
  vm.$el = document.querySelector(el);// 将dom元素挂载到实例上(注意querySelector非实时性)
  let fragment = document.createDocumentFragment();// 创建了一虚拟的节点对象,节点对象包含所有属性和方法
  while (child = vm.$el.firstChild) {
    fragment.appendChild(child);// 将el中的元素依次放入虚拟节点中
  }
  function replace(frag) {
    Array.from(frag.childNodes).forEach(node => {// 遍历挂载区域的子节点
      let text = node.textContent;// 节点的文本内容
      let reg = /\{\{(.*?)\}\}/g;// 匹配vue{{}}的插值表达式
      if ((node.nodeType === 3) && reg.test(text)) {// 节点为文本节点且有插值表达式
        function replaceTxt() {
          node.textContent = text.replace(reg, (matched, placeholder) => {
            // placeholder是匹配到的分组 如:song, about.phone
            new Watcher(vm, placeholder, replaceTxt);   // 监听变化,进行匹配替换内容
            return placeholder.split('.').reduce((val, key) => {
              return val[key.trim()];
            }, vm);// vm是reduce的初始值
          });
        };
        // 替换
        replaceTxt();
      }
      // 如元素节点还有子节点,继续递归replace,直到拿到文本节点的{{}}插值表达式
      if (node.childNodes && node.childNodes.length) {
        replace(node);
      }
    });
  }
  replace(fragment);  // 寻找{{}}插值表达式并替换内容
  vm.$el.appendChild(fragment);   // 再将文档碎片放入el中
}

8.完整代码

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>

<body>
  <div id='app'>
    <h3>姓名:{{ singer }}</h3>
    <h3>歌名:{{ song }}</h3>
    联系方式:{{ about.phone }},联系地址:{{ about.address }}
  </div>
</body>

</html>
<script>
  // MVVM
  function Mvvm(options = {}) {// 设置默认值,防止不传报错
    this.$options = options;// 将所有属性挂载到$options上
    let data = this._data = this.$options.data; // this._data 这里也和Vue一样
    // this代理this._data
    for (let key in data) {// data和this._data是一样的
      Object.defineProperty(this, key, {
        configurable: true,
        get() {
          return this._data[key];
        },
        set(newVal) {
          this._data[key] = newVal;
        }
      });
    }
    observe(data);// 观察数据的变化
    new Compile(options.el, this);// 别忘了此时已经this代理了this._data
  }

  // 数据劫持
  function Observe(data) {
    let dep = new Dep();
    for (let key in data) {
      let val = data[key];// 当前属性值
      observe(val);// 递归数据,深度数据劫持
      Object.defineProperty(data, key, {
        configurable: true,// 设置该属性可删除
        get() {// 获取属性值
          Dep.target && dep.addSub(Dep.target);// 将watcher添加到订阅事件中 [watcher]
          return val;
        },
        set(newVal) {// 获取属性值
          if (val === newVal) {// 设置的值一样则不管
            return;
          }
          val = newVal;
          observe(newVal);// 设置新值后也需要定义成属性
          dep.notify();// 让所有watcher的update方法执行即可
        }
      });
    }
  }
  function observe(data) {
    if (!data || typeof data !== 'object') {// 防止递归栈溢出
      return;
    }
    return new Observe(data);// 每次new都是一个新的方法
  }

  // 数据编译渲染
  function Compile(el, vm) {
    vm.$el = document.querySelector(el);// 将dom元素挂载到实例上(注意querySelector非实时性)
    let fragment = document.createDocumentFragment();// 创建了一虚拟的节点对象,节点对象包含所有属性和方法
    while (child = vm.$el.firstChild) {
      fragment.appendChild(child);// 将el中的元素依次放入虚拟节点中
    }
    function replace(frag) {
      Array.from(frag.childNodes).forEach(node => {// 遍历挂载区域的子节点
        let text = node.textContent;// 节点的文本内容
        let reg = /\{\{(.*?)\}\}/g;// 匹配vue{{}}的插值表达式
        if ((node.nodeType === 3) && reg.test(text)) {// 节点为文本节点且有插值表达式
          function replaceTxt() {
            node.textContent = text.replace(reg, (matched, placeholder) => {
              // placeholder是匹配到的分组 如:song, about.phone
              new Watcher(vm, placeholder, replaceTxt);   // 监听变化,进行匹配替换内容
              return placeholder.split('.').reduce((val, key) => {
                return val[key.trim()];
              }, vm);// vm是reduce的初始值
            });
          };
          // 替换{{}}表达式
          replaceTxt();
        }
        // 如元素节点还有子节点,继续递归replace,直到拿到文本节点的{{}}插值表达式
        if (node.childNodes && node.childNodes.length) {
          replace(node);
        }
      });
    }
    replace(fragment);  // 寻找{{}}插值表达式并替换内容
    vm.$el.appendChild(fragment);   // 再将文档碎片放入el中
  }

  // 发布订阅模式,订阅和发布,如[fn1, fn2, fn3]
  function Dep() {
    this.subs = [];// 一个数组(存放函数的事件池)
  }
  Dep.prototype = {
    addSub() {// 用于添加函数
      this.subs.push(...arguments);// 不设置参数,想传多少个都可以
    },
    notify() {
      this.subs.forEach(sub => sub.update());// 绑定的方法,都有一个update方法
    }
  };
  // 监听函数
  function Watcher(vm, exp, fn) {
    this.fn = fn;// 需要执行的函数
    this.vm = vm;// 挂载的数据
    this.exp = exp;// 匹配到的{{}}内容
    // 添加一个事件
    // 这里我们先定义一个属性
    Dep.target = this;
    let arr = exp.split('.');
    let val = vm;
    arr.forEach(key => {
      val = val[key.trim()];// 匹配的key获取对应的挂载的数据,默认就触发get方法
    });
    Dep.target = null;
  }
  Watcher.prototype.update = function () {// 通过Watcher这个类创建的实例,都拥有update方法
    // notify的时候值已经更改了
    // 再通过vm, exp来获取新的值
    let arr = this.exp.split('.');
    let val = this.vm;
    arr.forEach(key => {
      val = val[key.trim()];   // 通过get获取到新的值
    });
    this.fn(val);// 调用update就会执行监听的函数
  };

  let mvvm = new Mvvm({
    el: '#app',
    data: {
      singer: '张芸京',
      song: '偏爱',
      about: {
        phone: '17773635475',
        address: '地球'
      }
    }
  });
</script>

本文中本人已经做了极为详细的注释,一步一步讲解mvvm的实现过程,如果这样你还不是很能理解的话,可要多读几次哦。例外,如发现代码有问题等欢迎反馈哦,感谢。

最后,mvvm实现这里是阅读了掘金一位大佬的博客(优化了少数代码),感兴趣的可以去看看大佬怎么实现的哦。点此跳转呢.

博主开始运营自己的公众号啦,感兴趣的可以关注“飞羽逐星”微信公众号哦,拿起手机就能阅读感兴趣的博客啦!

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

飞羽逐星

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值