如何理解MVVM原理?
提到MVVM,很多前端开发者都会想到Vue的双向绑定,然而它们并不能划等号,MVVM是一种软件架构模式,而Vue只是一种在前端层面上的实现,其实不单在Vue里,在很多Web 框架应用里都有相关的实现。MVVM模式到底是什么呢?要说到MVVM这种模式,则必须要提及另一种大多数开发者都能耳熟能详的模式,就是MVC模式。
什么是MVC?
在前几年,前后端完全分离开之前,很多很火的后端框架都会说自己是支持MVC模式,像JAVA的SpringMVC、PHP的smarty、Nodejs的express和Koa,那么MVC的模式到底是什么样的?先看看下面这张经典的MVC模型图,Model(模型)、View(视图)、 Controller(控制器)相互依赖关系的三部分组成模型。
认识一下这三部分具体是指什么。
Model
这里的Model在MVC中实际是数据模型的概念,可以把它当成从数据库里查出来后的一条数据,或者是将查询出来的元数据经过裁剪或者处理后的一个特定数据模型结构。
View
View是视图,是将数据内容呈现给用户肉眼的界面层,View层最终会将数据模型下的信息,渲染显示成人类能易于识别感知的部分。
Controller
Controller是数据模型与View之间的桥梁层,实际界面层的各种变化都要经过它来控制,而且像用户从界面提交的数据也会经过Controller的组装检查生成数据模型,然后改变数据库里的数据内容。
MVC的使用
像接触过MVC框架的同学就知道,如果想把数据从数据库里渲染到页面上,先要查询完数据库后,将拿到的元数据进行一些处理,一般会删掉无用的字段,或者进行多个数据模型间的数据聚合,然后再给到页面模板引擎(ejs,Thymeleaf等)进行数据组装,最后组合完成进行渲染后生成HTML格式文件供浏览器展示使用。
像前面提到的各大支持MVC模式的Web开发框架,在前后端彻底分离之后就很少再提了。因为前端独立开发发布,实际相对原来的MVC模式是少了View这一层,这也让新的概念Restful出现在我们的视野里,很多新的框架又开始支持提供这种前端控制轻量级模式下的适配方案。
但是前后端分离的出现后,MVC就此没有了吗?当然不是。实际对于MVC模式没有特别明确的概念,在前后端分离之后可以尝试从不同的角度去看。可以理解整个系统在原先的MVC基础上View层进行细化,把整个前端项目当成一个View层,也可以从前端视角去看,Restful接口返回的Json数据当成一个数据模型,作为MVC的Model层,而前端Javascript自身对数据的处理是Contrller层,真正的页面渲染结果是View层。
下面以前端视角下的MVC模式中举个例子,接口返回的数据Model模型与View页面之间由Controller连接,来完成系统中的数据展示。
<!--view-->...
...// 生成model数据模型functiongetDataApi(){// 模拟接口返回return{name:'mvc',data:'mvc 数据信息'} }// controller控制逻辑functionpageController(){constresult = getDataApi();document.getElementById('name').innerText =`姓名:${result.name}`;document.getElementById('data').innerText = result.data; }什么是MVVM?
随着前端对于控制逻辑的越来越轻量,MVVM模式作为MVC模式的一种补充出现了,万变不离其宗,最终的目的都是将Model里的数据展示在View视图上,而MVVM相比于MVC则将前端开发者所要控制的逻辑做到更加符合轻量级的要求。
ViewModel
在Model和View之间多了叫做View-Model的一层,将模型与视图做了一层绑定关系,在理想情况下,数据模型返回什么试图就应该展示什么,看看下面这个例子。
<!--view页面-->...
...// 生成model数据模型functiongetDataApi(){// 模拟接口返回return{ name:'mvc', data:'mvc 数据信息'} }// ViewModel控制逻辑functionpageViewModel(){constresult = getDataApi();returnresult; }上面作为理想情况下例子,在ViewModel引入之后,视图完全由接口返回数据驱动,由开发者所控制的逻辑非常轻量。不过这里要说明的是,在MVVM模式下,Controller控制逻辑并非就没了,像操作页面DOM响应的逻辑被SDK(如Vue的内部封装实现)统一实现了,像不操作接口返回的数据是因为服务端在数据返回给前端前已经操作好了。
例子里pageViewModel函数的实现是非常关键的一步,如何将数据模型与页面视图绑定起来呢?在目前的前端领域里有三类实现,Angularjs的主动轮询检查新旧值变化更新视图、Vue利用ES5的Object.defineProperty的getter/setter方法绑定、backbone的发布订阅模式,从主动和被动的方式去实现了ViewModel的关系绑定,接下来主要看看Vue中的MVVM的实现。
Vue2.0中的MVVM实现
Vue2.0的MVVM实现中,对View-Model的实现本质利用的ES5的Object.defineProperty方法,当Object.defineProperty方法在给数据Model对象定义属性的时候先挂载一些方法,在这些方法里实现与界面的值绑定响应关系,当应用的属性被读取或者写入的时候便会触发这些方法,从而达到数据模型里的值发生变化时同步响应到页面上。
Vue的响应式原理
// html
当new Vue在实例化的时候,首先将data方法里返回的对象属性都挂载上setter方法,而setter方法里将页面上的属性进行绑定,当页面加载时,浏览器提供的DOMContentloaded事件触发后,调用mounted挂载函数,开始获取接口数据,获取完成后给data里属性赋值,赋值的时候触发前面挂载好的setter方法,从而引起页面的联动,达到响应式效果。
简易实现Object.defineProperty下的绑定原理
// htmlvardata = {name:''};// Data BindingsObject.defineProperty(data,'name', {get:function(){},set:function(newValue){// 页面响应处理document.getElementById('name').innerText = newValue data.name = value },enumerable:true,configurable:true});// 页面DOM listenerdocument.getElementById('name').onchange =function(e){ data.name = e.target.value; }
实现Vue3.0版本的MVVM
这里采用Vue3.0最新的实现方式,用Proxy和Reflect来替代Object.definePropertypry的方式。至于Vue3.0为何不再采用2.0中Object.defineProperty的原因,我会在后续详写,先来介绍一下ES6里的Proxy与Reflect。
Proxy
Proxy是ES6里的新构造函数,它的作用就是代理,简单理解为有一个对象,不想完全对外暴露出去,想做一层在原对象操作前的拦截、检查、代理,这时候你就要考虑Proxy了。
constmyObj = {_id:'我是myObj的ID',name:'mvvm',age:25}constmyProxy =newProxy(myObj, { get(target, propKey) {if(propKey ==='age') {console.log('年龄很私密,禁止访问');return'*'; }returntarget[propKey]; }, set(target, propKey, value, receiver) {if(propKey ==='_id') {console.log('id无权修改');return; } target[propKey] = value + (receiver.time ||''); },// setPrototypeOf(target, proto) {},// apply(target, object, args) {},// construct(target, args) {},// defineProperty(target, propKey, propDesc) {},// deleteProperty(target, propKey) {},// has(target, propKey) {},// ownKeys(target) {},// isExtensible(target) {},// preventExtensions(target) {},// getOwnPropertyDescriptor(target, propKey) {},// getPrototypeOf(target) {},});myProxy._id =34;console.log(`age is:${myProxy.age}`);myProxy.name ='my name is Proxy';console.log(myProxy);constnewObj = {time:` [${newDate()}]`,};// 原对象原型链赋值Object.setPrototypeOf(myProxy, newObj);myProxy.name ='my name is newObj';console.log(myProxy.name);/**
* id无权修改
* 年龄很私密,禁止访问
* age is: *
* { _id: '我是myObj的ID', name: 'my name is Proxy', age: 25 }
* my name is newObj [Thu Mar 19 2020 18:33:22 GMT+0800 (GMT+08:00)]
*/
Reflect
Reflect是ES6里的新的对象,非构造函数,不能用new操作符。可以把它跟Math类比,Math是处理JS中数学问题的方法函数集合,Reflect是JS中对象操作方法函数集合,它暴露出来的方法与Object构造函数所带的静态方法大部分重合,实际功能也类似,Reflect的出现一部分原因是想让开发者不直接使用Object这一类语言层面上的方法,还有一部分原因也是为了完善一些功能。Reflect提供的方法还有一个特点,完全与Proxy构造函数里Hander参数对象中的钩子属性一一对应。
看下面一个改变对象原型的例子。
constmyObj = {_id:'我是myObj的ID',name:'mvvm',age:25}constmyProxy =newProxy(myObj, { get(target, propKey) {if(propKey ==='age') {console.log('年龄很私密,禁止访问');return'*'; }returntarget[propKey]; }, set(target, propKey, value, receiver) {if(propKey ==='_id') {console.log('id无权修改');return; } target[propKey] = value + (receiver.time ||''); }, setPrototypeOf(target, proto) {if(proto.status ==='enable') {Reflect.setPrototypeOf(target, proto);returntrue; }returnfalse; },});constnewObj = {time:` [${newDate()}]`,status:'sable'};// 原对象原型链赋值constresult1 =Reflect.setPrototypeOf(myProxy, {time:` [${newDate()}]`,status:'disable'});myProxy.name ='first set name'console.log(result1)//falseconsole.log(myProxy.name);//first set name// 原对象原型链赋值constresult2 =Reflect.setPrototypeOf(myProxy, {time:` [${newDate()}]`,status:'enable'});myProxy.name ='second set name'console.log(result1)//trueconsole.log(myProxy.name);//second set name [Thu Mar 19 2020 19:43:59 GMT+0800 (GMT+08:00)]/*当执行到这里时直接报错了*/// 原对象原型链赋值Object.setPrototypeOf(myProxy, {time:` [${newDate()}]`,status:'disable'});myProxy.name ='third set name'console.log(myProxy.name);/**
* 报错
*/
解释一下上面的这段代码,通过Reflec.setPrototypeOf方法修改原对象原型时,必须经过Proxy里hander的挂载的setPrototypeOf挂载函数,在挂载函数里进行条件proto.status是否是enable筛选后,再决定是否真正修改原对象myObj的原型,最后返回true或者false来告知外部原型是否修改成功。
这里还有一个关键点,就是在代码执行到原有的Object.setPrototypeOf方法时,程序则直接抛错,这其实也是Reflect出现的一个原因,即使现在ES5里的Object有同样的功能,但是Reflect实现的更友好,更适合开发者开发应用程序。
实现MVVM
接下来使用上面的Proxy和Reflect来实现MVVM,这里将data和Proxy输出到全局Window下,方便我们模拟数据双向联动的效果。
<!DOCTYPE html>
先打印了data,然后模拟有异步数据过来,手动修改data里的数据window.myProxy.age=25,这时候页面上的age联动变化为25,再次打印了查看data。接下来在页面上手动输入name,输入完成后触发输入框的onchange事件后,再次查看data,此时model里的数据已经变化为最新的与页面保持一致的值。
什么是异步渲染?
这个问题应该先要做一个前提补充,当数据在同步变化的时候,页面订阅的响应操作为什么不会与数据变化完全对应,而是在所有的数据变化操作做完之后,页面才会得到响应,完成页面渲染。
从一个例子体验一下异步渲染机制。
importVuefrom'Vue'newVue({el:'#app',template:'<div>{{val}}</div>', data () {return{val:'init'} }, mounted () {this.val ='我是第一次页面渲染'// debugger this.val ='我是第二次页面渲染'constst =Date.now()while(Date.now() - st <3000) {} }})
上面这一段代码中,在mounted里给val属性进行了两次赋值,如果页面渲染与数据的变化完全同步的话,页面应该是在mounted里有两次渲染。
而由于Vue内部的渲染机制,实际上页面只会渲染一次,把第一次的赋值所带来的的响应与第二次的赋值所带来的的响应进行一次合并,将最终的val只做一次页面渲染。
而且页面是在执行所有的同步代码执行完后才能得到渲染,在上述例子里的while阻塞代码之后,页面才会得到渲染,就像在熟悉的setTimeout里的回调函数的执行一样,这就是的异步渲染。
熟悉React的同学,应该很快能想到多次执行setState函数时,页面render的渲染触发,实际上与上面所说的Vue的异步渲染有异曲同工之妙。
Vue为什么要异步渲染?
我们可以从用户和性能两个角度来探讨这个问题。
从用户体验角度,从上面例子里便也可以看出,实际上我们的页面只需要展示第二次的值变化,第一次只是一个中间值,如果渲染后给用户展示,页面会有闪烁效果,反而会造成不好的用户体验。
从性能角度,例子里最终的需要展示的数据其实就是第二次给val赋的值,如果第一次赋值也需要页面渲染则意味着在第二次最终的结果渲染之前页面还需要渲染一次无用的渲染,无疑增加了性能的消耗。
对于浏览器来说,在数据变化下,无论是引起的重绘渲染还是重排渲染,都有可能会在性能消耗之下造成低效的页面性能,甚至造成加载卡顿问题。
异步渲染和熟悉的节流函数最终目的是一致的,将多次数据变化所引起的响应变化收集后合并成一次页面渲染,从而更合理的利用机器资源,提升性能与用户体验。
Vue中如何实现异步渲染?
先总结一下原理,在Vue中异步渲染实际在数据每次变化时,将其所要引起页面变化的部分都放到一个异步API的回调函数里,直到同步代码执行完之后,异步回调开始执行,最终将同步代码里所有的需要渲染变化的部分合并起来,最终执行一次渲染操作。
拿上面例子来说,当val第一次赋值时,页面会渲染出对应的文字,但是实际这个渲染变化会暂存,val第二次赋值时,再次暂存将要引起的变化,这些变化操作会被丢到异步API,Promise.then的回调函数中,等到所有同步代码执行完后,then函数的回调函数得到执行,然后将遍历存储着数据变化的全局数组,将所有数组里数据确定先后优先级,最终合并成一套需要展示到页面上的数据,执行页面渲染操作操作。
异步队列执行后,存储页面变化的全局数组得到遍历执行,执行的时候会进行一些筛查操作,将重复操作过的数据进行处理,实际就是先赋值的丢弃不渲染,最终按照优先级最终组合成一套数据渲染。
这里触发渲染的异步API优先考虑Promise,其次MutationObserver,如果没有MutationObserver的话,会考虑setImmediate,没有setImmediate的话最后考虑是setTimeout。
接下来在源码层面梳理一下的Vue的异步渲染过程。
接下来从源码角度一步一分析一下。
1、当我们使用this.val='343'赋值的时候,val属性所绑定的Object.defineProperty的setter函数触发,setter函数将所订阅的notify函数触发执行。
defineReactive() { ... set:functionreactiveSetter(newVal){ ... dep.notify(); ... } ...}
2、notify函数中,将所有的订阅组件watcher中的update方法执行一遍。
Dep.prototype.notify =functionnotify(){// 拷贝所有组件的watchervarsubs =this.subs.slice(); ... for (vari =0, l = subs.length; i < l; i++) { subs[i].update(); }};
3、update函数得到执行后,默认情况下lazy是false,sync也是false,直接进入把所有响应变化存储进全局数组queueWatcher函数下。
Watcher.prototype.update =functionupdate(){if(this.lazy) {this.dirty =true; }elseif(this.sync) {this.run(); }else{ queueWatcher(this); }};
4、queueWatcher函数里,会先将组件的watcher存进全局数组变量queue里。默认情况下config.async是true,直接进入nextTick的函数执行,nextTick是一个浏览器异步API实现的方法,它的回调函数是flushSchedulerQueue函数。
functionqueueWatcher(watcher){ ...// 在全局队列里存储将要响应的变化update函数queue.push(watcher); ...// 当async配置是false的时候,页面更新是同步的if(!config.async) { flushSchedulerQueue();return}// 将页面更新函数放进异步API里执行,同步代码执行完开始执行更新页面函数nextTick(flushSchedulerQueue);}
5、nextTick函数的执行后,传入的flushSchedulerQueue函数又一次push进callbacks全局数组里,pending在初始情况下是false,这时候将触发timerFunc。
functionnextTick(cb, ctx){var_resolve; callbacks.push(function(){if(cb) {try{ cb.call(ctx); }catch(e) { handleError(e, ctx,'nextTick'); } }elseif(_resolve) { _resolve(ctx); } });if(!pending) { pending =true; timerFunc(); }// $flow-disable-lineif(!cb &&typeofPromise!=='undefined') {returnnewPromise(function(resolve){ _resolve = resolve; }) }}
6、timerFunc函数是由浏览器的Promise、MutationObserver、setImmediate、setTimeout这些异步API实现的,异步API的回调函数是flushCallbacks函数。
vartimerFunc;// 这里Vue内部对于异步API的选用,由Promise、MutationObserver、setImmediate、setTimeout里取一个// 取用的规则是 Promise存在取由Promise,不存在取MutationObserver,MutationObserver不存在setImmediate,// setImmediate不存在setTimeout。if(typeofPromise!=='undefined'&& isNative(Promise)) {varp =Promise.resolve(); timerFunc =function(){ p.then(flushCallbacks);if(isIOS) { setTimeout(noop); } }; isUsingMicroTask =true;}elseif(!isIE &&typeofMutationObserver !=='undefined'&& ( isNative(MutationObserver) ||// PhantomJS and iOS 7.x MutationObserver.toString() ==='[object MutationObserverConstructor]')) {varcounter =1;varobserver =newMutationObserver(flushCallbacks);vartextNode =document.createTextNode(String(counter)); observer.observe(textNode, {characterData:true}); timerFunc =function(){ counter = (counter +1) %2; textNode.data =String(counter); }; isUsingMicroTask =true;}elseif(typeofsetImmediate !=='undefined'&& isNative(setImmediate)) { timerFunc =function(){ setImmediate(flushCallbacks); };}else{ timerFunc =function(){ setTimeout(flushCallbacks,0); };}
7、flushCallbacks函数中将遍历执行nextTick里push的callback全局数组,全局callback数组中实际是第5步的push的flushSchedulerQueue的执行函数。
// 将nextTick里push进去的flushSchedulerQueue函数进行for循环依次调用functionflushCallbacks(){ pending =false;varcopies = callbacks.slice(0); callbacks.length =0;for(vari =0; i < copies.length; i++) { copies[i](); }}
8、callback遍历执行的flushSchedulerQueue函数中,flushSchedulerQueue里先按照id进行了优先级排序,接下来将第4步中的存储watcher对象全局queue遍历执行,触发渲染函数watcher.run。
functionflushSchedulerQueue(){varwatcher, id;// 安装id从小到大开始排序,越小的越前触发的updatequeue.sort(function(a, b){returna.id - b.id; });// queue是全局数组,它在queueWatcher函数里,每次update触发的时候将当时的watcher,push进去for(index =0; index < queue.length; index++) { ... watcher.run();// 渲染... }}
9、watcher.run的实现在构造函数Watcher原型链上,初始状态下active属性为true,直接执行Watcher原型链的set方法。
Watcher.prototype.run =functionrun(){if(this.active) {varvalue =this.get(); ... }};
10、get函数中,将实例watcher对象push到全局数组中,开始调用实例的getter方法,执行完毕后,将watcher对象从全局数组弹出,并且清除已经渲染过的依赖实例。
Watcher.prototype.get =functionget(){ pushTarget(this);// 将实例push到全局数组targetStackvarvm =this.vm; value =this.getter.call(vm, vm); ...}
11、实例的getter方法实际是在实例化的时候传入的函数,也就是下面vm的真正更新函数_update。
function(){ vm._update(vm._render(), hydrating);};
12、实例的_update函数执行后,将会把两次的虚拟节点传入传入vm的patch方法执行渲染操作。
Vue.prototype._update =function(vnode, hydrating){varvm =this; ... var prevVnode = vm._vnode; vm._vnode = vnode;if(!prevVnode) {// initial rendervm.$el = vm.__patch__(vm.$el, vnode, hydrating,false/* removeOnly */); }else{// updatesvm.$el = vm.__patch__(prevVnode, vnode); } ...};
nextTick的实现原理
首先nextTick并不是浏览器本身提供的一个异步API,而是Vue中,用过由浏览器本身提供的原生异步API封装而成的一个异步封装方法,上面第5第6段是它的实现源码。
它对于浏览器异步API的选用规则如下,Promise存在取由Promise.then,不存在Promise则取MutationObserver,MutationObserver不存在setImmediate,setImmediate不存在最后取setTimeout来实现。
从上面的取用规则也可以看出来,nextTick即有可能是微任务,也有可能是宏任务,从优先去Promise和MutationObserver可以看出nextTick优先微任务,其次是setImmediate和setTimeout宏任务。
对于微任务与宏任务的区别这里不深入,只要记得同步代码执行完毕之后,优先执行微任务,其次才会执行宏任务。
Vue能不能同步渲染?
1、 Vue.config.async = false
当然是可以的,在第四段源码里,我们能看到如下一段,当config里的async的值为为false的情况下,并没有将flushSchedulerQueue加到nextTick里,而是直接执行了flushSchedulerQueue,就相当于把本次data里的值变化时,页面做了同步渲染。
functionqueueWatcher(watcher){ ...// 在全局队列里存储将要响应的变化update函数queue.push(watcher); ...// 当async配置是false的时候,页面更新是同步的if(!config.async) { flushSchedulerQueue();return}// 将页面更新函数放进异步API里执行,同步代码执行完开始执行更新页面函数nextTick(flushSchedulerQueue);}
在我们的开发代码里,只需要加入下一句即可让你的页面渲染同步进行。
importVuefrom'Vue'Vue.config.async =false
2、this._watcher.sync = true
在Watch的update方法执行源码里,可以看到当this.sync为true时,这时候的渲染也是同步的。
Watcher.prototype.update =functionupdate(){if(this.lazy) {this.dirty =true; }elseif(this.sync) {this.run(); }else{ queueWatcher(this); }};
在开发代码中,需要将本次watcher的sync属性修改为true,对于watcher的sync属性变化只需要在需要同步渲染的数据变化操作前执行this._watcher.sync=true,这时候则会同步执行页面渲染动作。
像下面的写法中,页面会渲染出val为1,而不会渲染出2,最终渲染的结果是3,但是官网未推荐该用法,请慎用。
newVue({el:'#app',sync:true,template:'<div>{{val}}</div>', data () {return{val:0} }, mounted () {this._watcher.sync =truethis.val =1debuggerthis._watcher.sync =falsethis.val =2this.val =3}})