什么是MVVM模式?
MVVM 由 Model,View,ViewModel 三部分构成,Model 层代表数据模型,也可以在Model中定义数据修改和操作的业务逻辑;View 代表UI 组件,它负责将数据模型转化成UI 展现出来,ViewModel 是一个同步View 和 Model的对象。
在MVVM架构下,View 和 Model 之间并没有直接的联系,而是通过ViewModel进行交互,Model 和 ViewModel 之间的交互是双向的, 因此View 数据的变化会同步到Model中,而Model 数据的变化也会立即反应到View 上。
ViewModel 通过双向数据绑定把 View 层和 Model 层连接了起来,而View 和 Model 之间的同步工作完全是自动的,无需人为干涉,因此开发者只需关注业务逻辑,不需要手动操作DOM, 不需要关注数据状态的同步问题,复杂的数据状态维护完全由 MVVM 来统一管理。

MVVM的流程实现
在 Vue 的 MVVM 设计中,假设我们自己模拟写一个类似new Vue()的构造函数,名字叫MVVM,其流程分析参考下图:

Observer 数据监听器,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者,内部采用Object.defineProperty的getter和setter来实现。
Compile 指令解析器,它的作用对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数。
Watcher 订阅者, 作为连接 Observer 和 Compile 的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数。
Dep 消息订阅器,内部维护了一个数组,用来收集订阅者(Watcher),数据变动触发notify 函数,再调用订阅者的 update 方法。
根据流程图我们分别构建mvvm.js、compile.js、observer.js、watcher.js
mvvm.js:类似于new Vue()中的构造函数,传入options对象参数,在参数 options 中传入了一个 Dom 的根元素节点和数据 data 并挂在了当前的 MVVM 实例上。
当存在根节点的时候,通过 Observer 类的getter和setter对 data 数据进行了劫持,并通过 MVVM 实例的方法 proxyData 把 data 中的数据挂在当前 MVVM 实例上,同样对数据进行了劫持,这样我们在获取和修改数据的时候可以直接操作vm.message,其中vm是new MVVM()出来的实例,message是挂载到实例上的数据,在 Vue 中数据劫持的核心方法是 Object.defineProperty,我们也使用这个方式通过添加 getter 和 setter 来实现数据劫持。
代码如下:
class MVVM{
constructor(options){
// 一上来 先把可用的东西挂载在实例上
this.$el = options.el;
this.$data = options.data;
// 如果有要编译的模板我就开始编译
if(this.$el){
// 数据劫持 就是把对想的所有属性 改成get和set方法
new Observer(this.$data);
this.proxyData(this.$data);
// 用数据和元素进行编译
new Compile(this.$el, this);
}
}
proxyData(data){//将this.$data上的数据代理到vm实例上
Object.keys(data).forEach(key=>{
Object.defineProperty(this,key,{
get(){
return data[key]
},
set(newValue){
data[key] = newValue
}
})
})
}
}
复制代码compile.js:对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数。当数据变化了,会给元素添加new Watcher()监控数据的变化,并调用Watcher中的callback。
代码如下:
class Compile {
constructor(el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
if (this.el) {
// 如果这个元素能获取到 我们才开始编译
// 1.先把这些真实的DOM移入到内存中 fragment
let fragment = this.node2fragment(this.el);
// 2.编译 => 提取想要的元素节点 v-model 和文本节点 {{}}
this.compile(fragment);
// 3.把编译号的fragment在塞回到页面里去
this.el.appendChild(fragment);
}
}
/* 专门写一些辅助的方法 */
isElementNode(node) {
return node.nodeType === 1;
}
// 是不是指令
isDirective(name) {
return name.includes('v-');
}
/* 核心的方法 */
compileElement(node) {
// 带v-model v-text
let attrs = node.attributes; // 取出当前节点的属性
Array.from(attrs).forEach(attr => {
// 判断属性名字是不是包含v-model
let attrName = attr.name;
if (this.isDirective(attrName)) {
// 取到对应的值放到节点中
let expr = attr.value;
let [, type] = attrName.split('-');
// node this.vm.$data expr
CompileUtil[type](node, this.vm, expr);
}
})
}
compileText(node) {
// 带{{asd}}
let expr = node.textContent; // 取文本中的内容
let reg = /\{\{([^}]+)\}\}/g; // {{a}} {{b}} {{c}}
if (reg.test(expr)) {
// node this.vm.$data text
CompileUtil['text'](node, this.vm, expr);
}
}
compile(fragment) {
// 需要递归
let childNodes = fragment.childNodes;
Array.from(childNodes).forEach(node => {
if (this.isElementNode(node)) {
// 是元素节点,还需要继续深入的检查
// 这里需要编译元素
this.compileElement(node);
this.compile(node)
} else {
// 文本节点
// 这里需要编译文本
this.compileText(node);
}
});
}
node2fragment(el) { // 需要讲el中的内容全部放到内存中
// 文档碎片 内存中的dom节点
let fragment = document.createDocumentFragment();
let firstChild;
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
}
return fragment; // 内存中的节点
}
}
CompileUtil = {
getVal(vm, expr) { // 获取实例上对应的数据
expr = expr.split('.'); // [message,a]
return expr.reduce((prev, next) => { // vm.$data.a
return prev[next];
}, vm.$data);
},
getTextVal(vm, expr) { // 获取编译文本后的结果
return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
return this.getVal(vm, arguments[1]);
})
},
text(node, vm, expr) { // 文本处理
let updateFn = this.updater['textUpdater'];
// {{message.a}} => hello,zfpx;
let value = this.getTextVal(vm, expr);
// {{a}} {{b}}
expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
new Watcher(vm, arguments[1],(newValue)=>{
// 如果数据变化了,文本节点需要重新获取依赖的属性更新文本中的内容
updateFn && updateFn(node,this.getTextVal(vm,expr));
});
})
updateFn && updateFn(node, value)
},
setVal(vm,expr,value){ // [message,a]
expr = expr.split('.');
// 收敛
return expr.reduce((prev,next,currentIndex)=>{
if(currentIndex === expr.length-1){
return prev[next] = value;
}
return prev[next];
},vm.$data);
},
model(node, vm, expr) { // 输入框处理
let updateFn = this.updater['modelUpdater'];
// 这里应该加一个监控 数据变化了 应该调用这个watch的callback
new Watcher(vm,expr,(newValue)=>{
// 当值变化后会调用cb 将新的值传递过来 ()
updateFn && updateFn(node, this.getVal(vm, expr));
});
node.addEventListener('input',(e)=>{
let newValue = e.target.value;
this.setVal(vm,expr,newValue)
})
updateFn && updateFn(node, this.getVal(vm, expr));
},
updater: {
// 文本更新
textUpdater(node, value) {
node.textContent = value
},
// 输入框更新
modelUpdater(node, value) {
node.value = value;
}
}
}复制代码observer.js:数据监听器,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者,内部采用Object.defineProperty的getter和setter来实现。
代码如下:
class Observer{
constructor(data){
this.observe(data);
}
observe(data){
// 要对这个data数据将原有的属性改成set和get的形式
if(!data || typeof data !== 'object'){
return;
}
// 要将数据 一一劫持 先获取取到data的key和value
Object.keys(data).forEach(key=>{
// 劫持
this.defineReactive(data,key,data[key]);
this.observe(data[key]);// 深度递归劫持
});
}
// 定义响应式
defineReactive(obj,key,value){
// 在获取某个值的适合 想弹个框
let that = this;
let dep = new Dep(); // 每个变化的数据 都会对应一个数组,这个数组是存放所有更新的操作
Object.defineProperty(obj,key,{
enumerable:true,
configurable:true,
get(){ // 当取值时调用的方法
Dep.target && dep.addSub(Dep.target);//添加watcher
return value;
},
set(newValue){ // 当给data属性中设置值的适合 更改获取的属性的值
if(newValue!=value){
// 这里的this不是实例
that.observe(newValue);// 如果是对象继续劫持
value = newValue;
dep.notify(); // 通知所有人 数据更新了 执行watcher中的update方法
}
}
});
}
}
class Dep{
constructor(){
// 订阅的数组
this.subs = []
}
addSub(watcher){
this.subs.push(watcher);
}
notify(){
this.subs.forEach(watcher=>watcher.update());
}
}复制代码watcher.js: 作为连接 Observer 和 Compile 的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数。
代码如下:
// 观察者的目的就是给需要变化的那个元素增加一个观察者,当数据变化后执行对应的方法
class Watcher{
constructor(vm,expr,cb){
this.vm = vm;
this.expr = expr;
this.cb = cb;
// 先获取一下老的值
this.value = this.get();
}
getVal(vm, expr) { // 获取实例上对应的数据
expr = expr.split('.'); // [message,a]
return expr.reduce((prev, next) => { // vm.$data.a
return prev[next];
}, vm.$data);
}
get(){
Dep.target = this;
let value = this.getVal(this.vm,this.expr);
Dep.target = null;
return value;
}
// 对外暴露的方法
update(){
let newValue = this.getVal(this.vm, this.expr);
let oldValue = this.value;
if(newValue != oldValue){
this.cb(newValue); // 对应watch的callback
}
}
}
// 用新值和老值进行比对 如果放生变化 就调用更新方法
复制代码验证MVVM
按照Vue的引入方式来验证我们自己模拟实现的MVVM模式吧!
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>MVVM</title>
</head>
<body>
<div id="app">
<!-- 双向数据绑定 输入框v-model绑定的数据 -->
<input type="text" v-model="message"> <div>{{message}}</div>
<ul>
<li>{{message}}</li>
</ul>
{{message}}
</div>
<!-- 引入依赖的 js 文件 -->
<script src="./js/watcher.js"></script>
<script src="./js/observer.js"></script>
<script src="./js/compile.js"></script>
<script src="./js/mvvm.js"></script> <script>
let vm = new MVVM({
el: '#app',
data: {
message: 'hello world!'
}
});
</script>
</body>
</html>
复制代码打开 Chrom 浏览器的控制台进行验证:
- 输入
vm.message = "hello"看页面是否更新; - 输入
vm.$data.message = "hello"看页面是否更新; - 改变文本输入框内的值,看页面的其他元素是否更新。
总结
MVVM 模式对于前端开发有着非常重要的意义,它实现了双向数据绑定,实时保证 View 层与 Model 层的数据同步,以数据为驱动,让我们在开发时基于数据编程,而最少的操作 Dom,这样大大提高了页面渲染的性能,提高开发的效率和维护成本,可以让我们把更多的精力用于业务逻辑的开发上。