经过一学期的高级软件工程课程的学习,收获非常多,从“工欲善其事,必先利其器”中了解到非常强大的开发工具和基础技能,到“代码中的软件工程——工程化编程实战中”编写Menu程序体会到了何为工程化的编程,到“码农的自我修养之从需求分析到软件设计”学习到了需求分析和软件设计的方法,再到“码农的自我修养之软件科学基础概论”学习了软件架构及其描述方法和设计模式,最后在“码农的自我修养之软件危机和软件过程”体会到了没有银弹的含义等等,学到了很多,在此对课程中所讲的MVVM模式以vue.js代码为例进行总结。
在此之前曾多次在网上搜索有关MVVM的描述,却一致无法深入理解,直到在课堂上听了孟宁老师的讲解,感觉豁然开朗。
一、什么是MVVM?
MVVM即 Model-View-ViewModel,最早由微软提出来,借鉴了桌面应用程序的MVC模式的思想,是一种针对WPF、Silverlight、Windows Phone的设计模式,目前广泛应用于复杂的Javacript前端项目中。
二、Vue.js框架的MVVM架构
在前端页面中,把Model用纯JavaScript对象表示,View负责显示,两者做到了最大限度的分离。把Model和View关联起来的就是ViewModel。ViewModel负责把Model的数据同步到View显示出来,还负责把View的修改同步回Model。以比较流行的Vue.js框架为例,MVVM架构示意图如下:
三、Vue.js的基本用法
1. Model、View在vue.js中的表示
Model是一个JavaScript对象
{
message: 'Hello Vue!'
}
负责显示的是DOM节点可以用{{ message }}来引用Model的属性,也就是View了
<div id="app">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">Reverse Message</button>
</div>
其中v-on:click="reverseMessage"用来跟踪DOM的点击事件调用reverseMessage方法
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
2. vue中Model与View的绑定
<!DOCTYPE html>
<html>
<head>
<title>My first Vue app</title>
<script src="https://unpkg.com/vue"></script>
</head>
<body>
<div id="app">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">Reverse Message</button>
</div>
<script>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue.js!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
})
</script>
</body>
</html>
我们在创建Vue对象的时候将View的id="app"与Model(JavaScript对象定义的data)绑定起来,这样'Hello Vue!'就会自动更新到View DOM元素中。View DOM元素button上的事件click绑定Vue对象的方法reverseMessage,这样点击button按钮就能触发reverseMessage,reverseMessage方法只是修改了Model中JavaScript对象定义的message,而页面却能神奇地自动更新message。就是模型数据绑定和DOM事件监听。如下完整代码来自https://vuejs.org/v2/guide/,该页面上也有如下代码的运行演示。
四、Vue.js背后MVVM模型的秘密
Vue.js是一个前端构建数据驱动的Web界面的库,主要的特色是响应式的数据绑定,区别于以往的命令式用法。也就是在this.message = this.message.split('').reverse().join('')的过程中,拦截'='的过程,从而实现模型和视图自动同步更新的功能。而不需要显式地使用命令更新视图。Vue.js如何做到这一点的呢?
1. Object.defineProperty
首先把一个普通对象作为参数创建Vue对象时,Vue.js将遍历data的属性,用 Object.defineProperty 将要观察的对象“=”操作转化为getter/setter,以便拦截对象赋值与取值操作,称之为Observer;
//遍历data用Object.defineProperty 将要观察的对象“=”操作转化为getter/setter
Observer.prototype.transform = function(data){
for(var key in data){
var value = data[key];
Object.defineProperty(data, key, {
enumerable:true,
configurable:true,
get:function(){
return value;
},
set:function(newVal){
if(newVal == value){
return;
}
//遍历newVal
this.transform(newVal);
data[key] = newVal;//赋值还会调用set方法死循环了
}
});
//递归处理
this.transform(value);
}
};
此处代码涉及到的点有:闭包和递归。js中的对象默认都有set、get方法,此处为重载;在set中对data[key]赋值还会点用set,有死循环,所以在这里要是用闭包进行处理;因为value可能还是key:value,所以涉及到递归;
2. 编译视图模板的主要工作
将DOM解析,提取其中的事件指令与占位符/表达式,并赋与不同的操作创建Watcher在模型中监听视图中出现的占位符/表达式,以及根据事件指令绑定监听事件和method,这是编译视图模板的主要工作,我们称之为Compiler;
//DOM中的指令与占位符
...
<p>{{ message }}</p>
<button v-on:click="reverseMessage">Reverse Message</button>
...
//创建Watcher在模型中监听视图中出现的占位符/表达式的每一个成员
var watcher = new Watcher("message"):
//绑定监听事件和method
node.addEventListener("click", "reverseMessage"):
3. 使用到观察者模式
1)绑定观察者及get方法调用
将Compiler的解析结果,与Observer所观察的对象连接起来建立关系,在Observer观察到对象数据变化时,接收通知,同时更新DOM,称之为Watcher;
//观察者模式中的被观察者的核心部分
var Dep = function(){
this.subs = {};
};
Dep.prototype.addSub = function(target){
if(!this.subs[target.uid]) {
//防止重复添加
this.subs[target.uid] = target;
}
};
Dep.prototype.notify = function(newVal){
for(var uid in this.subs){
this.subs[uid].update(newVal);
}
};
Dep.target = null;
代码分析:Dep即为被观察者,即Model中数据的一个属性,在subs中记录了所有的观察者。其中的target即为编译视图模版得到的watcher,通过addSub将观察者watcher无重复的添加到subs中,在数据变化时通过notify通知所有的观察者,此处调用的updata函数在如下代码段中定义:
//创建Watcher,观察者模式中的观察者
var Watcher = function(exp, vm, cb){
this.exp = exp; // 占位符/表达式的一个成员
this.cb = cb; //更新视图的回调函数
this.vm = vm; //ViewModel
this.value = null;
this.getter = parseExpression(exp).get;
this.update();
};
Watcher.prototype = {
get : function(){
Dep.target = this;
var value = this.getter?this.getter(this.vm):'';
Dep.target = null;
return value;
},
update :function(){
var newVal = this.get();
if(this.value != newVal){
this.cb && this.cb(newVal, this.value);
this.value = newVal;
}
}
};
首先对占位符/表达式进行绑定,exp是在编译视图阶段传入的:new Watcher("message")。由于watcher是编译视图模版得出来的,watcher是视图的一部分,所以此处的this.value是视图中的value,一开始被设置为null。然后将this.getter指向被观察的exp的get方法,最后点用updata。
对于update函数,首先会调用this.get方法获取新的值,此处的newVal是Model中的数据。get方法首先将Dep.target指向自身,然后调用this.getter方法获取被观察者的值,方法实现如下:
Observer.prototype.defineReactive = function(data, key, value){
var dep = new Dep();
Object.defineProperty(data, key ,{
enumerable:true,
configurable:false,
get:function(){
if(Dep.target){
//添加观察者
dep.addSub(Dep.target);
}
return value;
},
set:function(newVal){
if(newVal == value){
return;
}
//data[key] = newVal;//死循环!赋值还会调用set方法
value = newVal;//为什么可以这样修改?闭包依赖的外部变量
//遍历newVal
this.transform(newVal);
//发送更新通知给观察者
dep.notify(newVal);
}
});
//递归处理
this.transform(value);
};
进入exp的get方法后,首先将watcher添加到观察者列表中,然后将值返回,如果当前视图中的值与Model中的值不相等,update就调用更新视图的回调函数cb来更新视图,并将newVal赋值给this.Value。
因为是在取值操作时将watcher加入到观察者列表中,所以每次取值都会尝试添加,所以在前面介绍的Dep.addSub中会先判断是否已经添加过,防止重复添加。
2)set方法调用
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
当Model中的数据被修改时,复制操作“=”会被拦截,触发对应exp的set方法,前面说到set方法中有死循环,修改后的代码如下:
Observer.prototype.defineReactive = function(data, key, value){
var dep = new Dep();
Object.defineProperty(data, key ,{
enumerable:true,
configurable:false,
get:function(){
if(Dep.target){
//添加观察者
dep.addSub(Dep.target);
}
return value;
},
set:function(newVal){
if(newVal == value){
return;
}
//data[key] = newVal;//死循环!赋值还会调用set方法
value = newVal;//为什么可以这样修改?闭包依赖的外部变量
//遍历newVal
this.transform(newVal);
//发送更新通知给观察者
dep.notify(newVal);
}
});
//递归处理
this.transform(value);
};
在这里使用到了闭包,直接对value进行赋值,即对闭包依赖的外部变量进行赋值。
set方法首先判断新值是否等于旧值,相等就直接返回,否则就修改Model的值,并通知观察者更新视图中的数据。
这样逻辑完整Vue.js内部实现的MVVM框架实现机制就呈现出来了。
3)几个关键点
1. new vue时先transform对象再编译视图模版,因为new watcher时需要用到get。
2. get、set方法在transform中没有被执行,是一个闭包,set在赋值时才会被执行,get方法在取值时才会被执行。
作者:SA21225444
参考资料 代码中的软件工程 https://gitee.com/mengning997/se
学到了很多新知识,对软件工程的理解更深刻了,在此非常感谢孟宁老师的教导!