此文实现一个mvvm模式,并且讲解一下vue的实现原理
MVVM
定义:双向数据绑定
特点:数据影响视图,视图影响数据
angular主要靠的是脏值检测实现双向数据绑定,vue靠的是数据劫持+发布订阅模式
vue不兼容ie8以下的版本,因为vue核心实现的mvvm模式用的是 Object.defineProperty
· Object.defineProperty
它可以给对象定义属性
首先,我们定义一个空对象,然后在该对象中定义一个name属性,代码如下:
let obj={}
Object.defineProperty(obj,'name',{
value:'zhangsan'
})
结果,可知,name属性已经定义完成,现在我想删除这个属性。
delete obj.name;
但是查看结果的时候,发现它并未删掉
以上可知,通过Object.defineProperty的方式定义对象属性,并不能直接用delete的方式删除,而是需要配置:configurable(默认false,是否可删除)。
let obj={}
Object.defineProperty(obj,'name',{
configurable:true,//默认false,是否可删除
value:'zhangsan'
})
这样就可以删除了,同样的,可修改,可枚举(可遍历)也需要配置特有属性。
let obj={}
Object.defineProperty(obj,'name',{
configurable:true,//默认false,是否可删除
writable:true,//默认false,是否可修改
enumerable:true,//默认false,是否可枚举(是否可遍历)
value:'zhangsan',
})
在实际中,value一般会被分成两个部分:get、set,这也就是我们常说的getter和setter。
注意: 有了get和set,就不能用writable和value,否则会报错。
let obj = {}
Object.defineProperty(obj, 'name', {
configurable: true,//默认false,是否可删除
enumerable: true,//默认false,是否可枚举(是否可遍历)
get() {
return 'zhangsan'
},
set(val) {
console.log(val)
}
})
从结果中,我们可知,当获取obj中的name属性值的时候,调用get方法;当改变name属性值的时候,调用set方法。
· 数据劫持
定义:通俗的讲,就是用Object.defineProperty来定义我们的所有属性
let vue =new Vue({
el:'#app',
data:{a:1}
})
这段代码,大家应该都熟悉,其中的data对象中的每一个属性,我们需要用Object.defineProperty重新定义。
现在呢,我们自己实现一下这个功能。
首先我们先创建一个mvvm.js文件,专门写这些功能。
html部分
<div id="app">
{{a}}
</div>
<script src="mvvm.js"></script>
<script>
let vueDemo = new VueDemo({
el: '#app',
data: { a: 1 }
})
</script>
mvvm.js部分
function VueDemo(options = {}) {
this.$options = options;//将所有属性挂载到$options(仿vue)
var data = this._data = this.$options.data;
observer(data)
}
// 观察对象给对象增加Object.defineProperty
function observer(data) {
if(typeof data !=='object') return;
return new Observer(data)
}
// Observer构造,这里我们写主要逻辑
function Observer(data) {
// 通过Object.defineProperty给data定义属性
for (let key in data) {//遍历
let val = data[key];
Object.defineProperty(data, key, {
enumerable: true,//可枚举
get() {
return val
},
set(newVal) { //更改值的时候
if (newVal === val) { //如果设置的值跟以前一样,我们就忽视它
return;
}
val = newVal; //如果以后再获取值的时候,将设置的新值再丢回去
}
})
}
}
现在看一下结果:
结果可知,data中的a属性已经被Object.defineProperty成功定义,也可以修改和获取它,但是在实际项目中,属性a很有可能是这种形式:a:{b:2,{c:3}}。
比如,我给属性a赋值{b:1}
结果看出,它是没有get和set方法的,所以我们需要在赋值的时候再执行observe方法,让里面的属性再次用Object.defineProperty定义,同样,获取的时候也需要调用。
在实际vue操作中,我们获取data的属性,只需要this.属性名即可,实现这个功能,同样,我们只要在this中用Object.defineProperty定义data中所有的属性即可。
代码如下:
for (let key in data) {
Object.defineProperty(this, key, {
enumerable: true,
get() {
return this._data[key]
},
set(newVal) {
this._data[key] = newVal
}
})
}
这样我们就实现了。
· 编译
现在,数据我们已经拿到了,那如何显示在页面中呢?
先将html变复杂一点
<div id="app">
<p>a中a的值:{{a.a}}</p>
<div>b的值{{b}}</div>
</div>
<script src="mvvm.js"></script>
<script>
let vueDemo = new VueDemo({
el: '#app',
data: {
a: {
a:1
},
b:2
}
})
</script>
思路:
1.先获取div#app下的所有子节点,比如<p/>、<div/>
2.然后,获取<p/>、<div/>的子节点,如果子节点是文本节点而且里面有{{}},那么,通过textContent获取里面的内容,然后通过正则将{{}}替换成响应的数据;如果不是元素节点,就判断是否有子节点,如果有,再获取其内的子节点进行遍历(递归),直到是文本节点为止。
// 编译
function Compile(el, vm) {//el:替换的范围
vm.$el = document.querySelector(el);//获取el
let reg = /{{(.*)}}/;//用于后面检测{{}}
//文档碎片,在内存,不占dom
let fragment = document.createDocumentFragment();
//然后将每一个dom节点塞到文档碎片中(内存中),fragment.append具有移动节点的作用
var child;
while (child = vm.$el.firstChild) {
fragment.append(child);
}
replace(fragment)
function replace(fragment) {
// 需要遍历fragment中的子节点,注意:childNodes是类数组,需要Array.from转换一下
Array.from(fragment.childNodes).forEach(node => {
// 获取里面的内容
var text = node.textContent;
// 如果是元素节点而且有{{}}
if (node.nodeType == 3 && reg.test(text)) {
//console.log(RegExp.$1) //取到了vm.a.a / vm.b
let arr = RegExp.$1.split('.');
let val = vm;
arr.forEach(k=>{
val = val[k];
})
node.textContent = text.replace(/{{(.*)}}/,val);
}
if (node.childNodes) {
replace(node)
}
})
}
vm.$el.appendChild(fragment);
}
编译完成以后,数据渲染到了页面当中。
但是当我们修改数据的时候,修改成功后,数据并未更新到页面上,这需要我们将observe(数据劫持)和compile(编译)结合起来,需要用发布-订阅模式。
· 发布-订阅模式
先有订阅 再有发布
思路:现在有一个方法(watcher)可以帮我们订阅一些事件(是函数),这些事件放在一个数组里面([fn1,fn2,fn3]),当我们发布的时候,只需要遍历这些数组,依次执行。
订阅:就是往数组里面放函数
发布:就是讲数组中的函数依次执行
现在我们实现一个发布-订阅方法
先定义一个函数Dep,让订阅的函数都存在Dep的数组中,并定义两个方法:addSub是往数组中添加函数,notify是依次执行函数。
function Dep() {
this.subs = [];
}
Dep.prototype.addSub = function (sub) {//订阅,sub是个函数(事件)
this.subs.push(sub)
}
// 调用的时候,事件依次执行,notify:通知
Dep.prototype.notify = function () {
this.subs.forEach(sub => sub.update());
}
然后,写一个订阅的方法:watcher,它的作用是给每个传入进来的函数,添加一个属性方法(update),而这个属性方法可以执行传进来的函数。
特定的,给每一个函数设定一个update属性方法。
// 订阅,通过这个类Watcher ,通过这个类创建的实例都拥有update方法
function Watcher(fn) {
this.fn = fn;
}
Watcher.prototype.update = function () {
this.fn()
}
let watcher = new Watcher(function () {
console.log(1)
});
实例化Dep,实现发布--订阅
let dep = new Dep();
dep.addSub(watcher) //订阅
dep.addSub(watcher) //订阅
dep.addSub(watcher) //订阅
dep.notify(); // 发布 , 结果是 1 1 1
以上,通过dep.addSub实现将函数保存到数组subs中(订阅),通过notify依次执行subs中的函数(发布),实现了发布--订阅
现在,我们就看一下如何用发布--订阅模式,来实现数据的关联。
思路:当数据改变了,我们需要刷新视图,所以我们要找到编译方法中,将数据替换到节点中的部分。
代码如下:
...
<!-- 这个地方添加逻辑-->
node.textContent = text.replace(/{{(.*)}}/,val);
...
在替换内容之前,我们需要订阅一下,数据一变,我们直接再执行替换内容这个操作就行了。
// watcher
new Watcher(vm, RegExp.$1, function(newVal){ //数据一变,我们需要接受一个新值
node.textContent = text.replace(/{{(.*)}}/,newVal);
}
我们需要重新构建一下watcher
// 订阅,通过这个类Watcher ,通过这个类创建的实例都拥有update方法
function Watcher(vm,exp,fn) {
this.vm=vm;
this.exp=exp;
this.fn = fn; //我们要把watcher添加到订阅中
Dep.target = this;
let val =vm;
let arr = exp.split('.');
arr.forEach(k=>{ //这个操作就是取值:this.a.a,会调用get方法
val = val[k]
})
Dep.target=null;
}
Watcher.prototype.update = function () {
// 我们在这获取值
let val =this.vm;
let arr = this.exp.split('.');
arr.forEach(k=>{
//这个操作就是取值:this.a.a,会调用get方法,如果改变了数据,那就会到得到最新值
val = val[k]
})
this.fn(val);将最新值当做参数,去执行new Watcher中的函数,然后刷新视图
}
当执行get获取值的时候,把this订阅到Dep方法的数组中,然后改变数据的时候,执行dep.notify()执行发布。
代码如图:
· 实现双向绑定(v-model)
html部分
<div id="app">
<p>a中a的值:{{a.a}}</p>
<div>b的值{{b}}</div>
<input type="text" v-model="b">
</div>
思路:首先判断,元素节点中如果带有v-model属性,我就将相对应的值赋给它。
上面我们只判断是否为文本节点,现在我们需要判断元素节点。
代码如下:
// v-model
if(node.nodeType==1){
let nodeAttrs = node.attributes;//获取dom节点中的属性
Array.from(nodeAttrs).forEach(attr=>{
let name = attr.name;
let value = attr.value;
if(name.indexOf('v-model') !== -1){
node.value = vm[value]; //将值显然在input中
}
// 订阅一下
new Watcher(vm,value,function(newVal){
node.value = newVal; //当watcher触发时会自动将内容放到输入框中
})
node.addEventListener('input',e=>{
let newVal = e.target.value;
vm[value] = newVal;
})
})
}
· 实现computed
html部分
computed:{
hello(){
return Number(this.b) + Number(this.c)
}
}
//在data中定义一个属性c:20
//页面中添加{{hello}}
思路:
首先获取computed中的key,也就是方法名,然后将它通过Object.defineProperty定义到实例上。
注意:computed中也有get和set属性,所以需要判断。
function initComputed(){
let vm = this;
let computed = this.$options.computed;
console.log(Object.keys(computed))
Object.keys(computed).forEach(key=>{
Object.defineProperty(vm,key,{
get:typeof computed[key] === 'function' ? computed[key] : computed[key].get,
set:{}
})
})
}
然后将initComputed方法加载到VueDemo构造函数中。
initComputed.call(this);
到此,功能完全实现,完整代码我发布到了github上,大家可以查看。
https://github.com/JinGuiMin/vue__mvvm__demogithub.com