仿Vue中的双向数据绑定实现

蚊子前端博客
发布于 2018-06-08 10:10
在使用Vue的过程中,给我印象最深的就是双向数据绑定,那么双向绑定的原理是什么呢,我们也来实现一个简单的双向绑定

我们在使用Vue的过程中,双向绑定给了我们特别深的印象,data和view视图相互传递,只要其中一个发生了变化,另一个自动修改:

COPYHTML

<div id="#app"> <p>{{username}}</p> <p><input type="text" v-model="username"></p> </div> <script> new Vue({ el: '#app', data:{ username: '' } }) </script>

1. 快速的入门

1.1 Object.defineProperty

在Vue中,是使用Object.defineProperty进行了数据劫持,当获取数据或者修改数据时,都能通过自定义的getter/setter截取到, defineProperty官方文档。我们先看一个简单的例子:

COPYJAVASCRIPT

var user = {name: 'wenzi'}, key = 'name'; // obj 传入的obj格式的数据 // key 要定义的key // val 当前key对应的值 function defineProperty(obj, key, val){ Object.defineProperty(obj, key, { enumerable: true, // 是否出现对象的枚举属性中 configurable: false, // get(){ // 获取key值时触发 console.log( 'get value: '+val ); return val; }, set(newVal){ // 给当前key设置值时触发 if(val!==newVal){ console.log(val+' --> '+newVal); val = newVal; } } }) } defineProperty(user, key, user[key]); // 为user中的key重新定义get/set方法 console.log( user.name ); // get value: wenzi user.name = 'skeeter'; // wenzi --> skeeter

可见在获取key对应的值时会触发get方法,在为key赋值时会触发set方法,那么我们在set方法里加入对应的修改视图的函数,就能在data发生变化时,视图同步更新。我们在写例子之前,先要考虑解决两个个问题:

  1. 如何获取视图DOM中对应相关的元素?

  2. 如何将属性值与视图DOM对应起来?

关于第1个问题,我们首先用最笨的方式来实现,然后再考虑优化。先拿到最顶端的元素,然后遍历所有的子元素,通过正则获取到制定规则的属性;对于第2个问题,我们先给每个属性一个回调函数,当对应的值发生变化时,启动这个回调函数。来看个例子

COPYHTML

<div id="app"> <h2><p>{{name}}</p></h2> <p>{{age}}</p> </div> <script> function Vue(options){ this.el = options.el; this.data = options.data; this.watcher = {}; // 将obj里所有的属性都进行监听 Object.keys(this.data).forEach(key=>{ this.defineProperty(this.data, key, this.data[key]); }); // 解析DOM this.compile(); } Vue.prototype = { defineProperty(obj, key, val){ let self = this; Object.defineProperty(obj, key, { enumerable: true, // 是否出现对象的枚举属性中 configurable: false, // get(){ // 获取key值时触发 console.log( 'get value: '+val ); return val; }, set(newVal){ // 给当前key设置值时触发 if(val!==newVal){ console.log(val+' --> '+newVal); val = newVal; // console.log( self ); // 拿出在watcher中保存的函数,然后更新 self.watcher[key](newVal); } } }) }, compile(){ // 解析DOM视图中双括号之间的内容 let ele = document.querySelector(this.el); [].slice.call(ele.childNodes).forEach(node=>{ let reg = /\{\{(.*)\}\}/, text = node.textContent; if( reg.test(text) ){ let key = reg.exec(text)[1]; // 获取到key node.textContent = this.data[key]; // data中存储的key值渲染到视图中 // 定义一个对应key的函数,在defineProperty中的set进行调用 this.watcher[key]=(newVal)=>{ node.textContent = newVal; } } }) } } let ww = new Vue({ el: '#app', data: { name: 'wenzi', age: 28 } }); // 2000ms后更新 setTimeout(()=>{ ww.data.name = 'skeetershi'; ww.data.age = Date.now(); }, 2000) </script>

在我们定义的Vue的prototype里,定义了两个方法,defineProperty用来监听属性, compile用来解析DOM,然后通过watcher中定义的方法将两者关联起来。

1.2 data数据代理

是不是觉得在使用ww.data.name时很别扭,要是能直接通过ww.name访问和修改属性就好了。这里我们做一个小小的代理,把data里的属性都绑定到Vue上,修改时同步到data里:

COPYJAVASCRIPT

function Vue(options){ this.el = options.el; this.data = options.data; this.watcher = {}; // 将obj里所有的属性都进行监听 Object.keys(this.data).forEach(key=>{ this.proxyKeys(key); // 添加代理 this.defineProperty(this.data, key, this.data[key]); // 监听属性 }); // 解析DOM this.compile(); } Vue.prototype = { // 其他方法不变,添加一个代理方法,将data里的属性定义到this上,即Vue的实例中 proxyKeys(key){ let self = this; Object.defineProperty(this, key, { enumerable: false, configurable: true, get(){ // console.log(self); return self.data[key]; }, set(newVal){ self.data[key] = newVal; } }) } }

这样通过实例ww就能直接访问和修改data里的数据了:

COPYJAVASCRIPT

// 2000ms后更新 setTimeout(()=>{ ww.name = 'skeetershi'; ww.age = Date.now(); }, 2000)

1.3 createDocumentFragment

在上面例子的compile中,我们是直接用的DOM节点进行遍历与操作的,但是我们也知道,直接操作DOM有可能会引起页面的回流,影响页面的性能。

DocumentFragments 是DOM节点。它们不是主DOM树的一部分。通常的用例是创建文档片段,将元素附加到文档片段,然后将文档片段附加到DOM树。在DOM树中,文档片段被其所有的子元素所代替。

因为文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流(reflow)(对元素位置和几何上的计算)。因此,使用文档片段document fragments 通常会起到优化性能的作用(better performance)。

documentFragment 被所有主流浏览器支持。所以,没有理由不用。

COPYJAVASCRIPT

compile(){ // 解析DOM视图中双括号之间的内容 let ele = document.querySelector(this.el); var fragment = document.createDocumentFragment(); var child = ele.firstChild; while (child) { // 将Dom元素移入fragment中 // appendChild: 如果被插入的节点已经存在于当前文档的文档树中, // 则那个节点会首先从原先的位置移除,然后再插入到新的位置. fragment.appendChild(child); child = ele.firstChild } [].slice.call(fragment.childNodes).forEach(node=>{ let reg = /\{\{(.*)\}\}/, text = node.textContent; if( reg.test(text) ){ let key = reg.exec(text)[1]; // 获取到key node.textContent = this.data[key]; // data中存储的key值渲染到视图中 // 定义一个对应key的函数,在defineProperty中的set进行调用 this.watcher[key]=(newVal)=>{ node.textContent = newVal; } } }) ele.appendChild(fragment); }

2. 实现原理

在第1部分的快速入门里,我们已经有一个简要的了解了,虽然上面没有实现从视图到数据的更新,不过也不难理解,就是给对应的DOM绑定上事件,反向更新。

从数据到视图的更新,是需要对数据进行监听劫持,这里我们设置一个监听器Observer来实现对所有数据的监听; 设置一个订阅者Watcher,收到属性的变化通知并执行相应的函数,从而更新视图; 同时,要设置一个解析器Compiler,解析视图DOM中所有节点的指令,并将模板中的数据进行初始化,然后初始化对应的订阅器。

流程图如下:

仿Vue中的数据双向绑定-蚊子的前端博客

2.1 监听器Observer

采用递归的方式,将所有的属性都进行监听,不过我们这里没有对后加入的属性进行处理。因此只能处理初始化时的属性:

COPYJAVASCRIPT

function Observer(data){ this.walk(data); } Observer.prototype = { walk(data){ if( !data || typeof data!=='object' ){ return false; } Object.keys(data).forEach(key=>{ this.defineProperty(data, key, data[key]); }) }, defineProperty(data, key, val){ this.walk(val); Object.defineProperty(data, key, { enumerable: true, // 是否出现对象的枚举属性中 configurable: false, // get(){ // 获取key值时触发 console.log( 'value: '+JSON.stringify(val) ); return val; }, set(newVal){ // 给当前key设置值时触发 if(val!==newVal){ console.log( 'new value: '+JSON.stringify(newVal) ); val = newVal; } } }) } } var user = { name: 'wenzi', age: 27, score:{ eng: 88, math: 90 } }; new Observer(user); user.age = 20; // new value: 20 user.score.math = 'qwerty'; // new value: "qwerty"

同时我们还得有个容器,来存储所有的订阅者,当数据发生变化时,来通知这些订阅者。这里就用到了发布者/订阅者模式,订阅者就是每个Watcher,发布者是每个数据的set函数,那么谁来执行发布这个动作呢,这就需要一个Dep队列,当数据发生变化时,通过Dep来通知所有的订阅者:

COPYJAVASCRIPT

function Dep(){ this.subs = []; } Dep.prototype = { addSub(watcher){ this.subs.push(watcher); }, notify(){ this.subs.forEach(watcher=>{ watcher.run(); }) } };

那么什么时候执行add,什么时候执行notify呢? 执行notify的时机我们是没有疑问的,在set里;那么add呢?add方法在get里添加即可,理由: watcher是在什么时候用的,是在数据发生变化时更新视图的,在Watcher初始化时获取当前属性的值时进行触发,但是get里不能添加无意义的watcher,因此这里需要有一个限制条件,限制条件后面再说。

Observer中的defineProperty现在变成了这样:

COPYJAVASCRIPT

defineProperty(data, key, val){ this.walk(val); let dep = new Dep(); // 定义队列 Object.defineProperty(data, key, { enumerable: true, // 是否出现对象的枚举属性中 configurable: false, // get(){ // 获取key值时触发 if(条件允许){ dep.addSub(watcher); } return val; }, set(newVal){ // 给当前key设置值时触发 if(val!==newVal){ val = newVal; dep.notify(); // 发布消息 } } }) }

2.2 订阅器Watcher

订阅器Watcher在初始化时需要把自己添加到队列中,而且我们也在Observer中的get里执行了添加watcher的操作,所以这里我们只需要在Watcher初始化完成后,触发get操作即可,即获取对应的属性值,就能把wachter添加到队列中。

同时,我们只有在Watcher初始化时才需要添加订阅者,get方法会被多次的调用,因此这里我们用Dep.target缓存下订阅者,添加完成后再删除掉:

COPYJAVASCRIPT

// vm Vue的实例 // exp data中的key // cb 回调函数 // options function Watcher(vm, exp, cb, options){ this.vm = vm; this.exp = exp; this.cb = cb; this.value = this.get(); // 获取key对应的值,同时将watcher添加到Dep的队列中 } Watcher.prototype = { get(){ Dep.target = this; let value = this.vm[this.exp]; Dep.target = null; return value; }, update(){ this.run(); }, run(){ let value = this.vm[this.exp]; // 新值 // 当执行run方法时,value值与之前保存的value值不同时 if( value!==this.value ){ let oldValue = this.value; this.value = value; this.cb.call(this.vm, value, oldValue); // 执行在初始化时添加的回调函数 } } };

这时候我们可以把Observer中的defineProperty再次修改一下了:

COPYJAVASCRIPT

defineProperty(data, key, val){ this.walk(val); let dep = new Dep(); Object.defineProperty(data, key, { enumerable: true, configurable: false, get(){ // Dep.target存储的就是Watcher实例 if(Dep.target){ dep.addSub(Dep.target); } return val; }, set(newVal){ if(val!==newVal){ val = newVal; dep.notify(); } } }) }

到目前为止,我们就能实现一个简单的从数据到视图的更新了。这里我们先不解析视图中的标签,仅仅是用一个写死的标签,后面我们再完善解析器Compiler。

COPYHTML

<div id="app"> <h2 class="name">{{name}}</h2> </div> <script> function Vue(options){ this.el = options.el; this.data = options.data; this.watcher = {}; // 将obj里所有的属性都进行监听 Object.keys(this.data).forEach(key=>{ this.proxyKeys(key); // 添加代理 }); new Observer(this.data); let exp = 'name', // 绑定key为name的只 ele = document.querySelector(this.el+' .'+exp); // console.log(ele); ele.innerHTML = this.data[exp]; // 将该值更新到视图中 // 为exp属性添加订阅者,回调函数里将最新的值更新到视图中 new Watcher(this, exp, value=>{ ele.innerHTML = value; }) } </script>

具体完整的代码可以查看这个链接: github-index2代码

在代码可以看到,我们在Vue中创建了一个订阅者,这个订阅者的回调函数里,把exp对应的最新值给了ele.innerHTML,这样在name值更新时,会马上更新到视图中。

2.3 解析器Compiler

在上面的代码里,我们没有使用解析器,而是直接获取视图中的元素。不过实际中我们还是要用解析器去解析视图中的指令的。解析器的作用一方面是解析出视图中相关的指令,将数据填充到视图中,另一方面也是添加订阅器,在数据发生更新时,能同步更新到视图中

Vue中的命令非常多,而且也做了很多的兼容,比如双括号中可以进行运算,使用过滤器等。我们这里就简单的解析几个,比如有: v-model, {{}}, v-on。

解析的过程主要分为以下几个步骤:

  1. 把真实DOM元素转换为文档片段;

  2. 遍历文档片段中所有的节点,解析出双括号指令和v-*的指令;

  3. 根据解析出的不同的指令,执行不同的操作;

    • v-model: 初始化数据,添加订阅器,同时添加input事件

    • v-text: 初始化数据,添加订阅器

    • v-on: 为当前node节点添加对应的事件和回调

COPYJAVASCRIPT

function Compiler(vm, el){ this.vm = vm; this.el = document.querySelector(el); this.fragment = null; this.init(); } Compiler.prototype = { init(){ if (this.el) { this.fragment = this.nodeToFragment(this.el); // 文档片段 this.compileElement(this.fragment); // 解析 this.el.appendChild(this.fragment); // 将文档片段添加到视图中 } else { console.log('Dom元素不存在'); } } }

在解析指令的过程中,会频繁的操作DOM,为了防止影响页面的性能,我们首先把DOM元素转换为文档片段:

COPYJAVASCRIPT

// 将DOM节点转为文档片段 nodeToFragment(el){ var fragment = document.createDocumentFragment(); var child = el.firstChild; while (child) { // 将Dom元素移入fragment中 // appendChild: 如果被插入的节点已经存在于当前文档的文档树中 // 则那个节点会首先从原先的位置移除,然后再插入到新的位置. fragment.appendChild(child); child = el.firstChild } return fragment; }

然后我们就是解析文档片段中的指令,这里我们先解析双括号{{}}的指令:

COPYJAVASCRIPT

compileElement(fragment){ var childNodes = fragment.childNodes; [].slice.call(childNodes).forEach(node=>{ var reg = /\{\{(.*)\}\}/; // 双括号 var text = node.textContent; // 节点中的文本值 if (this.isTextNode(node) && reg.test(text)) { // 判断是否是符合这种形式{{}}的指令 // reg.exec(text)[1] 解析出双括号中的属性名 this.compileText(node, reg.exec(text)[1]); } // 继续递归遍历当前节点的子节点 if (node.childNodes && node.childNodes.length) { this.compileElement(node); } }); }, // node 当前的文档节点 // exp 节点中{{}}的属性名 compileText(node, exp) { let val = this.vm[exp]; // 获取到在Vue中data已定义的属性 node.textContent = val; // 将初始化的数据初始化到视图中 // console.log( exp ); new Watcher(this.vm, exp, value=>{ // 生成订阅器并绑定更新函数 node.textContent = value; }); }, isTextNode(node) { return node.nodeType == 3; }

在上面的代码中,我们解析到当前是文本节点且含有双括号后,则将data中对应的属性值更新到视图中,同时添加订阅器,当数据发生变化时则更新到视图中。

在解析v-on的事件指令,v-on的完整指令是v-on:event="fn",因此我们先从DOM中所有的节点里先筛选出v-*的属性,然后再细分是对应的那个指令。在compileElement的forEach中:

COPYJAVASCRIPT

compileElement(fragment){ var childNodes = fragment.childNodes; [].slice.call(childNodes).forEach(node=>{ var reg = /\{\{(.*)\}\}/; // 双括号 var text = node.textContent; // 节点中的文本值 if (this.isElementNode(node)) { // 当前节点为元素节点 // 解析当前元素节点中的attr属性 v-* this.compileAttr(node); } else if (this.isTextNode(node) && reg.test(text)) { // 判断是否是符合这种形式{{}}的指令 // reg.exec(text)[1] 解析出双括号中的属性名 this.compileText(node, reg.exec(text)[1]); } // 继续递归遍历当前节点的子节点 if (node.childNodes && node.childNodes.length) { this.compileElement(node); } }); }, compileAttr(node){ var nodeAttrs = node.attributes; // 获取当前节点上所有的属性 var self = this; // 对这些属性进行遍历,如果是带有v-的属性则再进行细分 [].slice.call(nodeAttrs).forEach(attr => { // console.dir(attr); let attr_name = attr.name; if (/v\-/.test(attr_name)) { // v-* 的指令 let exp = attr.value; let dir = attr_name.substring(2); // 获取v-*后面*的具体值 if (/on\:/.test(dir)) { // 当前为v-on:event类型 // 绑定事件 self.compileEvent(node, self.vm, exp, dir); } else { // v-model self.compileModel(node, self.vm, exp, dir) } // 操作完成后删除当前v-*的属性 node.removeAttribute(attr_name); } }) }, // 添加对应的事件,并绑定回调函数 compileEvent(node, vm, exp, dir) { let eventType = dir.split(':')[1]; let cb = vm.methods && vm.methods[exp]; if (eventType && cb) { node.addEventListener(eventType, cb.bind(vm), false) } else { console.error('Vue no method: ' + exp); } }, isElementNode(node){ return node.nodeType === 1; }

这样在Vue中的methods里定义对应的事件后,在视图里使用on就能绑定上事件:

COPYHTML

<div v-on:click="getNowTime"></div> <script> new Vue({ el: '#app', methods:{ getNowTime(){ return Date.now() } } }) </script>

我们还有一个v-model需要解析,v-model是要把数据同步到视图中,同时在input标签的内容发生变化时反向更新数据。因此我们在初始化视图,添加订阅者的同时,还要添加一个input事件(针对input[type=text]标签):

COPYJAVASCRIPT

// 为model绑定input事件 compileModel(node, vm, exp, dir) { let self = this; let val = this.vm[exp]; node.value = val; new Watcher(this.vm, exp, value => { node.value = value; }); // 添加input标签 node.addEventListener('input', e => { let newVal = e.target.value; if (val == newVal) { return false; } self.vm[exp] = newVal; val = newVal; }, false); }

最后我们初始化一个Vue实例,大功告成,最终版的代码可以点击git-最终代码

COPYHTML

<div id="app"> <h1>{{username}}</h1> <p><input type="text" v-model="username"></p> <p>{{timestamp}}</p> <p><input type="button" value="点击获取当前时间" v-on:click="getNowTime"></p> </div> <script> let app = new Vue({ el: '#app', data: { username: 'wenzi', timestamp: '', }, methods:{ getNowTime: function(){ this.timestamp = (new Date()).toLocaleTimeString(); } } }); </script>

3. 总结

在学习Vue的双向绑定过程中,经常被Observer, Watcher和Compiler之间的关联关系搞混,在通过反复的写代码,梳理他们的关系后,终于搞清楚了。当然,目前也只是实现了基本的功能,更多的功能还需要后续再阅读源码进行完善。

标签:
阅读(645)

公众号:

qrcode

微信公众号:前端小茶馆