原生js实现一个简单的vue的数据双向绑定
vue是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()
来劫持各个属性的setter
,getter
,在数据变动时发布消息给订阅者,触发相应的监听回调
所以我们要先做好下面3步:
1.实现一个监听器Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。
2.实现一个订阅者Watcher,可以收到属性的变化通知并执行相应的函数,从而更新视图。
3.实现一个解析器Compile,可以扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。
1.实现一个Observer
Observer是一个数据监听器,主要依赖于Object.defineProperty()方法,而这个方法在ie8及以下存在兼容问题,请看(MDN defineProperty)所以如vue官网所说:
兼容性
Vue 不支持 IE8 及以下版本,因为 Vue 使用了 IE8 无法模拟的 ECMAScript 5 特性。但它支持所有(兼容 ECMAScript 5 的浏览器。)
正因为这个方法,我们就可以利用Obeject.defineProperty()
来监听属性变动 那么就可以把需要observer的数据对象进行递归遍历,给他的每个属性都可以加上get,set。
而当给这个对象的某个值赋值操作的时候,就会触发setter
,那么就能监听到了数据变化。
function observer(data) { // 当不是对象的时候,退出 if (!data || typeof data !== 'object') { return; } // 取出所有属性遍历 Object.keys(data).forEach(function(key) { // 给每个属性加上get,set defineReactive(data, key, data[key]); }); }; function defineReactive(data, key, val) { observer(val); // 监听子属性 Object.defineProperty(data, key, { enumerable: true, // 可遍历 configurable: false, // 不能修改,删除 get: function() { return val; }, set: function(newVal) { val = newVal; console.log("已改变 "+newVal); } }); }
测试一下:
var obj={ name:'aaaaa', book:{ name:'bbbbbb' } } observer(obj) obj.name='cc' //已改变 cc obj.book.name='dd' //已改变 dd
现在已经监听了数据中的属性变化了,而接下来就是在代码中加入发布者-订阅者模式,关于这个设计模式不懂的可以看这里(js设计模式-发布订阅模式)
首先我们先创造一个订阅器Dep,他是用来收集订阅者Watcher的,然后在属性发生变化的时候执行对应订阅者的更新函数update 。
在上面的 defineReactive 函数最后加入下面代码:
function Dep() { this.subs = [];//存放消息数组 } Dep.prototype = { addSub: function(sub) { //增加订阅者函数 this.subs.push(sub); }, notify: function() { //发布消息函数 this.subs.forEach(function(sub) { sub.update(); //这里是订阅者的更新方法 }); } };
然后修改上面的 defineReactive 函数:
function defineReactive(data, key, val) { var dep = new Dep(); //实例化一个订阅器 observer(val); // 监听子属性 Object.defineProperty(data, key, { enumerable: true, // 可遍历 configurable: false, // 不能修改,删除 get: function() { return val; }, set: function(newVal) { if (val === newVal){return} //当前后数值相等,不做改变 val = newVal; dep.notify(); //当前后数值变化,这时就通知订阅者了 console.log("已改变 "+newVal); } }); }
在vue里面针对data这个对象的处理,区分了数组和对象,我们这只考虑对象这一种情况,更多(vue-数组处理)正由于这种处理,所以vue对于:
由于 JavaScript 的限制,Vue 不能检测以下变动的数组:
- 当你利用索引直接设置一个项时,例如:
vm.items[indexOfItem] = newValue
- 当你修改数组的长度时,例如:
vm.items.length = newLength
这种情况,设计了 vm.$set
这个实例方法来弥补这方面限制带来的不便。
2.实现Watcher
在第一步我们实现了订阅器,这一步实现订阅者,从上面代码看,在dep 调用 notify() 方法的时候,我们就该去遍历订阅者Watcher了,并且调用他自己的update()方法,
先实现订阅者对象,如下:
function Watcher (vm, exp, cb){ this.cb = cb; this.vm = vm; this.exp = exp; this.value = this.get();//初始化的时候就调用 } Watcher.prototype={ // 只有在订阅者Watcher初始化的时候才需要添加订阅者 get:function(){ Dep.target = this; // 在Dep.target缓存下订阅者 var value = this.vm.data[this.exp] // 强制执行监听器里的get函数 Dep.target = null; // 释放订阅者 return value; }, // dep.subs[i].notify() 会执行到这里 update:function() { this.run(); }, run:function() { // 执行 get()获得value ,call更改cb的this指向 。 var value = this.vm.data[this.exp]; var oldVal = this.value; if (value !== oldVal) { this.value = value; this.cb.call(this.vm, value, oldVal); } } }
而在这个时候,我们需要通过Dep的addSub(),将Watcher加进去,在Watcher的构造函数中会调用这句话
this.value = this.get();//初始化的时候就调用
所以我们可以在 defineReactive 函数中做个调整,修改一下Object.defineProperty的get要调用的函数,来对应Watcher构造函数的这个get函数。
而在这里需要判断一下:是不是Watcher的构造函数在调用,如果是,说明他就是这个属性的订阅者。
这里就会用到 Dep.target 这样一个全局唯一的变量,用来判断。代码如下:
function defineReactive(data, key, val) { var dep = new Dep(); //实例化一个订阅器 observer(val); // 监听子属性 Object.defineProperty(data, key, { enumerable: true, // 可遍历 configurable: false, // 不能修改,删除 get: function() { // 如果这个属性存在,说明这是watch 引起的 if(Dep.target){ // 那我调用dep.addSub把这个订阅者加入订阅器里面 dep.addSub(Dep.target) } return val; }, set: function(newVal) { if (val === newVal){return} //当前后数值相等,不做改变 val = newVal; dep.notify(); //当前后数值变化,这时就通知订阅者了 console.log("已改变 "+newVal); } }); }
最后在Dep函数后面加上:
Dep.target = null;//释放每一个订阅者
写到这里我们可以写个简单的例子来测试一下:
先写个函数将我们的Observer和Watcher关联起来:
function pvp(data,el,exp){ this.data=data; observer(data); //给data每个属性加上get,set el.innerHTML=this.data[exp]; //粗暴的直接绑定,测试一下效果 new Watcher(this,exp,function(val){ el.innerHTML = val; //调用Watcher直接赋值 }) return this }
在html中写下:
<h1 id="name">{{name}}</h1>
js中写下:
var ele = document.querySelector('#app'); var pvp = new pvp( {test: 'hello pvp'}, ele, 'test'
);
结果如下:
好了,下面我们来实现对dom节点的解析,可以让pvp写起来更像vue的写法
3.实现Compile
解析模板,首先需要获取到dom元素,然后对含有dom元素上含有指令的节点进行处理,这个环节需要对dom操作比较频繁,
所以可以用一个通用的办法,先建一个fragment片段,将需要解析的dom节点存入fragment片段里再进行处理。请看(空文档对象)
//创造一个空白节点 nodeToFragment: function (el) { var fragment = document.createDocumentFragment(); var child = el.firstChild; while (child) { // 将Dom每个元素都移入fragment中 fragment.appendChild(child); child = el.firstChild; } return fragment; },
而在这里我们需要实现一个 Compile 方法,代码如下:
function Compile(el, vm) { this.vm = vm; this.el = document.querySelector(el); this.fragment = null; this.init(); //初始化一个方法,直接调用解析节点 } Compile.prototype = { init: function () { if (this.el) { this.fragment = this.nodeToFragment(this.el); //调用了上面的方法,把元素放入并返回 this.compileElement(this.fragment);//对这个里面的元素解析 this.el.appendChild(this.fragment);//再重新放回去 } else { console.error("找不到节点") } }, //创造一个空白节点 nodeToFragment: function (el) { var fragment = document.createDocumentFragment(); var child = el.firstChild; while (child) { // 将Dom每个元素都移入fragment中 fragment.appendChild(child); child = el.firstChild; } return fragment; }, // 解析节点 compileElement: function (el) { var childNodes = el.childNodes; var self = this; [].slice.call(childNodes).forEach(function(node) { var reg = /\{\{(.*)\}\}/; var text = node.textContent; if (node.nodeType == 1) { //如果是元素节点 self.compileFirst(node); } else if (node.nodeType == 3 && reg.test(text)) { //如果是文本节点 self.compileText(node, reg.exec(text)[1]); } if (node.childNodes && node.childNodes.length) { //如果下面还有子节点,继续循环 self.compileElement(node); } }); }, //如果是元素节点 compileFirst: function(node) { var nodeAttrs = node.attributes; var self = this; Array.prototype.forEach.call(nodeAttrs, function(attr) { var attrName = attr.name; var exp = attr.value; if (attrName='p-model') { //当这个属性为p-model的时候就解析model self.compileModel(node, self.vm, exp); } }); }, //如果是文本节点 compileText: function(node, exp) { var self = this; var initText = this.vm[exp]; this.updateText(node, initText); new Watcher(this.vm, exp, function (value) { self.updateText(node, value);//通知Watcher,开始订阅 }); }, //解析p-model compileModel: function (node, vm, exp) { var self = this; var val = this.vm[exp]; this.modelUpdater(node, val); new Watcher(this.vm, exp, function (value) { self.modelUpdater(node, value); //通知Watcher,开始订阅 }); node.addEventListener('input', function(e) { var newValue = e.target.value; if (val === newValue) { return; } self.vm[exp] = newValue; val = newValue; }); }, updateText: function (node, value) {
//这里是直接替换文本节点 node.textContent = typeof value == 'undefined' ? '' : value; }, modelUpdater: function(node, value, oldValue) { //如果不存在就返回空,这里是更新model node.value = typeof value == 'undefined' ? '' : value; } }
然后我们在对上面的关联函数 pvp 进行修改主要是对 data 用 defineProperty 方法进行再一次封装,方便我们使用 xx.data 的写法去调用属性,在对这个函数进行修改,代码如下:
function Pvp(options){ var self = this; this.data=options.data; Object.keys(this.data).forEach(function(key) { self.proxyKeys(key); }); observer(this.data); //给data每个属性加上get,set new Compile(options.el,this) return this } Pvp.prototype = { proxyKeys: function (key) { var self = this; Object.defineProperty(this, key, { enumerable: false, configurable: true, get: function getter () { return self.data[key]; }, set: function setter (newVal) { self.data[key] = newVal; } }); } }
最后在我们的html的script中写下:
var src=new Pvp({ el: '#app', data: { test: 'hello world', } });
打完收工!!!! 最后点(源码)获取源码