参考https://www.pandashen.com/2018/03/28/20180328140039,原文中代码有一些bug。
MVVM 的前世今生
MVVM 设计模式,是由 MVC(最早来源于后端)、MVP 等设计模式进化而来,M - 数据模型(Model),VM - 视图模型(ViewModel),V - 视图层(View)。
在 MVC 模式中,除了 Model 和 View 层以外,其他所有的逻辑都在 Controller 中,Controller 负责显示页面、响应用户操作、网络请求及与 Model 的交互,随着业务的增加和产品的迭代,Controller 中的处理逻辑越来越多、越来越复杂,难以维护。为了更好的管理代码,为了更方便的扩展业务,必须要为 Controller “瘦身”,需要更清晰的将用户界面(UI)开发从应用程序的业务逻辑与行为中分离,MVVM 为此而生。
很多 MVVM 的实现都是通过数据绑定来将 View 的逻辑从其他层分离,可以用下图来简略的表示:
使用 MVVM 设计模式的前端框架很多,其中渐进式框架 Vue 是典型的代表,并在开发使用中深得广大前端开发者的青睐,我们这篇就根据 Vue 对于 MVVM 的实现方式来简单模拟一版 MVVM 库。
MVVM 的流程分析
在 Vue 的 MVVM 设计中,我们主要针对 Compile(模板编译)、Observer(数据劫持)、Watcher(数据监听)和 Dep(发布订阅)几个部分来实现,核心逻辑流程可参照下图:
类似这种 “造轮子” 的代码毋庸置疑一定是通过面向对象编程来实现的,并严格遵循开放封闭原则,由于 ES5 的面向对象编程比较繁琐,所以,在接下来的代码中统一使用 ES6 的 class 来实现。
MVVM 类的实现
在 Vue 中,对外只暴露了一个名为 Vue 的构造函数,在使用的时候 new 一个 Vue 实例,然后传入了一个 options 参数,类型为一个对象,包括当前 Vue 实例的作用域 el、模板绑定的数据 data 等等。
我们模拟这种 MVVM 模式的时候也构建一个类,名字就叫 MVVM,在使用时同 Vue 框架类似,需要通过 new 指令创建 MVVM 的实例并传入 options。
文件:MVVM.js
class MVVM {
constructor(options) {
// 先把 el 和 data 挂在 MVVM 实例上
this.$el = options.el;
this.$data = options.data;
if (this.$el) {
// $data数据劫持
new Observer(this.$data);
// 将数据代理到实例上 vm.message = "hello"
this.proxyData(this.$data);
// 用数据和元素进行编译
new Compile(this.$el, this);
}
}
proxyData(data) { // 代理数据的方法
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key];
},
set(newVal) {
data[key] = newVal;
}
});
});
}
}
通过上面代码,我们可以看出,在我们 new 一个 MVVM 的时候,在参数 options 中传入了一个 Dom 的根元素节点和数据 data 并挂在了当前的 MVVM 实例上。
当存在根节点的时候,通过 Observer 类对 data 数据进行了劫持,并通过 MVVM 实例的方法 proxyData 把 data 中的数据挂在当前 MVVM 实例上,同样对数据进行了劫持,是因为我们在获取和修改数据的时候可以直接通过 this 或 this.$data,在 Vue 中实现数据劫持的核心方法是 Object.defineProperty,我们也使用这个方式通过添加 getter 和 setter 来实现数据劫持。
最后使用 Compile 类对模板和绑定的数据进行了解析和编译,并渲染在根节点上,之所以数据劫持和模板解析都使用类的方式实现,是因为代码方便维护和扩展,其实不难看出,MVVM 类其实作为了 Compile 类和 Observer 类的一个桥梁。
模板编译 Compile 类的实现
Compile 类在创建实例的时候需要传入两个参数,第一个参数是当前 MVVM 实例作用的根节点,第二个参数就是 MVVM 实例,之所以传入 MVVM 的实例是为了更方便的获取 MVVM 实例上的属性。
在 Compile 类中,我们会尽量的把一些公共的逻辑抽取出来进行最大限度的复用,避免冗余代码,提高维护性和扩展性,我们把 Compile 类抽取出的实例方法主要分为两大类,辅助方法和核心方法,在代码中用注释标明。
1、解析根节点内的 Dom 结构
文件:Compile.js
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);
}
}
/* 辅助方法 */
// 判断是否是元素节点
isElementNode(node) {
return node.nodeType === 1;
}
/* 核心方法 */
// 将根节点转移至文档碎片
node2fragment(el) {
// 创建文档碎片
let fragment = document.createDocumentFragment();
// 第一个子节点
let firstChild;
// 循环取出根节点中的节点并放入文档碎片中
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
}
return fragment;
}
}
上面编译模板的过程中,前提条件是必须存在根元素节点,传入的根元素节点允许是一个真实的 Dom 元素,也可以是一个选择器,所以我们创建了辅助方法 isElementNode 来帮我们判断传入的元素是否是 Dom,如果是就直接使用,是选择器就获取这个 Dom,最终将这个根节点存入 this.el 属性中。
解析模板的过程中为了性能,我们应取出根节点内的子节点存放在文档碎片中(内存),需要注意的是将一个 Dom 节点内的子节点存入文档碎片的过程中,会在原来的 Dom 容器中删除这个节点,所以在遍历根节点的子节点时,永远是将第一个节点取出存入文档碎片,直到节点不存在为止。
2、编译文档碎片中的结构
在 Vue 中的模板编译的主要就是两部分,也是浏览器无法解析的部分,元素节点中的指令和文本节点中的 Mustache 语法(双大括号)。
文件:Compile.js —— 完善
class Compile {
constructor(el, vm) {
//dom
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、将模板中的指令中的变量和 {{}} 中的变量替换成真实的数据
this.compile(fragment);
// 3、把编译好的 fragment 再塞回页面中
this.el.appendChild(fragment);
// ********** 以上为新增代码 **********
}
}
/* 辅助方法 */
// 判断是否是元素节点
isElementNode(_node) {
return _node.nodeType === 1;
}
// ********** 以下为新增代码 **********
// 判断属性是否为指令
isDirective(name) {
return name.includes("v-");
}
// ********** 以上为新增代码 **********
/* 核心方法 */
// 将根节点转移至文档碎片
node2fragment(el) {
// 创建文档碎片
let fragment = document.createDocumentFragment();
// 第一个子节点
let firstChild;
// 循环取出根节点中的节点并放入文档碎片中
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
}
return fragment;
}
// ********** 以下为新增代码 **********
// 解析文档碎片
compile(fragment) {
// 当前父节点节点的子节点,包含文本节点,类数组对象
let childNodes = fragment.childNodes;
// 转换成数组并循环判断每一个节点的类型
Array.from(childNodes).forEach(node => {
if (this.isElementNode(node)) { // 是元素节点
// 递归编译子节点
this.compile(node);
// 编译元素节点的方法
this.compileElement(node);
} else { // 是文本节点
// 编译文本节点的方法
this.compileText(node);
}
});
}
// 编译元素
compileElement(node) {
// 取出当前节点的属性,类数组
let attrs = node.attributes;
Array.from(attrs).forEach(attr => {
// 获取属性名,判断属性是否为指令,即含 v-
let attrName = attr.name;
if (this.isDirective(attrName)) {
// 如果是指令,取到该属性值得变量在 data 中对应得值,替换到节点中
let exp = attr.value;
// 取出方法名
let [, type] = attrName.split("-");
// 调用指令对应得方法
CompileUtil[type](node, this.vm, exp);
}
});
}
// 编译文本
compileText(node) {
// 获取文本节点的内容
let exp = node.textContent;
// 创建匹配 {{}} 的正则表达式
//let reg = /\{\{([^}+])\}\}/g;
//“.”表示任意字符。“+”表示前面表达式一次乃至多次。“?”表示匹配模式是非贪婪的。
let reg = /\{\{(.+?)\}\}/g;
// 如果存在 {{}} 则使用 text 指令的方法
if (reg.test(exp)) {
CompileUtil["text"](node, this.vm, exp);
}
}
// ********** 以上为新增代码 **********
}
上面代码新增内容得主要逻辑就是做了两件事:
- 调用 compile 方法对 fragment 文档碎片进行编译,即替换内部指令和 Mustache 语法中变量对应的值;
- 将编译好的 fragment 文档碎片塞回根节点。
在第一个步骤当中逻辑是比较繁琐的,首先在 compile 方法中获取所有的子节点,循环进行编译,如果是元素节点需要递归 compile,传入当前元素节点。在这个过程当中抽取出了两个方法,compileElement 和 compileText 用来对元素节点的属性和文本节点进行处理。
compileElement 中的核心逻辑就是处理指令,取出元素节点所有的属性判断是否是指令,是指令则调用指令对应的方法。compileText 中的核心逻辑就是取出文本的内容通过正则表达式匹配出被 Mustache 语法的 “{{ }}” 包裹的内容,并调用处理文本的 text 方法。
文本节点的内容有可能存在 “{{ }} {{ }} {{ }}”,正则匹配默认是贪婪的,为了防止第一个 “{” 和最后一个 “}” 进行匹配,所以在正则表达式中应使用非贪婪匹配。
在调用指令的方法时都是调用的 CompileUtil 下对应的方法,我们之所以单独把这些指令对应的方法抽离出来存储在 CompileUtil 对象下的目的是为了解耦,因为后面其他的类还要使用。
3、CompileUtil 对象中指令方法的实现
CompileUtil 中存储着所有的指令方法及指令对应的更新方法,由于 Vue 的指令很多,我们这里只实现比较典型的 v-model 和 “{{ }}” 对应的方法,考虑到后续更新的情况,我们统一把设置值到 Dom 中的逻辑抽取出对应上面两种情况的方法,存放到 CompileUtil 的 updater 对象中。
文件:CompileUtil.js
CompileUtil = {};
// 更新Dom节点方法
CompileUtil.updater = {
// 文本更新
textUpdater(node, value) {
node.textContent = value;
},
// 输入框更新
modelUpdater(node, value) {
node.value = value;
}
};
// 获取 data 值的方法
CompileUtil.getVal = function (vm, exp) {
// 将匹配的值用 . 分割开,如 vm.data.a.b
exp = exp.split(".");
// 归并取值
return exp.reduce((prev, next) => {
return prev[next];
}, vm.$data);
};
// 获取文本 {{}} 中变量在 data 对应的值
CompileUtil.getTextVal = function (vm, exp) {
// 使用正则匹配出 {{ }} 间的变量名,再调用 getVal 获取值
return exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
return this.getVal(vm, args[1]);
});
};
// 设置 data 值的方法
CompileUtil.setVal = function (vm, exp, newVal) {
exp = exp.split(".");
return exp.reduce((prev, next, currentIndex) => {
// 如果当前归并的为数组的最后一项,则将新值设置到该属性
if(currentIndex === exp.length - 1) {
return prev[next] = newVal
}
// 继续归并
return prev[next];
}, vm.$data);
}
// 处理 v-model 指令的方法
CompileUtil.model = function (node, vm, exp) {
// 获取赋值的方法
let updateFn = this.updater["modelUpdater"];
// 获取 data 中对应的变量的值
let value = this.getVal(vm, exp);
// 添加观察者,作用与 text 方法相同
new Watcher(vm, exp, newValue => {
updateFn && updateFn(node, newValue);
});
// v-model 双向数据绑定,对 input 添加事件监听
node.addEventListener('input', e => {
// 获取输入的新值
let newValue = e.target.value;
// 更新到节点
this.setVal(vm, exp, newValue);
});
// 第一次设置值
updateFn && updateFn(node, value);
};
// 处理文本节点 {{}} 的方法
CompileUtil.text = function (node, vm, exp) {
// 获取赋值的方法
let updateFn = this.updater["textUpdater"];
// 获取 data 中对应的变量的值
let value = this.getTextVal(vm, exp);
// 通过正则替换,将取到数据中的值替换掉 {{ }}
exp.replace(/\{\{(.+?)\}\}/g, (...args) => {
// 解析时遇到了模板中需要替换为数据值的变量时,应该添加一个观察者
// 当变量重新赋值时,调用更新值节点到 Dom 的方法
new Watcher(vm, args[1], newValue => {
// 如果数据发生变化,重新获取新值
updateFn && updateFn(node, newValue);
});
});
// 第一次设置值
updateFn && updateFn(node, value);
};
这部分的整个思路就是在 Compile 编译模板后处理 v-model 和 “{{ }}” 时,其实都是用 data 中的数据替换掉 fragment 文档碎片中对应的节点中的变量。因此会经常性的获取 data 中的值,在更新节点时又会重新设置 data 中的值,所以我们抽离出了三个方法 getVal、getTextVal 和 setVal 挂在了 CompileUtil 对象下。
获取和设置 data 的值两个方法 getVal 和 setVal 思路相似,由于获取的变量层级不定,可能是 data.a,也可能是 data.obj.a.b,所以都是使用归并的思路,借用 reduce 方法实现的,区别在于 setVal 方法在归并过程中需要判断是不是归并到最后一级,如果是则设置新值,而 getTextVal 就是在 getVal 外包了一层处理 “{{ }}” 的逻辑。
在这些准备工作就绪以后就可以实现我们的主逻辑,即对 Compile 类中解析的文本节点和元素节点指令中的变量用 data 值进行替换,还记得前面说针对 v-model 和 “{{ }}” 进行处理,因此设计了 model 和 text 两个核心方法。
model和text两个方法逻辑相似,都获取了各自的 updater 中的方法,对值进行设置,并且在设置的同时为了后续 data 中的数据修改,视图的更新,创建了 Watcher 的实例,并在内部用新值重新更新节点,不同的是 Vue 的 v-model 指令在表单中实现了双向数据绑定,只要表单元素的 value 值发生变化,就需要将新值更新到 data 中,并响应到页面上。
所以我们的实现方式是给这个绑定了 v-model 的表单元素监听了 input 事件,并在事件中实时的将新的 value 值更新到 data 中,至于 data 中的改变后响应到页面中需要另外三个类 Watcher、Observer 和 Dep 共同实现,我们下面就来实现 Watcher 类。
观察者 Watcher 类的实现
在 CompileUtil 对象的方法中创建 Watcher 实例的时候传入了三个参数,即 MVVM 的实例、模板绑定数据的变量名 exp 和一个 callback,这个 callback 内部逻辑是为了更新数据到 Dom,所以我们的 Watcher 类内部要做的事情就清晰了,获取更改前的值存储起来,并创建一个 update 实例方法,在值被更改时去执行实例的 callback 以达到视图的更新。
文件:Watcher.js
class Watcher {
constructor(vm, exp, callback) {
this.vm = vm;
this.exp = exp;
this.callback = callback;
// 更改前的值
this.value = this.get();
}
get() {
// 将当前的 watcher 添加到 Dep 类的静态属性上
Dep.target = this;
// 获取值触发数据劫持
let value = CompileUtil.getVal(this.vm, this.exp);
// 清空 Dep 上的 Watcher,防止重复添加
Dep.target = null;
return value;
}
update() {
// 获取新值
let newValue = CompileUtil.getVal(this.vm, this.exp);
// 获取旧值
let oldValue = this.value;
// 如果新值和旧值不相等,就执行 callback 对 dom 进行更新
if(newValue !== oldValue) {
this.callback(newValue);
}
}
}
看到上面代码一定有两个疑问:
- 使用 get 方法获取旧值得时候为什么要将当前的实例挂在 Dep 上,在获取值后为什么又清空了;
- update 方法内部执行了 callback 函数,但是 update 在什么时候执行。
这就是后面两个类 Dep 和 observer 要做的事情,我们首先来介绍 Dep,再介绍 Observer 最后把他们之间的关系整个串联起来。
发布订阅 Dep 类的实现
其实发布订阅说白了就是把要执行的函数统一存储在一个数组中管理,当达到某个执行条件时,循环这个数组并执行每一个成员。
文件:Dep.js
class Dep {
constructor() {
this.subs = [];
}
// 添加订阅
addSub(watcher) {
this.subs.push(watcher);
}
// 通知
notify() {
this.subs.forEach(watcher => watcher.update());
}
}
在 Dep 类中只有一个属性,就是一个名为 subs 的数组,用来管理每一个 watcher,即 Watcher 类的实例,而 addSub 就是用来将 watcher 添加到 subs 数组中的,我们看到 notify 方法就解决了上面的一个疑问,Watcher 类的 update 方法是怎么执行的,就是这样循环执行的。
接下来我们整合一下盲点:
- Dep 实例在哪里创建声明,又是在哪里将 watcher 添加进 subs 数组的;
- Dep 的 notify 方法应该在哪里调用;
- Watcher 内容中,使用 get 方法获取旧值得时候为什么要将当前的实例挂在 Dep 上,在获取值后为什么又清空了。
这些问题在最后一个类 Observer 实现的时候都将清晰,下面我们重点来看最后一部分核心逻辑。
数据劫持 Observer 类的实现
还记得实现 MVVM 类的时候就创建了这个类的实例,当时传入的参数是 MVVM 实例的 data 属性,在 MVVM 中把数据通过 Object.defineProperty 挂到了实例上,并添加了 getter 和 setter,其实 Observer 类主要目的就是给 data 内的所有层级的数据都进行这样的操作。
文件:Observer.js
class Observer {
constructor (data) {
this.observe(data);
}
// 添加数据监听
observe(data) {
if(!data || typeof data !== 'object') {
return;
}
Object.keys(data).forEach(key => {
// 劫持(实现数据响应式)
this.defineReactive(data, key, data[key]);
this.observe(data[key]); // 深度劫持
});
}
// 数据响应式
defineReactive (object, key, value) {
let _this = this;
// 每个变化的数据都会对应一个数组,这个数组是存放所有更新的操作
let dep = new Dep();
// 获取某个值被监听到
Object.defineProperty(object, key, {
enumerable: true,
configurable: true,
get () { // 当取值时调用的方法
Dep.target && dep.addSub(Dep.target);
return value;
},
set (newValue) { // 当给 data 属性中设置的值适合,更改获取的属性的值
if(newValue !== value) {
_this.observe(newValue); // 重新赋值如果是对象进行深度劫持
value = newValue;
dep.notify(); // 通知所有人数据更新了
}
}
});
}
}
在的代码中 observe 的目的是遍历对象,在内部对数据进行劫持,即添加 getter 和 setter,我们把劫持的逻辑单独抽取成 defineReactive 方法,需要注意的是 observe 方法在执行最初就对当前的数据进行了数据类型验证,然后再循环对象每一个属性进行劫持,目的是给同为 Object 类型的子属性递归调用 observe 进行深度劫持。
在 defineReactive 方法中,创建了 Dep 的实例,并对 data 的数据使用 get 和 set 进行劫持,还记得在模板编译的过程中,遇到模板中绑定的变量,就会解析,并创建 watcher,会在 Watcher 类的内部获取旧值,即当前的值,这样就触发了 get,在 get 中就可以将这个 watcher 添加到 Dep 的 subs 数组中进行统一管理,因为在代码中获取 data 中的值操作比较多,会经常触发 get,我们又要保证 watcher 不会被重复添加,所以在 Watcher 类中,获取旧值并保存后,立即将 Dep.target 赋值为 null,并且在触发 get 时对 Dep.target 进行了短路操作,存在才调用 Dep 的 addSub 进行添加。
而 data 中的值被更改时,会触发 set,在 set 中做了性能优化,即判断重新赋的值与旧值是否相等,如果相等就不重新渲染页面,不等的情况有两种,如果原来这个被改变的值是基本数据类型没什么影响,如果是引用类型,我们需要对这个引用类型内部的数据进行劫持,因此递归调用了 observe,最后调用 Dep 的 notify 方法进行通知,执行 notify 就会执行 subs 中所有被管理的 watcher 的 update,就会执行创建 watcher 时的传入的 callback,就会更新页面。
在 MVVM 类将 data 的属性挂在 MVVM 实例上并劫持与通过 Observer 类对 data 的劫持还有一层联系,因为整个发布订阅的逻辑都是在 data 的 get 和 set 上,只要触发了 MVVM 中的 get 和 set 内部会自动返回或设置 data 对应的值,就会触发 data 的 get 和 set,就会执行发布订阅的逻辑。
通过上面长篇大论的叙述后,这个 MVVM 模式用到的几个类的关系应该完全叙述清晰了,虽然比较抽象,但是细心琢磨还是会明白之间的关系和逻辑,下面我们就来对我们自己实现的这个 MVVM 进行验证。
验证 MVVM
我们按照 Vue 的方式根据自己的 MVVM 实现的内容简单的写了一个模板如下:
MVVM
{{message}}
- {{message}}
{{message}}
打开 Chrom 浏览器的控制台,在上面通过下面操作来验证:
- 输入 vm.message = "hello" 看页面是否更新;
- 输入 vm.$data.message = "hello" 看页面是否更新;
改变文本输入框内的值,看页面的其他元素是否更新。
总结
通过上面的测试,相信应该理解了 MVVM 模式对于前端开发重大的意义,实现了双向数据绑定,实时保证 View 层与 Model 层的数据同步,并可以让我们在开发时基于数据编程,而最少的操作 Dom,这样大大提高了页面渲染的性能,也可以使我们把更多的精力用于业务逻辑的开发上。