手写VUE mvvm双向数据绑定
当你打开这篇文章时,你肯定已经使用过vue,当你改变数据时,与之绑定的UI自动更新,当你触发一些表单元素时,与之绑定的数据也会自动更新。我刚开始学vue的时候对vue的双向数据绑定很好奇,所以今天我给大家实现一个简单的vue。
首先,你得明白为什么我们要使用双向数据绑定,在没有什么mvc,mvvm之前,当数据改变,我们总是需要手动通过id class等方式找到我们的DOM,手动的调用什么inner Text,setAtrribute,addClass等去更新DOM的各种属性,样式,文本等等,这样做有两个问题,第一:程序员把太多精力放在UI更新上,也就是数据和UI的同步上。第二:页面数据的维护也比较困难。如果程序的结构不好,逻辑再复杂点,你会发现程序写不下去了。第三:UI和js代码耦合度太高。对上面这些痛点有所体会的话,能帮助你更好的理解我们实现vue的mvvm究竟要干些什么事,是怎样提高程序员的开发效率的!
先给大家上一个图,这是我在vue官网上截的
这里Data数据源都是响应式的,也就是说用Object.defineProperty定义了set和get,这样可以对数据源进行劫持,每当你set的时候你就能调用notify通知所有的Watcher,每个watcher会有一个update方法更新UI,这里上图中是更新(在内存中计算)虚拟DOM,再由虚拟DOM更新真实的DOM。但是我的要写的vue例子中是在watcher的中update中保留了真实DOM的引用,以实现更新,并没有用到虚拟dom,那么还有一个问题就是,怎么根据模板生成watcher,又怎么把watcher添加到他所观察的数据的闭包环境中的。下面我们先看看我们要实现的最终效果。
就是当我在输入框输入文本的时候,下面能够同步,并且当我改变一个isshow值,圆相应隐藏或显示。
一 、实现Observer,实现可响应数据
function observe(data) {
if (!data || typeof data !== 'object') { //这里包括数组和对象 typeof [] === 'object' 为true
return;
}
Object.keys(data).forEach((key) => {
defineReactive(data, key, data[key]);
});
}
function defineReactive(obj, key, value) {
observe(value); //递归监听 如果属性的值为对象 则递归监听
Object.defineProperty(obj, key, {
configurable: false, //不能再define
enumerable: true, //可枚举
set: function (newValue) {
if (newValue == value) return;
value = newValue;
console.log("不好,有人要改变我的值....");
},
get: function () {
console.log("嘿,你触发我的取值器");
return value;
}
})
}
var data = {name: 'kitty'};
observe(data);
data.name = 'wangwang'; // 不好,有人要改变我的值....
这样我们的data对象就是可观测的了,这里每次调用defineReactive实现对对象某属性进行观测时,要注意如果此属性的值还是一个对象或者数组,那么需要继续递归处理,直到对象属性是一个基本类型停止。那么问题又来了,如果属性是一个数组,以上代码能实现对于数组的每个元素进行监听,但是我怎么实现对数组push pop splice等方法也进行监听,这样当使用这些方法时,也在我们的监听管辖范围之内。function defineReactive(obj, key, value) {
observe(value); //递归监听 如果属性的值为对象 则递归监听
if (value instanceof Array) {
//对该数组的push pop splice shift等等可以改变数组的方法进行装饰 并挂载到数组实例上
["push", "pop", "shift", "unfift", "splice"].forEach((method) => {
// let beforeDecorateMethod = Array.prototype[method];
value[method] = function (prop) {
let result = Array.prototype[method].call(value, prop)
//在这里 你可以插入你的代码 这样你每次push pop splice..的时候就能执行你的代码
return result;
}
})
}
Object.defineProperty(obj, key, {
configurable: false, //不能再define
enumerable: true, //可枚举
set: function (newValue) {
if (newValue == value) return;
value = newValue;
},
get: function () {
return value;
}
})
}
原理很简单,就是先获得数组原型上对应的方法,然后对其进行改造(装饰),然后再把装饰后的同名方法挂载到数组*上,这样你通过arr.push获得的方法就是你装饰后的push方法了,其原因就是访问对象方法或者属性时,会先在对象本身上找,找不到才会去__proto__原型对象上找。这样,你就能对这些改变数组方法也进行监听。。function defineReactive(obj, key, value) {
var dep = new Dep();
observe(value); //递归监听 如果属性的值为对象 则递归监听
if (value instanceof Array) {
//对该数组的push pop splice shift等等可以改变数组的方法进行装饰 并挂载到数组实例上
["push", "pop", "shift", "unfift", "splice"].forEach((method) => {
// let beforeDecorateMethod = Array.prototype[method];
value[method] = function (prop) {
let result = Array.prototype[method].call(value, prop)
dep.notify();
return result;
}
})
}
Object.defineProperty(obj, key, {
configurable: false, //不能再define
enumerable: true, //可枚举
set: function (newValue) {
if (newValue == value) return;
value = newValue;
dep.notify();
},
get: function () {
return value;
}
})
}
显然,Dep对象上应该有一个观察者集合,并且和一个notify通知方法,在这个方法里遍历所有的watcher ,依次触发watcher的update方法。Dep的实现如下function Dep() {
this.subs = [];
}
Dep.prototype = {
addWatcher: function (watcher) {
this.subs.push(watcher);
},
notify: function () {
this.subs.forEach((watcher) => {
watcher.update();
})
}
}
那么问题又来了,subs数组里保存的watcher是怎么添加进去的???而且dep对象又在一个闭包环境里面,而watcher又只能是编译模板时生成的,也就是在闭包外面生成的,所以我现在希望,当new Watcher的时候他能自己把自己添加到dep的subs数组中,听起来挺不可思议的。但是你想啊,这可以通过全局变量传递watcher对象呀!,因为set是用来通知的,我们只能在get方法上做文章了,用get来收集watcher。是不是豁然开朗。下面的是代码。 get: function () {
if (Dep.target) {
dep.addWatcher(Dep.target);
}
return value;
}
function Wathcer(exp, vm, callback) {
this.exp = exp;
this.vm = vm;
this.callback = callback;
Dep.target = this;
this.get();
Dep.target = null;
this.callback(this.value); //初始化试图
}
重点看Watcher的加粗部分代码,是不是想明白了!!!二、实现Compile,编译模板,初始化页面
<div id="app">
<input v-model="message"/>
<p v-bind:class="style">您输入的内容是{{message}} {{message}} </p>
<div v-bind:class="style2" v-show="isShow"></div>
</div>
//Compile对象做的事情 解析el所有子元素的所有指令 初始化视图 创建Watcher 并绑定update函数 watcher会把自己加到相应的dep订阅器中
function Compile(el, vm) {
this.$vm = vm;
this.$el = document.querySelector(el);
this.$fragment = this.elementToFragment(this.$el); //劫持el所有子元素 转化为fragment文档碎片 以免频繁在真实DOM树上读写 以提高性能
this.init();
this.$el.appendChild(this.$fragment);
}
Compile.prototype = {
elementToFragment: function (el) {
var container = document.createDocumentFragment();
var child;
while (child = el.firstChild) {
container.appendChild(child);
}
return container;
},
init: function () {
this.compileElement(this.$fragment);
},
......
}
compileElement: function (el) {
var childNodes = el.childNodes, vm = this.$vm;
[].slice.call(childNodes).forEach((node) => {
var text = node.textContent;
var reg = /\{\{(.*)\}\}/; // 表达式文本
if (node.nodeType == 1) { //普通标签
this.compileAttrs(node);
} else if (node.nodeType == 3 && reg.test(text)) {//文本节点 #text
this.compileText(node);
}
if (node.childNodes && node.childNodes.length > 0) {
this.compileElement(node); //递归调用
}
})
}
在这里分了两种情况,属性编译,和文本节点编译,最后,如果元素还有子元素就继续递归调用compileElement,如此,就可以保证所有的节点上的v-属性和包含{{}}的文本都可以被编译处理。
Compile.prototype = {
.....省略
compileText: function (node) { //当然这里需要匹配所有的{{exp}} 为每个不同的exp生成一个Watcher
var text = node.textContent;
var reg = /\{\{([a-z|1-9|_]+)\}\}/g;
reg.test(text);
var exp = RegExp.$1;
new Wathcer(exp, this.$vm, function (value) {
node.textContent = text.replace(reg, value);
});
},
compileAttrs: function (node) {
var complieUtils = this.complieUtils;
var attrs = node.attributes, me = this;
[].slice.call(attrs).forEach(function (attr) {
if (me.isDirective(attr)) {
var dir = attr.name.substring(2).split(':')[0];
var exp = attr.value;
complieUtils[dir + '_compile'].call(me, node, attr, exp);
}
})
},
isDirective: function (attr) { //通过name value获取属性的键值
return /v-*/.test(attr.name); //判断属性名是否以v-开头
},
complieUtils: {
model_compile: function (node, attr, exp) {
node.addEventListener("keyup", (e) => {
this.$vm.$data[exp] = e.target.value;
});
node.removeAttribute(attr.name);
new Wathcer(exp, this.$vm, function (value) {
node.value = value;
});
},
bind_compile: function (node, attr, exp) {
var attribute = attr.name.split(':')[1];
node.removeAttribute(attr.name);
new Wathcer(exp, this.$vm, function (value) {
node.setAttribute(attribute, value);
});
},
show_compile: function (node, attr, exp) {
node.removeAttribute(attr.name);
new Wathcer(exp, this.$vm, function (value) {
node.style.visibility = value ? 'visible' : 'hidden';
});
}
}
这里我添加了complieUtils对象,如果是v-text指令,就会使用调用text_compile,如果是v-bind指令,就会调用bind_compile函数,这样设计的目的是如果你想增加vue里面的指令,只需要扩展compileUtils这个对象即可~甚至你还可以给用户提供自定义属性指令的接口,然后本质是往complieUtils里面添加新的函数。
还值得一提的是Watcher对象,你在创建这个对象时需要给它传递一个callback,也就是更新时调用的函数。这里callback是个闭包,保留了对DOM的引用,以实现更新。
接下来看看Watcher
三 、实现Watcher,实现数据更新UI
function Wathcer(exp, vm, callback) {
this.exp = exp;
this.vm = vm;
this.callback = callback;
Dep.target = this;
this.get();
Dep.target = null;
this.callback(this.value); //初始化试图
}
Wathcer.prototype = {
get: function () {
this.value = this.vm.$data[this.exp];
},
update: function () {
this.get(); //先获得value值
this.callback(this.value);
}
}
Dep.target是一个桥梁,用来传递wather实例,在调用Watcher的构造函数时,会把自己赋值给Dep.target,然后触发对应数据的get,在get方法里会把该watcher添加到观察者集合里,最后别忘了将Dep.target置成null.Wacher的更新函数里面会执行在编译阶段传递过来的callback。如果这里思路有点乱的话,再回顾下一下代码。 Object.defineProperty(obj, key, {
configurable: false, //不能再define
enumerable: true, //可枚举
set: function (newValue) {
if (newValue == value) return;
value = newValue;
dep.notify();
},
get: function () {
if (Dep.target) {
dep.addWatcher(Dep.target);
}
return value;
}
})
Dep.prototype = {
addWatcher: function (watcher) {
this.subs.push(watcher);
},
notify: function () {
this.subs.forEach((watcher) => {
watcher.update();
})
}
}
看一下加粗的地方,理一理,就明白了。四 、实现MVVM,封装Vue对象
function Vue(options) {
this.$options = options;
var data = options.data;
this.$data = data;
this.$el = options.el;
observe(data); //劫持监听data所有属性
this.$compile = new Compile(this.$el, this) //模板解析
}
这样我们的一个简单的vue就实现了,当然这真的只是一个简单的vue mvvm的双向数据绑定,很多功能是不完善的~~~不过这个思路是挺棒的~上一篇: vue数据双向绑定原理及简单实现