什么是 MVVM
MVVM——Model-View-ViewModle 的缩写,MVC 设计模式的改进版。Model 是我们应用中的数据模型,View 是我们的 UI 层,通过 ViewModle,可以把我们 Modle 中的数据映射到 View 视图上,同时,在 View 层修改了一些数据,也会反应更新我们的 Modle。
上面的话,未免太官方了。简单理解就是双向数据绑定,即当数据发生变化的时候,视图也就发生变化,当视图发生变化的时候,数据也会跟着同步变化。
MVVM 这种思想的前端框架其实老早就有了,我记得是在 13 年,自己在公司的主要工作是做后台管理系统的 UI 设计和开发,当时就思考,如何让那些专注后台的开发,既简单又方便的使用前端开发的一些组件。当时有三种方案:
- 使用 Easy-ui,但 easy-ui 好像官方要求收费,当然也可以破解使用
- 自己开发 UI 框架,其实当时想做的东西就是后来 BootStrap
- 使用谷歌的 Angular,进行二次开发
后来的评估是:
- 使用 easy-ui,工作量太多
- 使用 Angular 和 easy-ui 不仅工作量很大,后台也要做相应的修改
- 自己写 UI 框架,比较合适,当时的做法是写一些 jQuery 相关的插件,先给后台一个 js 插件包,后续的 UI 修改,慢慢进行。
当时自己还是比较推崇 Angular 的,我记得后来还买了一本《基于 MVC 的 Javascript Web 富应用开发》专门去了解这种模式在工作中可能用的情况,以及实现它的一些基本思路。
当时热点比较高的 MVVM 框架有:
- Angular:谷歌出品,名气很大,入门高,使用麻烦,它提供了很多新的概念。
- Backbone.js,入门要求级别很高,我记得当时淘宝有些项目应用了这个,《基于 MVC 富应用开发》书里面也是以这个框架为主介绍 MVC 的。
- Ember:大而全的框架,开始写代码之前就已经有很多的工作要做了。
当年的环境和条件都没有现在好,无论从技术完善的情况,还是工作的实际情况上面看,都是如此——那时候前后端分离都是理想。
当然现在环境好了,各种框架的出现也极大方便了我们,提高了我们开发的工作效率。时代总是在进步,大浪淘沙,MVVM 的框架现在比较热门和流行的,我相信大家现在都知道,就是下面三种了:
- Angular
- Vue
- React
现在 Angular 除了一些忠实的拥趸,基本上也就没落了。Angular 无论从入门还是实际应用方面,都要比其他两个框架发费的时间成本更大。
Angular 现在有种英雄末路的感觉,但不能不承认,之前它确实散发了光芒。
Angular 的 1.x 版本,是通过 脏值检测 来实现双向绑定的。
而最新的 Angular 版本和 Vue,以及 React 都是通过 数据劫持 + 发布订阅模式 来实现的。
脏值检测
简单理解就是,把老数据和新数据进行比较,脏 就表示之前存在过,有过痕迹,通过比较新旧数据,来判断是否要更新。感兴趣的可以看看这篇文章 构建自己的 AngularJS,第一部分:作用域和 digest。
数据劫持 发布订阅
数据劫持:在访问或者修改对象的某个属性时,通过代码拦截这个行为,进行额外的操作或者修改返回结果。在 ES5 当中新增了 Object.defineProperty()可以帮我们实现这个功能。
发布订阅:现在每个人应该都用微信吧,一个人可以关注多个公众号,多个人可以同时关注相同的公众号。关注的动作就相当于订阅。公众号每周都会更新内容,并推送给我们,把写好的文章在微信管理平台更新就好了,点击推送,就相当于发布。更详细的可以深入阅读 javascript 设计模式——发布订阅模式
怎么实现一个 MVVM
我们静下心好好思考下,如果才能实现双向数据绑定的功能。可能需要:
- 一个初始化实例的类
- 一个存放数据的对象 Object
- 一个可以把我们的数据映射到 HTML 页面上的“模板解析”工具
- 一个更新数据的方法
- 一个通过监听数据的变化,更新视图的方法
- 一个挂载模板解析的 HTML 标签
通过上面这样的思考,我们可以简单的写一下大概的方法。
class MVVM {constructor(data){
this.$option = option;
const data = this._data = this.$option.data;
// 数据劫持
observe(data)
// 数据代理
proxyData(data)
// 编译模板
const dom = this._el = this.$option.el;
complie(dom,this);
// 发布订阅
// 连接视图和数据
// 实现双向数据绑定
}
}
// Observe 类
function Observe(){}
// Observe 实例化函数
function observe(data){return new Observe(data);
}
// Compile 类
function Compile(){}
// Compile 实例化函数
function compile(el){return new Compile(el)
}
数据劫持
我们有下面这样一个对象
let obj = {
name:"mc",
age:"29",
friends:{
name:"hanghang",
name:"jiejie"
}
}
我们要对这个对象执行某些操作(读取,修改),通常像下面就可以
// 取值
const name = obj.name;
console.log(obj.age)
const friends = obj.friends;
// 修改
obj.name = "mmcai";
obj.age = 30;
在 VUE 中,我们知道,如果 data 对象中的某个属性,在 template 当中绑定的话,当我们修改了这个属性值,我们的视图也就更新了。这就是双向数据绑定,数据变化,视图更新,同时反过来也一样。
要实现这个功能,我们就需要知道 data 当中的数据是如何变动了,ES5 当中提供了 Object.defineProperty()函数,我们可以通过这个函数对我们 data 对象当中的数据进行监听。当数据变动,就会触发这个函数里面的 set 方法,通过判断数据是否变化,就可以执行一些方法,更新我们的视图了。所以我们现在需要实现一个数据监听器 Observe,来对我们 data 中的所有属性进行监听。
// Observe 类的实例化函数
function observe(data){
// 判断数据是否是一个对象
if(typeof data !== 'object'){return;}
// 返回一个 Observe 的实例化对象
return new Observe(data)
}
// Observer 类的实现
class Observe{constructor(data){
this.data = data;
this.init(data)
}
init(data){for(let k in data){let val = data[k];
// 如果 data 是一个对象,我们递归调用自身
if(typeof val === 'object'){observe(val);
}
Object.defineProperty(data,k,{
enumerable:true,
get(){return val;},
set(newVal){
// 如果值相同,直接返回
if(newVal === val){return;};
// 赋值
val = newVal;
// 如果新设置的值是一个对象,递归调用 observe 方法,给新数据也添加上监听
if(typeof newVal === 'object'){observe(newVal);
}
}
})
}
}
}
了解了数据劫持,我们就可以明白,为什么我们实例化 vue 的时候,必须事先在 data 当中定义好我们的需要的属性了,因为我们新增的属性,没有经过 observe 进行监听,没有通过 observe 监听,后面 complie(模板解析)也就不会执行。
所以,虽然你可以在 data 上面设置新的属性,并读取,但视图却不能更新。
数据代理
我们常见的代理有 nginx, 就是我们不直接去访问(操作)我们实际要访问的数据,而是通过访问一个代理,然后代理帮我们去拿我们真正需要的数据。
一般的特点是:
- 安全,不把真实内容暴露
- 方便,可以把一些复杂的操作,通过代理进行简化
- …
下面是 VUE 简单的一个使用实例:
cosnt vm = new Vue({
el:"#app",
data:{name:"mmcai"}
});
我们的实例化对象 vm,想要读取 data 里面的数据的时候,不做任何处理的正常情况下,使用下面方式读取:
const name = vm.data.name;
这样操作起来,显然麻烦了一些,我们就可以通过数据代理,直接把 data 绑定到我们的实例上,所以在 vue 当中,我们一般获取数据像下面一样:
cosnt vm = new Vue({
el:"#app",
data:{name:"mmcai"},
created(){
// 直接通过实例就可以访问到 data 当中的数据
const name = this.name;
// 通过 this.data.name 也可以访问,但是显然,麻烦了一些
}
});
同样,我们通过 Object.defineProperty 函数,把 data 对象中的数据,绑定到我们的实例上就可以了,代码如下:
class MVVM {constructor(option){
// 此处代码省略
this.$option = option;
const data = this._data = this.$option.data;
// 调用代理
this._proxyData(data);
}
_proxyData(data){
const that = this;
for(let k in data){let val = data[k];
Object.defineProperty(that,k,{
enumerable:true,
get(){return that._data[k];
},
set(newVal){that._data[k] = newVal;
}
})
}
}
}
编译模板
利用正则表达式识别模板标识符,并利用数据替换其中的标识符。
VUE 里面的标识符是 {{}} 双大括号,数据就是我们定义在 data 上面的内容。
实现原理
- 确定我们的模板范围
- 遍历 DOM 节点,循环找到我们的标识符
- 将标识符的内容用数据进行填充填充
遍历解析需要替换的根元素 el 下的 HTML 标签,一定会使用遍历对 DOM 节点进行操作,对 DOM 操作就会引发页面的重排和重绘,为了提高性能和效率,可以把 el 根节点下的所有节点替换为文档碎片 fragment 进行解析编译操作,解析完成,再将 fragment 添加到根节点 el 中
如果想对文档碎片进行,更多的了解,可以查看文章底部的参考资料
<!-- 定义模板编译类 -->
class Complie{constructor(el,vm){
this.$vm = vm;
this.$el = document.querySelector(el);
// 第一步,把 DOM 转换成文档碎片
this.$fragment = this.nodeToFragment(this.$el);
// 第二步,匹配标识符,填充数据
this.compileElement(this.$fragment);
// 把文档碎片,添加到 el 根节点上面
this.$el.appendChild(this.$fragment);
}
// 把 DOM 节点转换成文档碎片
nodeToFragment(el){let nodeFragment = document.createDocumentFragment();
// 循环遍历 el 下面的节点,填充到文档碎片 nodeFragment 中
while(child = el.firstChild){nodeFragment.appendChild(child);
}
// 把文档碎片返回
return nodeFragment;
}
// 遍历目标,查找标识符,并替换
compileElement(node){let reg = /\{\{(.*)\}\}/;
Array.from(node.childNodes).forEach((node)=>{
let text = node.textContent;
if(node.nodeType === 3 && reg.test(text)){let arr = RegExp.$1.split('.');
// vm 是实例的整个 data 对象
let val = vm;
arr.forEach((k)=>{val = val[k]
})
node.textContent = text.replace(/\{\{(.*)\}\}/,val);
}
// 如果节点包含字节的,递归调用自身
if(node.childNodes){this.compileElement(node)
}
})
}
}
<!-- 实例化的方法 -->
const complie = (el,vm)=>{return new Compile(el,vm)
}
发布订阅
在软件架构中,发布订阅是一种消息范式,消息的发送者(成为发布者)不会将消息直接发送给特定的接收者(成为订阅者)。二十将发布的消息分为不同的类别,无需了解哪些订阅者是否存在。同样的,订阅者可以表达对一个或多个类别的兴趣,直接受感兴趣的消息,无需了解哪些发布者是否存在——维基。
上述的表达中,既然说发布者不关心订阅者,订阅者也不关心发布者,那么他们是如何通信呢?
其实就是通过第三方,通常在函数中我们,称他们为 观察者watcher
在 VUE 的里面,我们要确认几个概念,谁是发布者,谁是订阅者,为什么需要发布订阅?
上面我们说了数据劫持 Observe,也说了 Compile,其实,Observe 和 Compile 他们即使发布者,也是订阅者,帮助他们之间的通讯,就是 watcher 的工作。
通过下面的代码,我们简单了解下,发布订阅模式的实现情况。
// 创建一个类
// 发布订阅,本质上是维护一个函数的数组列表,订阅就是放入函数,发布就是让函数执行
class Dep{consturctor(){this.subs=[];
}
// 添加订阅者
addSub(sub){this.subs.push(sub);
}
// 通知订阅者
notify(){
// 订阅者,都有
this.subs.forEach((sub=>sub.update());
}
}
// 监听函数,watcher
// 通过 Watcher 类创建的实例,都有 update 方法
class Watcher{
// watcher 的实例,都需要传入一个函数
constructor(fn){this.fn = fn;}
// watcher 的实例,都拥有 update 方法
update(){this.fn();
}
}
// 把函数作为参数传入,实例化一个 watcher
const watcher = new Watcher(()=>{consoole.log('1')
});
// 实例化 Dep 类
const dep = new Dep();
// 将 watcher 放到 dep 维护的数组中,watcher 实例本身具有 update 方法
// 可以理解成函数的订阅
dep.addSub(watcher);
// 执行,可以理解成,函数的发布,
// 不关心,addSub 方法订阅了谁,只要订阅了,就通过遍历循环 subs 数组,执行数组每一项的 update
dep.notify();
通过以上代码的了解,我们继续实现我们 MVVM 中的代码,实现数据和视图的关联。
这种关联的结果就是,当我们修改 data 中的数据的时候,我们的视图更新。或者我们视图中修改了相关内容,我们的 data 也进行相关的更新,所以这里主要的逻辑代码,就是我们 watcher 当中的 update 方法。
我们根据上面的内容,对我们的 Observe 和 Compile 以及 Watcher 进行修改,代码如下:
class MVVM{constructor(option){
this.$option = option;
const data = this._data = this.$option.data;
this.$el = this.$option.el;
// 数据劫持
this._observe(data);
// 数据代理
this._proxyData(data);
// 模板解析
this._compile(this.$el,this)
}
// 数据代理
_proxyData(data){for(let k in data){let val = data[k];
Object.defineProperty(this,k,{
enumerable:true,
get(){return this._data[k];
},
set(newVal){this._data[k] = newVal;
}
})
}
}
}
// 数据劫持
class Observe{constructor(data){this.init(data);
}
init(data){let dep = new Dep();
for(let k in data){let val = data[k];
// val 可能是一个对象,递归调用
if(typeof val === 'object'){observe(val);
}
Object.defineProperty(data,k,{
enumerable:true,
get(){
// 订阅,
// Dep.target 是 Watcher 的实例
Dep.target && dep.addSub(Dep.target);
return val;
},
set(newVal){if(newVal === val){return;}
val = newVal;
observe(newVal);
dep.notify();}
})
}
}
}
// 数据劫持实例
function observe(data){if(typeof data !== 'object'){return};
return new Observe(data);
}
// 模板编译
class Compile{constructor(el,vm){vm.$el = document.querySelector(el);
//1. 把 DOM 节点,转换成文档碎片
const Fragment = this.nodeToFragment(vm.$el)
//2. 通过正则匹配,填充数据
this.replace(Fragment,vm);
//3. 把填充过数据的文档碎片,插入模板根节点
vm.$el.appendChild(Fragment);
}
// DOM 节点转换
nodeToFragment(el){
// 创建文档碎片,const fragment = document.createDocumentFragment();
// 遍历 DOM 节点,把 DOM 节点,添加到文档碎片上
while(child ===el.firstChild){fragment.appendChild(child);
}
// 返回文档碎片
return fragment;
}
// 匹配标识,填充数据
replace(fragment,vm){
// 使用 Array.from 方法,把 DOM 节点,转化成数据,进行循环遍历
Array.from(fragment.childNodes).forEach((node)=>{
// 遍历节点,拿到每个内容节点
let text = node.textContent;
// 定义标识符的正则
let reg = /\{\{(.*)\}\}/;
// 如果节点是文本,且节点的内容当中匹配到了模板标识符
// 数据渲染视图
if(node.nodeType===3 && reg.test(text)){
// 用数据替换标识符
let arr = RegExp.$1.split('.');
let val = vm;
arr.forEach((item)=>{val = val[item];
})
// 添加一个 watcher,当我们的数据发生变化的时候,更新我们的 view
new Watcher(vm,RegExp.$1,(newVal)=>{node.textContent = text.replace(reg,newVal);
})
// 把数据填充到节点上
node.textContent = text.replace(reg,val);
}
// 视图更新数据
if(node.nodeType === 1){
let nodeAttrs = node.attributes;
Array.from(nodeAttrs).forEach((attr)=>{
let name = attr.name;
// 获取标识符的内容,也就是 v -mode="a" 的内容
let exp = attr.value;
if(name.indexOf('v-model')===0){node.value = vm[exp];
};
new Watcher(vm,exp,(newVal)=>{node.value = newVal;});
node.addEventListener('input',function(e){
let newVal = e.target.value;
vm[exp] = newVal;
});
});
}
// 如果节点包含子节点,递归调用自身
if(node.childNodes){this.replace(node,vm);
}
})
}
}
// 模板编译实例
function compile(el,vm){return new Compile(el,vm)
}
// 发布订阅
class Dep{constructor(){this.subs = [];
}
// 订阅函数
addSub(fn){this.subs.push(fn);
}
// 发布执行函数
notify(){this.subs.forEach((fn)=>{fn();
})
}
}
// Dep 实例
function dep(){return new Dep();
}
// 观察者
class Watcher{
// vm, 我们的实例
// exp, 我们的标识符
// fn, 回调
constructor(vm,exp,fn){
this.fn = fn;
this.vm = vm;
this.exp = exp;
Dep.target = this;
let val = vm;
let arr = exp.split('.');
arr.forEach((k)=>{val = val[k]
});
// 完成之后,我们把 target 删除;Dep.target = null;
}
update(){
let val = this.vm;
let arr = this.exp.split('.');
arr.forEach((k)=>{val = val[k];
})
this.fn();}
}
function watcher(){return new Watcher()
}
Wathcer 干了那些好事:
- 在自身实例化的时候,往订阅器 (dep) 里面添加自己
- 自身有一个 update 方法
- 待 data 属性发生修改的时候,dep.notify()通知的时候,可以调用自身的 update()方法,在 update()方法出发绑定的回调
Watcher 连接了两个部分,包括 Observe 和 Compile;
在 Observe 方法执行的时候,我们给 data 的每个属性都添加了一个 dep,这个 dep 被闭包在 get/set 函数内。
当我们 new Watcher,在之后访问 data 当中属性的时候,就会触发通过 Object.defineProperty()函数当中的 get 方法。
get 方法的调用,就会在属性的订阅器实例 dep 中,添加当前 Watcher 的实例。
当我们尝试修改 data 属性的时候,就会出发 dep.notify()方法,该方法会调用每个 Watcher 实例的 update 方法,从而更新我们的视图。
结束语
回顾下整个 MVVM 实现的整个过程
- 使用 Object.defineProperty()函数,给每个 data 属性添加 get/set,并为每个属性创建一个 dep 实例,监听数据变化
- 同样使用 Object.defineProperty()函数,把 data 对象的属性,绑定到我们 MVVM 实例 vm 对象上,简化使用
- 通过 document.createDocumentFragment,把我们 el 节点下的 dom 转换成文档碎片
- 遍历文档碎片,找到模板标识符,进行数据的替换,添加 Watcher 观察者,当数据发生变化的时候,再次更新我们的文档碎片
- 把文档碎片插入到我们的 el 节点中。
- 我们修改 data, 执行 dep.notify()方法,然后调用 Watcher 实例上的 update 方法,更新视图。
我这里有一个简短的视频,是某培训机构讲解 MVVM 的内容,大家有兴趣,可以自取。
视频链接
提取码:1i0r
如果失效,可以私聊我。
参考
廖雪峰谈 MVVM
…, 让 MVVM 原理还给你
观察者模式与发布订阅模式
基于 vue 实现一个简单的 MVVM 框架
文档碎片