双向绑定是指既可以数据驱动视图,又可以视图驱动数据,那么要实现这样一个功能意味着我们需要将dom节点中与双向绑定相关的指令与属性传入一个类似于“加工工厂”的类中,进行筛选和加工,最后实现一个甄别出关键参数和指令并根据指令实现数据双向绑定的完整“工艺流程”。
vue数据响应式双向绑定的实现机制,大致如下流程:
实现具体思路如下:我们new出的MVVM类内含有各种属性,我们就需要实现一个监听类Observer,内含Object.defineProperty()方法去劫持各个属性的setter和getter以时刻察觉到MVVM类的变化,然后再将这个变化通知给Dep订阅器,Dep订阅器算是一个通知观察者的通信员和储存不同观察着不同属性的观察者(每个观察者只能观察一个属性)的容器,同时Dep订阅器通知观察者Watcher去更新DOM节点从而实现视图的更新。流程如下图红线:
接下来实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图,而在初始化视图时,走的时下图的红线流程,当解析器识别到数据变动时,又会走蓝线的流程去更新视图。
我们将实现该图流程划分如下:
1. 实现一个指令解析器Compile
2. 实现一个数据监听器Observer
3. 实现一个观察者Watcher(来更新视图)
4. 实现一个proxy代理
1.实现一个指令解析器Compile
Compile对el绑定的根元素的子节点的指令(如v-text,{{}},v-on等)进行一个扫描和解析,根据指令模板找到对应的挂载节点去替换数据,具体实现的方式就是绑定一个回调更新函数,传递给Watcher来更新视图。此外还要在Compile中获取一个文档碎片对象,放入内存中会减少页面的回流和重绘,文档碎片对象中对元素对象和文本对象进行处理,并甄别出传入的text,model等不同指令并针对不同的指令处理不同的事件。
1 class Compile{ 2 constructor(el,vm){ 3 // 判断el是否为元素节点,不是则获取其值 4 this.el=this.isElementNode(el) ? el : document.querySelector(el); 5 this.vm = vm; 6 // 1.获取文档碎片对象 放入内存中减少页面的回流重绘 7 const fragment = this.node2Fragment(this.el); 8 // console.log(fragment); 9 // 2.编译fragment模板 10 this.compile(fragment); 11 // 3.追加子元素到根元素 12 this.el.appendChild(fragment); 13 }; 14 15 // 编译fragment模板的方法 16 compile(fragment){ 17 // 1.获取到每一个子节点 18 const childNodes=fragment.childNodes; 19 [...childNodes].forEach(child=>{ 20 if(this.isElementNode(child)){ 21 // 是元素节点 22 // 编译元素节点 23 this.compileElement(child); 24 }else{ 25 // 文本节点 26 // 是否编译文本节点 27 this.compileText(child); 28 } 29 30 if(child.childNodes && child.childNodes.length){ 31 this.compile(child); 32 } 33 34 }) 35 }; 36 37 // 编译元素节点的方法 38 compileElement(node){ 39 const attributes=node.attributes; 40 [...attributes].forEach(attr=>{ 41 const {name,value}=attr; 42 if(this.isDirective(name)){ //判断是否为一个指令 43 const [ ,dirctive]=name.split('-'); 44 // console.log(dirctive); 45 const[dirName,eventName] = dirctive.split(':'); 46 // 更新数据,数据驱动视图 47 compileUtil[dirName](node,value,this.vm,eventName); 48 // 删除有指令的标签上的属性 49 node.removeAttribute('v-' + dirctive); 50 }else if(this.isEventName(name)){ 51 let[ ,eventName]= name.split('@'); 52 compileUtil['on'](node,value,this.vm,eventName); 53 } 54 }) 55 } 56 57 // 是否编译文本节点的方法 58 compileText(node){ 59 // 编译{{}} 60 const content =node.textContent; 61 // console.log(content); 62 if(/\{\{(.+?)\}\}/.test(content)){ 63 compileUtil['text'](node,content,this.vm) 64 } 65 } 66 67 isEventName(attrName){ 68 return attrName.startsWith('@') 69 } 70 71 isDirective(attrName){ 72 return attrName.startsWith('v-') 73 } 74 75 // 获取所有孩子节点 76 node2Fragment(el){ 77 // 1.创建文档碎片 78 const f=document.createDocumentFragment(); 79 let firstChild; 80 // 2.遍历取出所有节点加入f中 81 while(firstChild=el.firstChild){ 82 f.appendChild(firstChild); 83 } 84 return f; 85 } 86 87 // 判断是否为元素节点的方法 88 isElementNode(node){ 89 return node.nodeType === 1; 90 } 91 }
2.实现一个数据监听器Observer
我们接下来实现的是一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值通知依赖收集对象(Dep)并通知订阅者(Watcher)来更新视图。
1 class Observer{ 2 constructor(data){ 3 this.observe(data) 4 } 5 6 // 观察数据的方法 7 observe(data){ 8 if(data && typeof data==='object'){ 9 //console.log(Object.keys(data)); 10 Object.keys(data).forEach(key=>{ 11 this.defineReactive(data,key,data[key]) 12 }) 13 } 14 } 15 16 defineReactive(obj,key,value){ 17 //递归遍历 18 this.observe(value); 19 const dep=new Dep(); 20 // 劫持并监听所有的属性 21 Object.defineProperty(obj,key,{ 22 enumerable:true, 23 configurable:false, 24 get(){ 25 // 订阅数据变化时,往Dep中添加观察者 26 Dep.target && dep.addSub(Dep.target) 27 return value; 28 }, 29 set:(newVal)=>{ 30 this.observe(newVal) 31 if(newVal!==value){ 32 value=newVal; 33 } 34 // 告诉dep通知变化 35 dep.notify() 36 } 37 }) 38 } 39 }
3. 实现一个Watcher
它作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图
只要所做事情:
1、在自身实例化时往属性订阅器(dep)里面添加自己
2、自身必须有一个update()方法
3、待属性变动dep.notify()通知时,能调用自身的update()方法,并触发Compile中绑定的回调。
1 class Watcher{ 2 constructor(vm,expr,cb){ 3 this.vm=vm; 4 this.expr=expr; 5 this.cb=cb; 6 // 先把旧值保存起来 7 this.oldVal = this.getOldVal() 8 } 9 10 getOldVal(){ 11 Dep.target=this; 12 const oldVal= compileUtil.getVal(this.expr,this.vm); 13 Dep.target=null; 14 return oldVal; 15 } 16 17 update(){ 18 const newVal= compileUtil.getVal(this.expr,this.vm); 19 if(newVal!==this.oldVal){ 20 this.cb(newVal); 21 } 22 } 23 } 24 25 // 实现一个订阅器 26 class Dep{ 27 constructor(){ 28 this.subs=[]; 29 } 30 // 收集观察者 31 addSub(watcher){ 32 this.subs.push(watcher); 33 } 34 // 通知观察者去更新 35 notify(){ 36 this.subs.forEach(w=>w.update()) 37 } 38 }
4. 实现一个proxy代理
我们在使用vue的时候,通常可以直接vm.msg来获取数据,这是因为vue源码内部做了一层代理.也就是说把数据获取操作vm上的取值操作 都代理到vm.$data上
1 proxyData(data){ 2 for(const key in data){ 3 Object.defineProperty(this,key,{ 4 get(){ 5 return data[key]; 6 }, 7 set(newVal){ 8 data[key]=newVal; 9 } 10 }) 11 } 12 }
致此一个简易的vue源码就实现了,我们来测试一下:
1 <script src="./Observer.js"></script> 2 <script src="./Myvue.js"></script> 3 <script> 4 let vm=new Myvue({ 5 el:'#app', 6 data:{ 7 person:{ 8 name:"funkyou", 9 age:21, 10 fav:'ukulele', 11 }, 12 msg:"mvvm自制版", 13 htmlStr:'<h3>我是一个前端实习生</h3>' 14 }, 15 methods:{ 16 handleClick(){ 17 // console.log(this); 18 this.$data.person.name='Chrome' 19 } 20 }, 21 }) 22 </script>
对应的页面如图:
实现了数据的改变同步到页面上。
在我的GitHub上查看源码:
https://github.com/173392531/vue-