Vue响应式原理&如何实现MVVM双向绑定
时间:2023-04-02 12:30:43
HTML
前言众所周知,Vue.js的响应式采用了数据劫持+发布-订阅的模式,但是深有深意,作为新手,我常常觉得自己能答,最后想谈却以失败告终;作为经典的面试题之一,在大多数情况下,我只能回答“UseObject.defineProperty...”,所以写这篇文章来帮助自己理清响应式思维。什么是MVVMModel,View,View-Model就是mvvm的意思;View通过View-Model的DOMListeners将事件绑定到Model,Model通过DataBindings管理View中的数据,View-Model从中起到了连接桥梁的作用。根据mvvm模型,当模型(数据)发生变化时,相应的视图也会自动发生变化。这是有反应的。例如?//html{{a.b}}
我的消息是{{c}}
//jsletmvvm=newMvvm({el:'#app',data:{a:{b:'这是一个例子'},c:10,}});原理Vue实例创建时,vue会遍历数据选项Properties,使用Object.defineProperty将它们变成getters/setters,并在内部跟踪相关的依赖关系,在属性被访问和修改时通知变化。每个组件实例/元素都有一个对应的watcher程序实例,在组件渲染的过程中会将该属性记录为一个依赖,然后当依赖的setter被调用时,会通知watcher重新计算,导致其关联的组件更新总结,最重要的就是数据劫持的三个步骤:使用Object.defineProperty为每条数据设置getter/setter数据渲染:为页面上每一个使用数据的组件添加一个observer(依赖)watcher发布和subscribe:为每条数据添加一个订阅者(依赖收集器)dep,并将对应的观察者添加到依赖列表中。每当数据更新时,订阅者(依赖收集器)通知所有对应的观察者(依赖)自动更新对应的页面实现一个MVVM的思想通过上面我们知道了mvvm的大致运行原理,对应上面实现其功能分别是1,一个数据监听器Observer,监听数据的所有属性,如果有变化通知订阅者并绑定对应的update函数3、一个依赖Watcher类和一个依赖收集器dep类4、一个mvvm类Mvvm我们要创建一个Mvvm,根据我们之前的mvvm示例classMvvm{constructor(option){this.$option=option;//初始化this.init();}init(){//数据监控observe(this.$option.data);//编译newCompile(this.$option.el);}}我这里只写了一个函数,写在一个类中也是可以的observe(data){//判断是否是对象if(typeofdata!=='object')return//循环数据Object.keys(data).forEach(key=>{defineReactive(data,key,data[key]);})/*数据劫持defineReactive*@param*obj-监听对象;key-遍历对象的key;val-遍历对象的val*/functiondefineReactive(obj,key,val){//递归子属性observe(val);//数据劫持Object.defineProperty(obj,key,{enumerable:true,//enumerableconfigurable:true,//modifiable//设置getter和setter函数来劫持数据get(){console.log('get!',key,val);returnval},set(newVal){//监控新数据observe(newVal);console.log('set!',key,newVal);val=newVal;//赋值},})但是,仅仅这样写是不够的,因为还有像数组这样的特殊情况:严格来说,Object.defineProperty可以监听到数组的变化,但是无法监听到增加数组长度(prototype方法);简单来说就是,当使用数组原型方法改写数组时,虽然改写了数据,但是我们无法监控数组本身的改写;因此,在Vue中重写了数组的原型方法;我们也实现了这个重写://先获取原型上的方法,然后创建原型重写letmethods=['pop','shift','unshift','sort','reverse','splice','推'];让arrProto=数组。原型;让newArrProto=Object.create(arrProto);methods.forEach(method=>{newArrProto[method]=function(...args){console.log('arrchange!')//用function定义函数,使this指向调用数组;如果使用箭头函数,this会指向windowarrProto[method].call(this,...args)}})//数据劫持函数observe(data){//判断是否为数组类型+if(Array.isArray(data)){+//将数组数据原型指针指向自己定义的原型对象+data.__proto__=newArrProto;+return+}...}不过,这样还是有一个限制,就是,Vue无法检测到对象属性的增减;所以在Vue中使用了Vue.set和Vue.delete来弥补响应性;这个我们先跳过,有时间再补充指令解析/*编译类,解析dom中所有节点上的指令*@params*$el-需要渲染的标签*$vm-mvvm实例*/classCompile{构造函数(el,vm){this.vm=vm;this.$el=document.querySelector(el);//挂载到编译后的实例,方便操作this.frag=document.createDocumentFragment();//使用fragment类进行dom操作以节省开销this.reg=/\{\{(.*?)\}\}/g;//将所有dom节点移动到fragwhile(this.$el.firstChild){letchild=this.$el.firstChild;this.frag.appendChild(child);}//编译元素节点this.compile(this.frag);this.$el.appendChild(this.frag);}}这样一个编译功能框架就写好了,接下来我们需要补充里面的详细功能;因为我们需要在循环节点时在文本节点上识别{{xxx}}插值。.classCompile{...//compilecompile(frag){//遍历frag节点nodeArray.from(frag.childNodes).forEach(node=>{lettxt=node.textContent;//compiletext{{}}if(node.nodeType===3&&this.reg.test(txt)){this.compileTxt(node,RegExp.$1);}//递归子节点if(node.childNodes&&node.childNodes.length)this.compile(node)})}//编译文本节点compileTxt(node,key){node.textContent=typeofval==='undefined'?'':瓦尔;}...}到这里,第一次渲染页面的时候,mvvm已经可以渲染实例中的数据了,但是还不够,因为我们需要它实时自动更新发布和订阅。当一个数据同时被多个节点/节点上的组件引用时,当数据更新时,我们如何自动逐页更新呢?这就需要使用发布-订阅模型;我们可以在编译时为每个使用页面数据的组件添加一个观察者(dependency)watcher;然后为每个数据添加一个订阅者(依赖收集器)dep,并在依赖列表中添加对应的观察者(依赖)watcher。每当数据更新时,订阅者(依赖收集器)通知所有对应的观察者(依赖)自动更新对应的页面。因此,需要创建一个Dep,可以用来收集依赖,删除依赖,给依赖发送消息DepclassDep{constructor(){//创建一个数组保存所有依赖的路径this.subs=[];}//添加依赖@sub-dependency(watcherExample)addSub(sub){this.subs.push(sub);}//提醒释放notify(){this.subs.forEach(el=>el.update())}}Watcher//观察者/依赖类Watcher{constructor(vm,key,cb){this.vm=vm;this.key=键;这。cb=cb;//初始化时获取当前数据值this.value=this.get();}/*获取当前值*@param$boolean:true-数据更新/false-初始化*@returncurrentvm[key]*/get(boolean){Dep.target=boolean?空:这个;//触发getter,将自己添加到depletvalue=UTIL.getVal(this.vm,this.key);Dep.target=null;返回值;}update(){//获取最新值;//只在初始化时触发,更新时不触发getterletnowVal=this.get(true);//比较旧值if(this.value!==nowVal){console.log('update')this.value=nowVal;这个.cb(nowVal);}}}回到Compile,我们需要创建一个Awatcher实例;然后将渲染更新函数放到watcher的cb中;classCompile{...//编译文本节点compileTxt(node,key){+this.bind(node,this.vm,key,'text');}+//绑定依赖+bind(node,vm,key,dir){+letupdateFn=this.update(dir);+//第一次渲染+updateFn&&updateFn(node,UTIL.getVal(vm,key));+//setwatcher+newWatcher(vm,key,(newVal)=>{+//在cb+updateFn&&updateFn(node,newVal);+});+}+//update+update(dir){+switch(dir){+case'text'://textupdate+return(node,val)=>node.textContent=typeofval==='undefined'?'':val;+break;+}+}...}完成这些后,回到原来的defineReactive,修改,为每条数据添加一个dep实例;并在Adddependencytodepinstance的getter中;setter中增加dep实例释放功能;functionobserve(data){...functiondefineReactive(obj,key,val){//递归子属性observe(val);//添加依赖收集器+letdep=newDep();//数据劫持Object.defineProperty(obj,key,{enumerable:true,//enumerableconfigurable:true,//modifiableget(){console.log('get!',key,val);//添加订阅+Dep.target&&dep.addSub(Dep.target);returnval},set(newVal){observe(newVal);console.log('set!',key,newVal);val=newVal;//postupdate+部门通知();//触发更新},})}}至此,一个简单的响应式Mvvm就实现了。每当我们修改数据时,相应的页面内容会自动重新渲染更新;那么双向绑定是如何实现的呢?双向绑定双向绑定是在编译时识别node的元素节点,如果有v-model指令,则绑定元素的值和响应数据,在update函数update方法中添加对应的值classCompile{//compilecompile(frag){//遍历frag节点nodeArray.from(frag.childNodes).forEach(node=>{lettxt=node.textContent;//编译元素node+if(node.nodeType===1){+this.compileEl(node);+//编译文本{{}}}elseif(node.nodeType===3&&this.reg.test(txt)){this.compileTxt(node,RegExp.$1);}//递归子节点if(node.childNodes&&node.childNodes.length)this.compile(node)})}...+compileEl(node){+//查找指令v-xxx+letattrList=node.attributes;+if(!attrList.length)return;+[...attrList].forEach(attr=>{+letattrName=attr.name;+letattrVal=attr.value;+//判断是否有'v-'指令+if(attrName.includes('v-')){+//编译指令/绑定标签值及对应data+this.bind(node,this.vm,attrVal,'model');+letoldVal=UTIL.getVal(this.vm,attrVal);//获取vm实例的当前值+//添加输入事件监听器+节点。addEventListener('input',e=>{+letnewVal=e.target.value;//获取输入的新值+if(newVal===oldVal)return;+UTIL.setVal(this.vm,attrVal,newVal);+oldVal=newVal;+})+}+});+}...//更新update(dir){switch(dir){case'text'://文本更新return(node,val)=>node.textContent=typeofval==='undefined'?'':瓦尔;break;+case'model'://模型指令更新+return(node,val)=>node.value=typeofval==='undefined'?'':val;+中断;}}}简单的说,双向数据绑定就是通过v-xxx指令给一个组件添加一个addEventListner的监听函数。一旦事件发生,就会调用setter,从而调用dep.notify()通知所有依赖的watcher调用watcher.update()进行更新总结手工实现Mvvm的过程如下使用Object.defineProperty的get和set来hijackdata使用observe遍历data数据进行监控,并为data创建dep实例收集依赖使用Compile编译dom中所有节点,并在组件中添加wathcer实例并通过dep&watcher下发订阅阅读模式实现数据和视图同步项目源码欢迎搬项目源码最后,感谢阅读,欢迎指正和讨论?亲爱的读者们,欢迎star?