MVVM——Model-View-ViewModle的缩写,MVC设计模式的改进版。Model是咱们应用中的数据模型,View是咱们的UI层,经过ViewModle,能够把咱们Modle中的数据映射到View视图上,同时,在View层修改了一些数据,也会反应更新咱们的Modle。javascript
上面的话,未免太官方了。简单理解就是双向数据绑定,即当数据发生变化的时候,视图也就发生变化,当视图发生变化的时候,数据也会跟着同步变化。html
MVVM这种思想的前端框架其实老早就有了,我记得是在13年,本身在公司的主要工做是作后台管理系统的UI设计和开发,当时就思考,如何让那些专一后台的开发,既简单又方便的使用前端开发的一些组件。当时有三种方案:前端
后来的评估是:vue
当时本身仍是比较推崇Angular的,我记得后来还买了一本《基于MVC的Javascript Web富应用开发》专门去了解这种模式在工做中可能用的状况,以及实现它的一些基本思路。java
当时热点比较高的MVVM框架有:node
当年的环境和条件都没有如今好,不管从技术完善的状况,仍是工做的实际状况上面看,都是如此——那时候先后端分离都是理想。nginx
固然如今环境好了,各类框架的出现也极大方便了咱们,提升了咱们开发的工做效率。时代老是在进步,大浪淘沙,MVVM的框架如今比较热门和流行的,我相信你们如今都知道,就是下面三种了:正则表达式
如今Angular除了一些忠实的拥趸,基本上也就没落了。Angular不管从入门仍是实际应用方面,都要比其余两个框架发费的时间成本更大。
Angular如今有种英雄末路的感受,但不能不认可,以前它确实散发了光芒。segmentfault
Angular的1.x版本,是经过脏值检测来实现双向绑定的。后端
而最新的Angular版本和Vue,以及React都是经过数据劫持+发布订阅模式来实现的。
脏值检测
简单理解就是,把老数据和新数据进行比较,脏就表示以前存在过,有过痕迹,经过比较新旧数据,来判断是否要更新。感兴趣的能够看看这篇文章 构建本身的AngularJS,第一部分:做用域和digest。
数据劫持 发布订阅
数据劫持:在访问或者修改对象的某个属性时,经过代码拦截这个行为,进行额外的操做或者修改返回结果。在ES5当中新增了Object.defineProperty()能够帮咱们实现这个功能。
发布订阅:如今每一个人应该都用微信吧,一我的能够关注多个公众号,多我的能够同时关注相同的公众号。关注的动做就至关于订阅。公众号每周都会更新内容,并推送给咱们,把写好的文章在微信管理平台更新就行了,点击推送,就至关于发布。更详细的能够深刻阅读 javascript设计模式——发布订阅模式
咱们静下心好好思考下,若是才能实现双向数据绑定的功能。可能须要:
经过上面这样的思考,咱们能够简单的写一下大概的方法。
class MVVM { constructor(data){ this.$option = option; const data = this._data = this.$option.data; //数据劫持 observe(data) //数据代理 proxyData(data) //编译模板 const dom = this._el = this.$option.el; complie(dom,this); //发布订阅 //链接视图和数据 //实现双向数据绑定 } } // Observe类 function Observe(){} // Observe实例化函数 function observe(data){ return new Observe(data); } // Compile类 function Compile(){} // Compile实例化函数 function compile(el){ return new Compile(el) }
咱们有下面这样一个对象
let obj = { name:"mc", age:"29", friends:{ name:"hanghang", name:"jiejie" } }
咱们要对这个对象执行某些操做(读取,修改),一般像下面就能够
// 取值 const name = obj.name; console.log(obj.age) const friends = obj.friends; // 修改 obj.name = "mmcai"; obj.age = 30;
在VUE中,咱们知道,若是data对象中的某个属性,在template当中绑定的话,当咱们修改了这个属性值,咱们的视图也就更新了。这就是双向数据绑定,数据变化,视图更新,同时反过来也同样。
要实现这个功能,咱们就须要知道data当中的数据是如何变更了,ES5当中提供了Object.defineProperty()函数,咱们能够经过这个函数对咱们data对象当中的数据进行监听。当数据变更,就会触发这个函数里面的set方法,经过判断数据是否变化,就能够执行一些方法,更新咱们的视图了。因此咱们如今须要实现一个数据监听器Observe,来对咱们data中的全部属性进行监听。
// Observe类的实例化函数 function observe(data){ // 判断数据是不是一个对象 if(typeof data !== 'object'){ return; } // 返回一个Observe的实例化对象 return new Observe(data) } // Observer类的实现 class Observe{ constructor(data){ this.data = data; this.init(data) } init(data){ for(let k in data){ let val = data[k]; //若是data是一个对象,咱们递归调用自身 if(typeof val === 'object'){ observe(val); } Object.defineProperty(data,k,{ enumerable:true, get(){ return val; }, set(newVal){ //若是值相同,直接返回 if(newVal === val){ return; }; //赋值 val = newVal; //若是新设置的值是一个对象,递归调用observe方法,给新数据也添加上监听 if(typeof newVal === 'object'){ observe(newVal); } } }) } } }
了解了数据劫持,咱们就能够明白,为何咱们实例化vue的时候,必须事先在data当中定义好咱们的须要的属性了,由于咱们新增的属性,没有通过observe进行监听,没有经过observe监听,后面complie(模板解析)也就不会执行。
因此,虽然你能够在data上面设置新的属性,并读取,但视图却不能更新。
咱们常见的代理有nginx,就是咱们不直接去访问(操做)咱们实际要访问的数据,而是经过访问一个代理,而后代理帮咱们去拿咱们真正须要的数据。
通常的特色是:
下面是VUE简单的一个使用实例:
cosnt vm = new Vue({ el:"#app", data:{ name:"mmcai" } });
咱们的实例化对象vm,想要读取data里面的数据的时候,不作任何处理的正常状况下,使用下面方式读取:
const name = vm.data.name;
这样操做起来,显然麻烦了一些,咱们就能够经过数据代理,直接把data绑定到咱们的实例上,因此在vue当中,咱们通常获取数据像下面同样:
cosnt vm = new Vue({ el:"#app", data:{ name:"mmcai" }, created(){ // 直接经过实例就能够访问到data当中的数据 const name = this.name; // 经过this.data.name 也能够访问,可是显然,麻烦了一些 } });
一样,咱们经过Object.defineProperty函数,把data对象中的数据,绑定到咱们的实例上就能够了,代码以下:
class MVVM { constructor(option){ //此处代码省略 this.$option = option; const data = this._data = this.$option.data; //调用代理 this._proxyData(data); } _proxyData(data){ const that = this; for(let k in data){ let val = data[k]; Object.defineProperty(that,k,{ enumerable:true, get(){ return that._data[k]; }, set(newVal){ that._data[k] = newVal; } }) } } }
利用正则表达式识别模板标识符,并利用数据替换其中的标识符。
VUE里面的标识符是 {{}} 双大括号,数据就是咱们定义在data上面的内容。
实现原理
遍历解析须要替换的根元素el下的HTML标签,必定会使用遍历对DOM节点进行操做,对DOM操做就会引起页面的重排和重绘,为了提升性能和效率,能够把el根节点下的全部节点替换为文档碎片fragment进行解析编译操做,解析完成,再将fragment添加到根节点el中
若是想对文档碎片进行,更多的了解,能够查看文章底部的参考资料
<!--定义模板编译类--> class Complie{ constructor(el,vm){ this.$vm = vm; this.$el = document.querySelector(el); //第一步,把DOM转换成文档碎片 this.$fragment = this.nodeToFragment(this.$el); //第二步,匹配标识符,填充数据 this.compileElement(this.$fragment); //把文档碎片,添加到el根节点上面 this.$el.appendChild(this.$fragment); } // 把DOM节点转换成文档碎片 nodeToFragment(el){ let nodeFragment = document.createDocumentFragment(); // 循环遍历el下面的节点,填充到文档碎片nodeFragment中 while(child = el.firstChild){ nodeFragment.appendChild(child); } // 把文档碎片返回 return nodeFragment; } // 遍历目标,查找标识符,并替换 compileElement(node){ let reg = /\{\{(.*)\}\}/; Array.from(node.childNodes).forEach((node)=>{ let text = node.textContent; if(node.nodeType === 3 && reg.test(text)){ let arr = RegExp.$1.split('.'); // vm 是实例的整个data对象 let val = vm; arr.forEach((k)=>{ val = val[k] }) node.textContent = text.replace(/\{\{(.*)\}\}/,val); } // 若是节点包含字节的,递归调用自身 if(node.childNodes){ this.compileElement(node) } }) } } <!--实例化的方法--> const complie = (el,vm)=>{ return new Compile(el,vm) }
在软件架构中,发布订阅是一种消息范式,消息的发送者(成为发布者)不会将消息直接发送给特定的接收者(成为订阅者)。二十将发布的消息分为不一样的类别,无需了解哪些订阅者是否存在。一样的,订阅者能够表达对一个或多个类别的兴趣,直接受感兴趣的消息,无需了解哪些发布者是否存在——维基。
上述的表达中,既然说发布者不关心订阅者,订阅者也不关心发布者,那么他们是如何通讯呢?
其实就是经过第三方,一般在函数中咱们,称他们为观察者watcher
在VUE的里面,咱们要确认几个概念,谁是发布者,谁是订阅者,为何须要发布订阅?
上面咱们说了数据劫持Observe,也说了Compile,其实,Observe和Compile 他们即便发布者,也是订阅者,帮助他们之间的通信,就是watcher的工做。
经过下面的代码,咱们简单了解下,发布订阅模式的实现状况。
// 建立一个类 // 发布订阅,本质上是维护一个函数的数组列表,订阅就是放入函数,发布就是让函数执行 class Dep{ consturctor(){ this.subs=[]; } // 添加订阅者 addSub(sub){ this.subs.push(sub); } // 通知订阅者 notify(){ // 订阅者,都有 this.subs.forEach((sub=>sub.update()); } } // 监听函数,watcher // 经过Watcher类建立的实例,都有update方法 class Watcher{ // watcher的实例,都须要传入一个函数 constructor(fn){ this.fn = fn; } // watcher的实例,都拥有update方法 update(){ this.fn(); } } // 把函数做为参数传入,实例化一个watcher const watcher = new Watcher(()=>{ consoole.log('1') }); // 实例化Dep 类 const dep = new Dep(); // 将watcher放到dep维护的数组中,watcher实例自己具备update方法 // 能够理解成函数的订阅 dep.addSub(watcher); // 执行,能够理解成,函数的发布, // 不关心,addSub方法订阅了谁,只要订阅了,就经过遍历循环subs数组,执行数组每一项的update dep.notify();
经过以上代码的了解,咱们继续实现咱们MVVM中的代码,实现数据和视图的关联。
这种关联的结果就是,当咱们修改data中的数据的时候,咱们的视图更新。或者咱们视图中修改了相关内容,咱们的data也进行相关的更新,因此这里主要的逻辑代码,就是咱们watcher当中的update方法。
咱们根据上面的内容,对咱们的Observe和Compile以及Watcher进行修改,代码以下:
class MVVM{ constructor(option){ this.$option = option; const data = this._data = this.$option.data; this.$el = this.$option.el; // 数据劫持 this._observe(data); // 数据代理 this._proxyData(data); //模板解析 this._compile(this.$el,this) } // 数据代理 _proxyData(data){ for(let k in data){ let val = data[k]; Object.defineProperty(this,k,{ enumerable:true, get(){ return this._data[k]; }, set(newVal){ this._data[k] = newVal; } }) } } } // 数据劫持 class Observe{ constructor(data){ this.init(data); } init(data){ let dep = new Dep(); for(let k in data){ let val = data[k]; // val 多是一个对象,递归调用 if(typeof val === 'object'){ observe(val); } Object.defineProperty(data,k,{ enumerable:true, get(){ // 订阅, // Dep.target 是Watcher的实例 Dep.target && dep.addSub(Dep.target); return val; }, set(newVal){ if(newVal === val){ return; } val = newVal; observe(newVal); dep.notify(); } }) } } } // 数据劫持实例 function observe(data){ if(typeof data !== 'object'){ return }; return new Observe(data); } // 模板编译 class Compile{ constructor(el,vm){ vm.$el = document.querySelector(el); //1.把DOM节点,转换成文档碎片 const Fragment = this.nodeToFragment(vm.$el) //2.经过正则匹配,填充数据 this.replace(Fragment,vm); //3.把填充过数据的文档碎片,插入模板根节点 vm.$el.appendChild(Fragment); } // DOM节点转换 nodeToFragment(el){ // 建立文档碎片, const fragment = document.createDocumentFragment(); //遍历DOM节点,把DOM节点,添加到文档碎片上 while(child ===el.firstChild){ fragment.appendChild(child); } // 返回文档碎片 return fragment; } //匹配标识,填充数据 replace(fragment,vm){ // 使用Array.from方法,把DOM节点,转化成数据,进行循环遍历 Array.from(fragment.childNodes).forEach((node)=>{ // 遍历节点,拿到每一个内容节点 let text = node.textContent; // 定义标识符的正则 let reg = /\{\{(.*)\}\}/; //若是节点是文本,且节点的内容当中匹配到了模板标识符 // 数据渲染视图 if(node.nodeType===3 && reg.test(text)){ // 用数据替换标识符 let arr = RegExp.$1.split('.'); let val = vm; arr.forEach((item)=>{ val = val[item]; }) // 添加一个watcher,当咱们的数据发生变化的时候,更新咱们的view new Watcher(vm,RegExp.$1,(newVal)=>{ node.textContent = text.replace(reg,newVal); }) //把数据填充到节点上 node.textContent = text.replace(reg,val); } // 视图更新数据 if(node.nodeType === 1){ let nodeAttrs = node.attributes; Array.from(nodeAttrs).forEach((attr)=>{ let name = attr.name; // 获取标识符的内容,也就是v-mode="a"的内容 let exp = attr.value; if(name.indexOf('v-model')===0){ node.value = vm[exp]; }; new Watcher(vm,exp,(newVal)=>{ node.value = newVal; }); node.addEventListener('input',function(e){ let newVal = e.target.value; vm[exp] = newVal; }); }); } // 若是节点包含子节点,递归调用自身 if(node.childNodes){ this.replace(node,vm); } }) } } // 模板编译实例 function compile(el,vm){ return new Compile(el,vm) } // 发布订阅 class Dep{ constructor(){ this.subs = []; } // 订阅函数 addSub(fn){ this.subs.push(fn); } // 发布执行函数 notify(){ this.subs.forEach((fn)=>{ fn(); }) } } // Dep实例 function dep(){ return new Dep(); } // 观察者 class Watcher{ // vm,咱们的实例 // exp,咱们的标识符 // fn,回调 constructor(vm,exp,fn){ this.fn = fn; this.vm = vm; this.exp = exp; Dep.target = this; let val = vm; let arr = exp.split('.'); arr.forEach((k)=>{ val = val[k] }); // 完成以后,咱们把target 删除; Dep.target = null; } update(){ let val = this.vm; let arr = this.exp.split('.'); arr.forEach((k)=>{ val = val[k]; }) this.fn(); } } function watcher(){ return new Watcher() }
Wathcer干了那些好事:
Watcher链接了两个部分,包括Observe和Compile;
在Observe方法执行的时候,咱们给data的每一个属性都添加了一个dep,这个dep被闭包在get/set函数内。
当咱们new Watcher,在以后访问data当中属性的时候,就会触发经过Object.defineProperty()函数当中的get方法。
get方法的调用,就会在属性的订阅器实例dep中,添加当前Watcher的实例。
当咱们尝试修改data属性的时候,就会出发dep.notify()方法,该方法会调用每一个Watcher实例的update方法,从而更新咱们的视图。
回顾下整个MVVM实现的整个过程
我这里有一个简短的视频,是某培训机构讲解MVVM的内容,你们有兴趣,能够自取。
提取码:1i0r
若是失效,能够私聊我。