一. MVVM
MVVM实际是Model - View - ViewModel的模式,这三个组件也是MVVM的核心:
- Model —— 包含了业务和验证逻辑的数据模型
- View —— 定义屏幕中View的结构,布局和外观
- ViewModel —— 扮演“View”和“Model”之间的使者,帮忙处理 View 的全部业务逻辑
Model和View之间使用ViewMode进行关联,ViewModel负责将Model的数据变化显示在View上,通过将View的改变反馈到Model上。
二. Object.defineProperty()
Vue的数据双向绑定是通过数据劫持结合发布者-订阅者模式的方式来实现的,我们可以先来看一下,如果在控制台输出一个定义在vue初始数据上的对象是个什么东西:
let vm = new Vue({
data: {
obj: {
a: 1
}
},
created: function () {
console.log(this.obj);
}
});
结果如下:

我们可以看到属性a有两个相对应的get() / set()
方法,为什么会多出这两个方法呢?因为Vue是通过 Object.defineProperty()
来实现数据劫持的。
那么Object.defineProperty()
是用来干什么的呢?
可以从这里来看看MDN的官方定义
它说: Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
意思是Object.defineProperty()
可以来控制一个对象属性的一些特有操作,比如读写权、是否可以枚举等等,具体查看文档。
而这里我们研究下它的get() / set()
属性。
按照我们以往的方式,我们获取一个对象的属性可以这样:
var Book = {
name: 'Echo的书'
};
console.log(Book.name); // 输出'Echo的书'
那么如果现在,我们想要在输出的结果中,自动给书加上书名号应该怎么做呢?这时就要用到Object.defineProperty()
的get() / set()
方法了:
var Book = {};
var name = '';
Object.defineProperty(Book, 'name', {
set: function(value){
name = value;
console.log('这本书的名字叫做:' + name);
},
get: function(value){
return '《' + name + '》';
}
})
Book.name = 'Echo的书📖';
console.log(Book.name);
结果如下:

我们通过Object.defineProperty()
设置了对象Book的name属性,对其get和set进行重写操作,顾名思义:
- get就是在读取name属性这个值触发的函数
- set就是在设置name属性这个值触发的函数
所以当执行 Book.name = 'Echo的书📖' 这个语句时,控制台会打印出 "这本书的名字叫做:Echo的书📖",紧接着,当读取这个属性时,就会输出 "《Echo的书📖'》",因为我们在get函数里面对该值做了加工了。
那我们现在来输出一下Book,看下结果会是什么呢?

有没有发现它的结构和我们最开始打印的vue初始数据的结构非常相似,这说明vue确实是通过这种方法来进行数据劫持的。
好了,基础知识我们已经了解完了,那么问题是,MVVM的双向绑定究竟是如何实现的呢?
三. 双向绑定的实现
实现MVVM主要包括两个方面:
- 视图变化更新数据
- 数据变化更新视图

数据变化更新视图的重点在于:如何知道数据更新了,其实在我们之前就已经了解过了,就是Object.defineProperty()
。
通过Object.defineProperty()
对属性设置一个set函数,当数据改变了就会来触发这个函数,所以我们只要将一些需要更新的方法放在这里面就可以实现data更新view了。

四. 实现过程
我们已经知道了,数据的双向绑定的实现,首先就是需要对数据进行劫持监听。所以我们需要设置一个监听器Observer()
, 用来监听所有属性,如果这些属性中有发生变化的了,就需要告诉订阅者 Watcher()
是否要更新视图,因为订阅者是有很多个的,所以我们需要有个集中的消息订阅者 Dep
来管理这些订阅者。然后在监听器和订阅者之间进行统一的管理。
接着,我们还需要有一个指令解析器Compile,对每个节点元素进行扫描和解析,将相关指令对应初始化成一个订阅者Watcher()
,并替换模板数据或者绑定相应的函数,此时当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。
因此接下去我们执行以下3个步骤,实现数据的双向绑定:
- 实现一个监听器Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。
- 实现一个订阅者Watcher,可以收到属性的变化通知并执行相应的函数,从而更新视图。
- 实现一个解析器Compile,可以扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。
流程图如下所示:

1. 实现一个监听器Observer()
Observer()
是一个数据监听器,其核心的方法就是我们上面所说的 Object.defineProperty()
, 如果要对所有的属性都进行监听的话,那么可以通过递归的方法遍历所有属性,并对其进行Object.defineProperty()
处理,具体实现如下所示:
// 遍历函数
function observer(datas){
if(!datas || typeof datas !=='object'){
return;
}
Object.keys(datas).forEach((key)=>{
defineReactive(datas, key, datas[key]);
});
}
function defineReactive(datas, key, val){
observer(val);
Object.defineProperty(datas, key, {
enumerable: true,
configurable: true,
set(newVal){
val = newVal;
console.log(`属性${key}被监听了,它的新值为${newVal.toString()}`);
},
get(){
return val;
}
});
}
let datas = {
book: {
name: 'Echo的书',
author: 'Echo',
buyInfos: {
money: '¥99'
}
},
pushlishTime: '2020-01-01'
};
observer(datas);
datas.book.name = 'Echo新买的书';
datas.book.buyInfos.money = '¥109';
这样就实现了一个简单的监听器啦~
接下来,我们按照思路,需要创建一个可以容纳订阅者的消息订阅器Dep
,订阅器Dep
主要负责收集订阅者Watcher()
,然后再属性变化的时候执行对应订阅者的更新函数。所以显然订阅器需要有一个容器,这个容器就是list,将上面的Observer()
稍微改造下,植入消息订阅器: