在第一天学习Vue时,打开了Vue的官网,看了一段视频演示,被 Vue 的响应式惊到了,非常好奇这种实时更新是如何实现的.遂查询了一些资料,其中有一些明显超出了现在能理解的范围.但大体的思路弄明白了,赶紧总结一下.
目标 1:实现简单的响应式
- 思路:
所谓的响应式,就是在 vm的 data 对象中的成员属性发生变化时,会将此变化实时更新至使用此数据的页面部分上,也就是m层发生变化,v层自动更新.
首先想到的解决办法,就是在我们改变一个属性时,会触发某个事件,然后为这个事件写一个更新DOM的回调函数,就可以实现基本的响应式了.
- 困难:
可是之前所见过的所有的事件大多都是基于 DOM 元素的,没有基于对象的属性的.
那首先看看一切的起始点,vm.$data有什么特别:
并不是想象中的单纯的 data 中的键值对,而是将data 中的键值对变为一个大对象的一部分,然后添加了一些 get 和 set 方法.还有一个属性__ob__是什么,暂时搞不懂,先不管他.根据字面意思来理解,这些方法就是获取和设置某个属性后触发的回调函数.看来思路是对的,下来就是找为属性注册事件的方法了.
这个方法就是出路.此方法可为一个对象添加一个新属性(或修改一个已存在的属性),并且为这个属性设置属性描述符.
Object.defineProperty(obj, prop, descriptor) //obj:添加或修改属性的对象 //prop:要添加或修改的属性名 //descriptor:属性描述符,对象形式
其中的 descriptor 是核心,是区别于普通方法(如 obj.key=value 的形式)定义属性的优势所在,在其中,就可以注册我们一直在寻找的事件监听.
var obj={} Object.defineProperty(obj, 'test', { get: function () { console.log('调用了 get'); }, set: function (newVal) { console.log('调用了 set,参数为'+newVal); } }) obj.test //调用了 get obj.test='params' //调用了 set,参数为params obj.test //调用了 get
在以上的代码中,使用 Objective.defineProperty 方法为 obj 添加了一个 test 属性,并添加了 get 和 set 两个回调函数作为属性描述符.而这两个回调函数触发的条件即分别是这个属性被访问时和被设置时,即为这个属性添加了一个访问事件和一个设置事件.
注意:在上面的代码中,无论事先给 test设定任何值,或者不设定值,访问属性 obj.test 都只会得到 get 方法的返回值,也就是说,这个属性存在的意义已经限定在触发 get和 set 的回调函数上了,与普通的属性(作为储存数据的变量)完全不同了.所以,obj.test 可看做一个函数(get)调用; obj.test=newVal可看做一个函数(set)调用,并传入了一个实参 newVal,这里的赋值运算符只是为了触发 set 并标识实参,并没有为 test 设定新值.
现在,对一个已存在的对象属性添加 get和 set.并且要求在访问这个属性时,能输出之前设定的值;在设置这个属性时,更新get方法所能返回的值.
这里遇到一个小问题:根据上面的分析,在使用Objective.defineProperty 方法为obj更新属性,设定 get 和 set时,原本这个属性代表的值将会和这个属性完全剥离,再也无法通过obj.key 或者 obj[key]访问到.那么,就在更新属性之前,把这个值记录下来,并且让这个值只能被本属性的 get和 set 方法访问----对,就是闭包.
function defineReactive(obj, key, val) { Object.defineProperty(obj, key, { get: function () { return val }, set: function (newVal) { if (newVal === val) return; val = newVal } }) }
函数写好了,联想到 vm.$data 中有多个键值对,肯定这里面的多个甚至全部键值对都是响应式的,那我们模拟一个对象,遍历这个对象时调用这个函数好了
var stu = {name: 'Alex', age: 15, height: 170}; Object.keys(stu).forEach(function (key) { defineReactive(stu, key, stu[key]) })
现在打印下这个 stu:
嗯,已经有 vm.$data点样子了.
- 实现:
到现在,所有的属性都已经绑定好了事件,属性值也都可以正常获取和修改了,接下来就要在页面上呈现了,也就是在 set 的回调函数中添加更新页面的操作.
为了观察方便,直接做一个简单的双向绑定:
<body> <span id="name"></span> 的年龄是 <span id="age"></span>岁,身高是 <span id=height></span><br> <input type="text" placeholder="修改姓名" refer="name"><br> <input type="text" placeholder="修改年龄" refer="age"><br> <input type="text" placeholder="修改身高" refer="height"><br> </body> <script> function defineReactive(obj, key, val) { Object.defineProperty(obj, key, { get: function () { return val }, set: function (newVal) { if (newVal === val) return; val = newVal update() //每次设置数据,都会更新页面 } }) } var stu = { name: 'Alex', age: 15, height: 170 }; Object.keys(stu).forEach(function (key) { defineReactive(stu, key, stu[key]) }) document.querySelectorAll('input').forEach(function(item){ item.oninput=function(){ stu[item.getAttribute('refer')]=item.value //每当输入数据,都会触发set } }) var span_age=document.getElementById('age') var span_name=document.getElementById('name') var span_heigth=document.getElementById('height') function update(){ span_name.innerText=stu.name span_age.innerText=stu.age span_heigth.innerText=stu.height } update() //初始化页面数据 </script>
至此,第一个目标,一个简单的响应式已经实现.
但是,在使用的过程中,发现一个很严重的问题,那就是数据绑定.我们并没有去识别 DOM中哪个位置使用了哪个对象属性.在实际使用中,手动为一个个 DOM添加事件,或者为一个个标签写入 innerText显然是不现实的.所以接下来,解决数据的自动绑定问题.
目标 2:实现数据的自动绑定
- 思路:
通过学习 Vue的基本语法,可以知道 vue 判断数据绑定的标识:在标签内,通过 v-bind,v-for,v-model 等命令标识,而在内容区域,通过{{}}来标识.那么要找到这些标识,vue 必然会对vm.$el中的DOM树进行一次全面分析,找出这些标识符,并将这些标识符所表示的属性与 data 中的对应属性值关联起来.
- 分解步骤:
1.遍历节点
将一个标签中的所有子节点拿出来遍历,可能是一个耗能非常高的任务.为了高效完成这一步操作,可以使用DocumentFragment(文档片段) 来进行.正如其中所描述的,在虚拟 DOM中进行修改,不会触发页面的重渲染,仅在 append到页面中时,触发一次性渲染,大大提高了DOM渲染的效率.国外的一些大神也专门做了这项测试.
那第一步操作,就变成了劫持所有 DOM节点至文档片段中:
function toFragment(father){ var flag=document.createDocumentFragment(); var child; while (child=father.firstChild){ flag.appendChild(child); //绑定数据具体逻辑待完成 } return flag; }
2.实现数据绑定
在遍历时,就需要判断这个节点有没有 v-命令或者{{}}这样的标识符了.在这个模拟中,先只做 v-model 和{{}} 的识别
function compile(child,data){
var reg= /\{\{(.*)\}\}/; //设定正则用于检测{{}}
if(child.nodeType===1 && (child.nodeName==='INPUT' || child.nodeName==='SELECT' || child.nodeName==='TEXTAREA')){
var attr=child.attributes //获取这个节点的全部行内属性 for(var i = 0;i<attr.length;i++){ if(attr[i].nodeName==='v-model'){ var name=attr[i].nodeValue; //获取 v-model 所绑定的属性名 child.addEventListener('input',function(){ data[name]=child.value //修改对应的属性值,触发 set
console.log(data[name]) //为了方便检查数据更新
}) child.value=data[name] //将data中的数据展示在页面中. 页面初始化要用 child.removeAttribute('v-model') //在真实 DOM中,不会显示 v-model 这个属性 } } }else if(child.nodeType===3){ if(reg.test(child.nodeValue)){ var name=RegExp.$1.trim(); //RegExp.$1表示第一个符合条件的()中的内容,即{{}}中的内容 child.nodeValue=data[name]; } } }
接下来,在 toFragment 中调用 complie(child,stu),先去掉 get 中的 update()调用,目前这一步只关心数据的绑定.然后再添加如下代码
<div id="app"> {{name}}<span>的年龄是</span>{{age}}<span> 岁,身高是</span>{{height}}<br> <input type="text" placeholder="修改姓名" v-model="name"><br> <input type="text" placeholder="修改年龄" v-model="age"><br> <input type="text" placeholder="修改身高" v-model="height"><br> </div> <script> var el=document.getElementById('app'); el.appendChild(toFragment(el)) //把劫持来的节点还给真实 DOM </script>
检查一下成果:
数据自动绑定完成!
现在,剩下了最后一个任务.数据自动绑定和响应式更新完成了,但是并没有反映到页面当中去.
最终目标:响应式更新页面数据
- 思路:
其实这里就少了一个第二步去掉的 set 中的 update()方法.最开始的时候这个方法很好写,因为都是手动选定的 DOM元素,但是变成自动绑定以后就不那么简单了.
关键还是谁更新的问题:谁绑定了我这个属性,我这个属性在更新的时候,谁就去更新. 这里要用到一个叫观察者模式的思想,大意就是定义了一对多的关系,为一个属性定义一个主题对象(Dep),为所有绑定这个属性的节点定义watcher对象,当这个属性发生变化时,Dep发布通知给所有的 watcher,watcher 接到通知后执行更新操作.现在通过实例具体来深入理解下.
- 具体实现:
有了大体的思路,现在的问题是Dep 和 Watcher如何定义,在什么地方去添加他们,以及何时调用他们的方法.
1.Dep
根据 Dep的定义,每个属性都有一个自己的 Dep,那创建它的地方应该就是在 defineReactive 这个函数中了,这个函数被用在遍历对象中添加 get 和 set方法. 而因为它需要有两个功能,获取订阅者和发布通知,所以这肯定是个自定义对象了,那么根据它的功能写一个构造函数:
function Dep(){ this.subs=[] } Dep.prototype={ addSub:function(sub){ this.subs.push(sub) }, notify:function(){ this.subs.forEach(function(sub){ sub.update(); }) } }
然后在确定好的位置 new 一个
function defineReactive(obj, key, val) { var dep=new Dep() ..... }
2.Watcher
追根溯源,在数据自动绑定时,进入了最终 if判断的那些节点,都要建立一个 watcher 对象,这个对象肯定要有一个 update方法,用于代替之前写死的那个.在这个 update 方法中,要使用的信息有:这个节点(为谁更新),数据对象以及绑定的属性名. 除此之外,为了将自己添加进 Dep的 sub名单,还需要给 Dep一个明确的信号,让其调用 dep.addSub 方法.这个过程在什么时候完成呢?页面初始化,首次触发数据的 get 回调时.
function Watcher(data,child,name){ Dep.target=this; this.data=data; this.node=child; this.name=name; this.update(); //初始化页面数据,触发了 get方法,同时因为此时Dep.target 存在指向,就会触发其中的 addSub 方法 Dep.target=null; //将 Dep.target 重新指向空,保证了只有在第一次访问数据(页面初始化)时才会触发 addSub 方法,防止每次更新数据都重复添加 } Watcher.prototype={ update:function(){ this.node.nodeValue=this.data[this.name] } }
new 一下
function compile(child,data){ .... else if(child.nodeType===3){ if(reg.test(child.nodeValue)){ var name=RegExp.$1.trim(); new Watcher(data,child,name) } } }
3.两者配合
在确定了彼此的形态和位置后,最后就是确定方法何时调用了.很显然,dep.addSub 需要在 watcher首次访问数据时调用,而 dep.notify 需要在数据更新,也就是 set 中调用:
function defineReactive(obj, key, val) { var dep=new Dep() Object.defineProperty(obj, key, { get: function () { if(Dep.target) dep.addSub(Dep.target) return val }, set: function (newVal) { if (newVal === val) return; val = newVal dep.notify(); } }) }
最终效果:
1 <!DOCTYPE html> 2 <html lang="en"> 3 4 <head> 5 <meta charset="UTF-8"> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 <meta http-equiv="X-UA-Compatible" content="ie=edge"> 8 <title>Document</title> 9 </head> 10 11 <body> 12 <div id="app"> 13 {{name}}<span>的年龄是</span>{{age}}<span> 岁,身高是</span>{{height}}<br> 14 <input type="text" placeholder="修改姓名" v-model="name"><br> 15 <input type="text" placeholder="修改年龄" v-model="age"><br> 16 <input type="text" placeholder="修改身高" v-model="height"><br> 17 </div> 18 19 </body> 20 21 </html> 22 <script> 23 //定义主题对象 24 function Dep(){ 25 this.subs=[] 26 } 27 Dep.prototype={ 28 addSub:function(sub){ 29 this.subs.push(sub) 30 }, 31 notify:function(){ 32 this.subs.forEach(function(sub){ 33 sub.update(); 34 }) 35 } 36 } 37 38 //定义观察者 39 function Watcher(data,child,name){ 40 Dep.target=this; 41 this.data=data; 42 this.node=child; 43 this.name=name; 44 this.update(); //初始化页面数据,触发了 get方法,同时因为此时Dep.target 存在指向,就会触发其中的 addSub 方法 45 Dep.target=null; 46 //将 Dep.target 重新指向空,保证了只有在第一次访问数据(页面初始化)时才会触发 addSub 方法,防止每次更新数据都重复添加 47 } 48 Watcher.prototype={ 49 update:function(){ 50 this.node.nodeValue=this.data[this.name] 51 } 52 } 53 54 //定义访问器 55 function defineReactive(obj, key, val) { 56 var dep=new Dep() 57 Object.defineProperty(obj, key, { 58 get: function () { 59 if(Dep.target) dep.addSub(Dep.target) 60 return val 61 62 }, 63 set: function (newVal) { 64 if (newVal === val) return; 65 val = newVal 66 dep.notify(); 67 } 68 }) 69 } 70 var stu = { 71 name: 'Alex', 72 age: 15, 73 height: 170 74 }; 75 76 //遍历添加访问器 77 Object.keys(stu).forEach(function (key) { 78 defineReactive(stu, key, stu[key]) 79 }) 80 81 82 //劫持所有子节点至文档片段 83 function toFragment(father){ 84 var flag=document.createDocumentFragment(); 85 var child; 86 while (child=father.firstChild){ 87 flag.appendChild(child); 88 compile(child,stu) 89 } 90 return flag; 91 } 92 //绑定数据的具体逻辑 93 function compile(child,data){ 94 var reg= /\{\{(.*)\}\}/; //设定正则用于检测{{}} 95 if(child.nodeType===1 && (child.nodeName==='INPUT' || child.nodeName==='SELECT' || child.nodeName==='TEXTAREA')){ 96 var attr=child.attributes //获取这个节点的全部行内属性 97 for(var i = 0;i<attr.length;i++){ 98 if(attr[i].nodeName==='v-model'){ 99 var name=attr[i].nodeValue; //获取 v-model 所绑定的属性名 100 child.addEventListener('input',function(){ 101 data[name]=child.value //修改对应的属性值,触发 set 102 console.log(data[name]); 103 }) 104 child.value=data[name] //将data中的数据展示在页面中. 页面初始化要用 105 child.removeAttribute('v-model') //在真实 DOM中,不会显示 v-model 这个属性 106 } 107 } 108 }else if(child.nodeType===3){ 109 if(reg.test(child.nodeValue)){ 110 var name=RegExp.$1.trim(); //RegExp.$1表示第一个符合条件的()中的内容,即{{}}中的内容 111 child.nodeValue=data[name]; 112 new Watcher(data,child,name) 113 } 114 } 115 } 116 117 var el=document.getElementById('app'); 118 el.appendChild(toFragment(el)) // 返还节点给页面 119 120 </script>