[VUE] 模仿vue实现一个mvvm框架

什么是MVVM

MVVM 设计模式,是由 MVC、MVP 等设计模式进化而来其中:

  1. M - 数据模型(Model),简单的JS对象。
  2. VM - 视图模型(ViewModel),连接Model与View。
  3. V - 视图层(View),呈现给用户的DOM渲染界面。

以上是MVVM模式的示例图,核心设计是当view修改时,即用户在页面dom上交互操作时,通过viewModel的机制,可以实时修改model数据。同理,当时js实例(model)修改,时,通过viewModel可以立即,更新渲染在view上。

VUE是MVVM吗?

其实这是业内一个公开的无需讨论的问题,因为VUE官网里就有提到。防止有的朋友还不理解,贴出官网中相关部分:

vue在设计上是遵循MVVM模型的,可是他留了一个口,就是ref。用户可以通过ref直接操作dom,跳过viewModel的环节。所以说VUE没有完全遵循MVVM模型。

手写MVVM

template模板

目前业内大大小小的框架其实不少,但国内使用最广泛是VUE,因此这里我们的模仿对象也选择VUE。在html模板部分用{{}}定义。

...
<body>
    <div id="app">
        <input type="text" v-model='name'>
        <input type="text" v-model='age'>
        <h2>姓名是{{  name   }}</h2>
        <h2>年龄是{{age}}</h2>
    </div>
</body>
...
复制代码

我们在用id定义一个dom节点作为渲染入口,这里简单的用2个输入框作为例子。在2个输入框中输入值,会即时渲染下方的h2标签内容。整理的写法跟vue一致。

渲染入口

定义一个渲染入口,并且把初始数据,渲染dom传进去。

let vm = new MyVUE({
        el:'#app',
        data:{
            name:"张三",
            age:18
        }
});
复制代码

渲染类

可以肯定的是MyVue是一个类,我们先来确认MyVue需要做什么?

  1. 找到传进来的dom选择内容,并能正确找到该dom元素。
  2. 把传进来的data管理起来
  3. 把data与页面模板中匹配的部分关联起来
  4. 把模板内容真正渲染到dom上

根据以上的信息,我们先定以下内容:

  class MyVue {
    constructor(options) {
      this.$el = document.querySelector(options.el);//获取页面上的dom入口
      this.$data = options.data;                    //初始化data
      this.observe(this.$data);                     //观察管理data内容
      this.nodeToFragment(this.$el, this);          //把我们的模板node渲染到dom上
    }
    observe(){
      ...
    }
    nodeToFragment(){
      ...
    }
    ...
  }
复制代码

观察管理

思路是递归观察整个data,并数据劫持每一项。

observe(data) {
      // 数据校验,只处理对象和数组
      if (typeof data !== 'object') return;
      for (const key in data) {
        // 数据劫持对象中的项
        this.defineReactive(data, key, data[key]);
      }
}
复制代码

这里延申出了一个新的方法,专门做数据劫持的工作。这里我们用vue2同款思路defineProperty。(Vue3用的是proxy)。每个value都会有自己的订阅实例和watch实例。

defineReactive(obj, key, value) {
		const ctx = this;
      // 递归处理obj
      ctx.observe(value);
  		//需要一个订阅通知机制
      let dep = new Dep;
      Object.defineProperty(obj, key, {
        get() {
          if (Dep.watchIns) {
            //加入订阅
            dep.addSub(Dep.watchIns)
          }
          return value
        },
        set(newVal) {
          // 当值被修改后,需要做三件事。1.把原来的value换成新的。2.重新观察新的value。3.触发通知。
          if (value !== newVal) {
            value = newVal;
            ctx.observe(value);
            dep.notify();
          }
        }
      })
}
复制代码

订阅通知

我们用一个类专门做订阅通知的工作,内容很简单就是管理一个订阅事件队列。可以在这边插入通知触发后需要做的事件,当通知发生后,遍历事件队列触发事件。在defineReactive中可以看到我们是在每一个value的观察中都会new 一个新的Dep的,让每个value的事件队列独立。

class Dep {
    constructor() {
      this.subs = [];
    }
    addSub(sub) {
      this.subs.push(sub)
    }
    notify() {
      this.subs.forEach(item => {
        // 让对应的事件 做更新操作
        item.update();
      })
    }
}
复制代码

编译

这样我们的第一步,管理数据就算完成了。接下来开始编译页面模板。编译我们做的就是把入口dom下的所有内容转成fragment,进行编译,然后重新渲染dom。

nodeToFragment(node, vm) {
      let child;
      //创建文档碎片
      let fragment = document.createDocumentFragment();
      // while循环 把node中的每一个子节点 都转移到了fragment上
      while (child = node.firstChild) {
        fragment.appendChild(child)
        // 编译内容
        this.compile(child, vm)
      }
      // 转移完成之后 页面中的node节点里边就没有元素了
      // 把fragment上的所有节点还给了node
      node.appendChild(fragment)
}
复制代码

compile方法做的是具体的页面模板内容校验,如果找到需要监听的变量,则建立监听。这里主要分2类,如果node内容是普通文本。则直接建立监听,触发通知时,修改文本内容。如果node内容是input,则建立监听之后,需要给dom加上事件,如果dom事件触发就修改data内容。从而触发通知,修改文本内容。

    compile(node) {
      const vm = this;
      // 判断node的节点类型 看他是不是元素节点
      if (node.nodeType == 1) {
        //证明是元素节点  那么 我们要去处理行内属性
        let attrs = node.attributes;// 所有的行内属性,然后看那个是v-开头的
        [...attrs].forEach(item => {
          //校验这个属性是不是v-开头的
          if (/^v-/.test(item.nodeName)) {
            // 获取变量名
            let valName = item.nodeValue;
            // 如果data中没有该变量则报错
            if (typeof vm.$data[valName] === 'undefined') throw valName + ' is not defined';
            // 监听变量
            new Watcher(node, this, valName)
            // 获取对应的值
            let val = vm.$data[valName];
            //把值这放到input框中;
            node.value = val;
            //建立dom的监听
            node.addEventListener('input', (e) => {
              //要把更改之后的input框的内容 设置给name
              vm.$data[valName] = e.target.value
            })
          }
        });
        [...node.childNodes].forEach(item => {
          //针对有子节点的元素 接着进行编译
          this.compile(item);
        })
      } else {
        // 这是文本节点
        let str = node.textContent;
        // 把原来的模板字样存起来
        node.str = str;
        //校验我们的{{}}语法
        if (/{{(.+?)}}/.test(str)) {
          str = str.replace(/{{(.+?)}}/g, (a, b) => {
            // 获取{{}}中的变量
            b = b.replace(/^ +| +$/g, '');// 去除首尾空格
            // 如果data中没有该变量则报错
            if (typeof vm.$data[b] === 'undefined') throw b + ' is not defined';
            // 监听变量
            new Watcher(node, vm, b)
            return vm.$data[b]
          })
          node.textContent = str
        }
      }
    }
复制代码

观察者

观察者的作用是给dom上每一个绑定了的node建立观察,整体逻辑是用watchIns标识,触发value 的get方法,添加订阅,然后去掉标识。当时通知触发时,会触发update方法。

class Watcher {
    constructor(node, vm, key) {
      // 这里用watchIns标识,然后触发data的get,实现监听。
      Dep.watchIns = this;
      this.node = node;
      this.vm = vm;
      this.key = key;
      this.setInit();
      // 结束后把watchIns标识去掉。
      Dep.watchIns = null;
    }
    update() {
      this.setInit();
      if (this.node.nodeType == 1) {
        // 内容是input
        this.node.value = this.value
      } else {
        let str = this.node.str;
        str = str.replace(/{{(.+?)}}/g, (a, b) => {
          b = b.trim();
          return this.vm.$data[b]
        })
        this.node.textContent = str
      }
    }
    setInit() {
      this.value = this.vm.$data[this.key]
    }
}
复制代码

补充:这里的node.str会保持是一开的{{name}},而node.textContent则可以真正渲染在dom中。

整体代码

  // 订阅器
  class Dep {
    constructor() {
      this.subs = [];
    }
    addSub(sub) {
      this.subs.push(sub)
    }
    notify() {
      this.subs.forEach(item => {
        // 让对应的事件 做更新操作
        item.update();
      })
    }
  }
  // 观察者
  class Watcher {
    constructor(node, vm, key) {
      // 这里用watchIns标识,然后触发data的get,实现监听。
      Dep.watchIns = this;
      this.node = node;
      this.vm = vm;
      this.key = key;
      this.setInit();
      // 结束后把watchIns标识去掉。
      Dep.watchIns = null;
    }
    update() {
      this.setInit();
      if (this.node.nodeType == 1) {
        // 内容是input
        this.node.value = this.value
      } else {
        let str = this.node.str;
        str = str.replace(/{{(.+?)}}/g, (a, b) => {
          b = b.trim();
          return this.vm.$data[b]
        })
        this.node.textContent = str
      }
    }
    setInit() {
      this.value = this.vm.$data[this.key]
    }
  }
  // 我们的框架入口
  class MyVue {
    constructor(options) {
      this.$el = document.querySelector(options.el);
      this.$data = options.data;
      this.observe(this.$data);
      this.nodeToFragment(this.$el, this);
    }
    observe(data) {
      // 数据校验,只处理对象和数组
      if (typeof data !== 'object') return;
      for (const key in data) {
        // 数据劫持对象中的项
        this.defineReactive(data, key, data[key]);
      }
    }
    defineReactive(obj, key, value) {
      const ctx = this;
      // 递归处理obj
      ctx.observe(value);
      let dep = new Dep;
      Object.defineProperty(obj, key, {
        get() {
          if (Dep.watchIns) {
            //Dep.target  就是watcher实例
            dep.addSub(Dep.watchIns)
          }
          return value
        },
        set(newVal) {
          if (value !== newVal) {
            value = newVal;
            ctx.observe(value);
            dep.notify();
          }
        }
      })
    }
    nodeToFragment(node, vm) {
      let child;
      //创建文档碎片
      let fragment = document.createDocumentFragment();
      // while循环 把node中的每一个子节点 都转移到了fragment上
      while (child = node.firstChild) {
        fragment.appendChild(child)
        // 编译内容
        this.compile(child, vm)
      }
      // 转移完成之后 页面中的node节点里边就没有元素了
      // 把fragment上的所有节点还给了node
      node.appendChild(fragment)
    }
    compile(node) {
      const vm = this;
      // 判断node的节点类型 看他是不是元素节点
      if (node.nodeType == 1) {
        //证明是元素节点  那么 我们要去处理行内属性
        let attrs = node.attributes;// 所有的行内属性,然后看那个是v-开头的
        [...attrs].forEach(item => {
          //校验这个属性是不是v-开头的
          if (/^v-/.test(item.nodeName)) {
            // 获取变量名
            let valName = item.nodeValue;
            // 如果data中没有该变量则报错
            if (typeof vm.$data[valName] === 'undefined') throw valName + ' is not defined';
            // 监听变量
            new Watcher(node, this, valName)
            // 获取对应的值
            let val = vm.$data[valName];
            //把值这放到input框中;
            node.value = val;
            //建立dom的监听
            node.addEventListener('input', (e) => {
              //要把更改之后的input框的内容 设置给name
              vm.$data[valName] = e.target.value
            })
          }
        });
        [...node.childNodes].forEach(item => {
          //针对有子节点的元素 接着进行编译
          this.compile(item);
        })
      } else {
        // 这是文本节点
        let str = node.textContent;
        // 把原来的模板字样存起来
        node.str = str;
        //校验我们的{{}}语法
        if (/{{(.+?)}}/.test(str)) {
          str = str.replace(/{{(.+?)}}/g, (a, b) => {
            // 获取{{}}中的变量
            b = b.replace(/^ +| +$/g, '');// 去除首尾空格
            // 如果data中没有该变量则报错
            if (typeof vm.$data[b] === 'undefined') throw b + ' is not defined';
            // 监听变量
            new Watcher(node, vm, b)
            return vm.$data[b]
          })
          node.textContent = str
        }
      }
    }
  }
  // 渲染入口
  let vm = new MyVue({
    el: '#app',
    data: {
      name: "张三",
      age: 18,
    }
  });
复制代码

总结

实践下来,总体的思路其实就是MVVM的模式,我们先是监听model中的data,利用defineProperty为他建立一个订阅列表。再给View中的用到data的内容建立watch,当view中的input等交互发生,会让model修改,从而修改view中的文本内容。

为了实现这套流程,我们用到了一些关键技术:

  1. 把dom转为fragment,为的是减少直接在dom上的修改。
  2. 实现订阅通知机制,实际上利用的是发布订阅者的设计模式。
  3. 实现变量的监听用的是defineProperty方法。
  4. 为了每次渲染dom模板我们需要把原来的模板内容保存在node本身。

猜你喜欢

转载自juejin.im/post/7032307871421300766