什么是 MVVM?
MVVM 即 Model-View-ViewModel 的缩写,最早由微软提出,其中的 Model 和 View 想必大家都很清楚了,这就只讲 ViewModel 吧,ViewModel 是 View 和 Model 中的一座桥梁,将 View 和 Model 进行绑定,使得 Model 中数据的变化可以传递给 View 引起视图的变化,反之亦可,这一过程是自动化的,这种轻量级的架构使得开发更加高效便捷,同时也保证视图和数据的一致性。
MVVM 模型
谈谈 MVVM 框架
MVVM 在挺多的地方都有使用,如 Android,Web 等,这里就只讨论 Web。
在前端 Vue.js 提供了类似于 MVVM 的数据双向绑定方式,它的核心是 ViewModel,主要负责 View 和 Model 的数据绑定。
实例如下:
<!-- View -->
<div id="app">
<input type="text" v-model="msg" />
<input type="text" v-model="msg" />
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
<script>
// Model
let data = {
msg: "value",
};
// ViewModel
let vm = new Vue({
el: "#app",
data: data,
});
</script>
<!-- View -->
<div id="app">
<input type="text" v-model="msg" />
<input type="text" v-model="msg" />
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
<script>
// Model
let data = {
msg: "value",
};
// ViewModel
let vm = new Vue({
el: "#app",
data: data,
});
</script>
当第一个 input 改变值的时候,第二个 input 也会跟着改变,反过来也是可以的,然而我们并没有写两者绑定的代码,这一过程是 ViewModel 自动为我们完成的。
实现一个 MVVM
这里就不谈 Vue.js 是如何实现的了,原理都差不多,网上相关的文章也很多,可以自行搜索查看,本篇文章主要讲述 MVVM Demo 的实现原理和过程。
流程
首先先放一张流程图:
从图中可以比较清楚的看到创建 MVVM 时需要对 模板(View) 进行编译,对 数据(Model) 进行劫持,然后创建 监听器(Watcher) 以及 发布器(Dispatcher),最后将 Watcher 绑定到 Dispatcher 上即可完成 Model 绑定 View,对于可以修改的 View 元素,如 input,则需要同时创建一个更新事件,当可修改的元素发生改变后则直接修改 Model 中的数据,此时就完成了 View 绑定 Model。
Model 到 View 的更新过程如上图蓝色路线,当被劫持的数据即 Model 发生改变后,则调用与之绑定的 Dispatcher 的 notify 方法,notify 方法则会调用与其绑定的每个 Watcher 的 update 方法,update 方法会调用对应的元素的更新回调方法,如普通的文本节点修改的是 textContent,input 元素修改的是 value,具体的修改方法是在创建 Watcher 的时候指定的,更新回调执行完毕后对应的更改就会反映到 View 上。
View 到 Model 的更新过程如上图绿色路线,当 View 修改的时候,会触发对应的更新事件,更新事件是在编译模板的时候就指定的,该更新事件会直接对 Model 的数据进行修改。不过此时同样会触发 Model 的 set,为了防止死循环就需要进行节流,即判断新值是否和旧值相同,如果相同则不调用 Dispatcher 的 notify 方法。
实现
相关的流程讲完了,那么就进入实现的过程吧。ヾ(≧ ▽ ≦)ゝ
创建 VM
创建 VM 的 过程 很简单,就是 劫持数据 和 编译模板:
劫持数据
劫持数据的方法目前有挺多种的,可以使用传统的观察者模式,或者使用 Object.defineProperty,也可以使用 Proxy 的方式。本文劫持数据的方式使用的是 Proxy。
Proxy 只能代理对象,而不能代理基本数据类型,所以 Vue 3.0 的 Ref 为了代理基本数据类型将基本数据类型封装成只有一个 value 键的对象,不过本文就不弄那么复杂了,遇到基本数据类型就 直接返回值。由于 Proxy 并不能深度代理数据,所以需要递归的对每一个对象进行代理。
当发现这个对象没有子对象的时候就可以创建 Proxy 代理对象了,不过在这之前需要先创建该对象的Dispatcher,作为该对象改变的通知调度器。Proxy 可以定义多种陷阱方法,这里就只设置 set,get 和 deleteProperty,下面就具体说下这 3 个方法。
首先是 get 方法:
functon get(obj, prop) {
// 一个临时指向 watcher 的指针,当有新的 watcher 创建时,会首次获取绑定数据,此时就会将 watcher 绑定到 dispatcher
if (Dispatcher.target) {
dispatcher.bind(prop);
}
if (Dispatcher.targetFor) {
dispatcher.bindFor();
}
return obj[prop];
}
functon get(obj, prop) {
// 一个临时指向 watcher 的指针,当有新的 watcher 创建时,会首次获取绑定数据,此时就会将 watcher 绑定到 dispatcher
if (Dispatcher.target) {
dispatcher.bind(prop);
}
if (Dispatcher.targetFor) {
dispatcher.bindFor();
}
return obj[prop];
}
在创建 Watcher 的时候,会设置 Dispatcher.target 为当前 Watcher,并且因为模板中并没有绑定数据,所以要从 Model 中首次获取数据,此时就会调用对象的 get 陷阱方法,也就是上面的代码,判断 Dispatcher.target 不为空的时候就会将 Dispatcher.target 的 Watcher 绑定到该对象的 Dispatcher 上,此时 Model 就和 View 绑定在了一起。Dispatcher.targetFor 同理,只不过这个指向的 Watcher 是父元素,用于 for 循环。
然后是 set 方法:
function set(obj, prop, value) {
if (obj[prop] !== value) {
obj[prop] = _this.observable(value);
// 通知数据更新
dispatcher.notify();
}
// Hook length 属性以监听数组的增加
if (prop === "length" && dispatcher.for) {
dispatcher.notifyFor();
}
return true;
}
function set(obj, prop, value) {
if (obj[prop] !== value) {
obj[prop] = _this.observable(value);
// 通知数据更新
dispatcher.notify();
}
// Hook length 属性以监听数组的增加
if (prop === "length" && dispatcher.for) {
dispatcher.notifyFor();
}
return true;
}
由于设置值的时候也可以设置相同的值,此时一样会触发 set 陷阱方法,会造成不必要的更新,同时可能会导致死循环,所以需要在真正设置值和 notify 前对其进行节流,当新值和旧值相同时就不操作,直接返回 true(Proxy 的 set 方法要求返回 true,返回 false 或不返回会抛出异常),如果值不同,就可以设置,不过由于设置的可能是一个对象,这个对象可能不是 Proxy 对象,所以还需要递归的代理该对象。设置完毕后就可以调用 notify 方法来通知 Watcher 更新视图了。
由于数组有可能新增或减少,所以可以判断更改的是否是 length 属性,如果是则通知 for 循环的 Watcher。
deleteProperty 和 set 差不多,这里就不讲了。
模板编译
劫持数据说完了,接下来就是模板编译了。编译普通的模板其实挺简单的,比如这个编译纯文本:
// <div x-text="msg">
function compileText(node, before = "") {
node.textContent = new Watcher(
before + node.getAttribute("x-text"),
(value) => {
node.textContent = value;
},
this.vm
).value;
}
// <div x-text="msg">
function compileText(node, before = "") {
node.textContent = new Watcher(
before + node.getAttribute("x-text"),
(value) => {
node.textContent = value;
},
this.vm
).value;
}
代码其实很简单就是创建一个 Watcher,创建 Watcher 的时候 Watcher 会将获取到的值(第一次的值)保存到 value 属性中,只需要将指定节点的 textContent 属性设置为这个 value 就可以了。
不过除了像文本节点和属性节点外,还有 input 这种输入节点,此时还需要为 input 增加监听器,当 input 更改的时候触发监听器设置 Model:
function compileModel(node, before = "") {
let key = node.getAttribute("x-model");
node.value = new Watcher(
before + key,
(value) => {
node.value = value;
},
this.vm
).value;
node.addEventListener("change", (e) => {
this.vm.set(key, node.value);
});
}
function compileModel(node, before = "") {
let key = node.getAttribute("x-model");
node.value = new Watcher(
before + key,
(value) => {
node.value = value;
},
this.vm
).value;
node.addEventListener("change", (e) => {
this.vm.set(key, node.value);
});
}
Dispatcher & Watcher
Dispatcher 和 Watcher 这里就不介绍了,其实就是订阅者模式稍微改变下而已,具体可以自行查阅。( ̄︶ ̄)↗
使用
使用的方法有点类似于 Vue:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">
<h1 x-text="titleText"></h1>
<h2 x-html="titleHtml"></h2>
<input x-model="inputModel" />
<textarea x-model="textareaModel" cols="5" rows="5"></textarea>
<select x-model="selectModel">
<option value="true">true</option>
<option value="false"></option>
</select>
<img x-bind="src:imgBind" />
<ul x-for="item in ulFor">
<li>
<p x-class="item.class" x-text="item.value"></p>
</li>
</ul>
<div x-show="divShow">Show</div>
<div>
<button x-if="btnIf === 1">1</button>
</div>
</div>
<script src="vm.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app">
<h1 x-text="titleText"></h1>
<h2 x-html="titleHtml"></h2>
<input x-model="inputModel" />
<textarea x-model="textareaModel" cols="5" rows="5"></textarea>
<select x-model="selectModel">
<option value="true">true</option>
<option value="false"></option>
</select>
<img x-bind="src:imgBind" />
<ul x-for="item in ulFor">
<li>
<p x-class="item.class" x-text="item.value"></p>
</li>
</ul>
<div x-show="divShow">Show</div>
<div>
<button x-if="btnIf === 1">1</button>
</div>
</div>
<script src="vm.js"></script>
</body>
</html>
let vm = new Vm({
el: "#app",
data: {
titleText: "title",
titleHtml: "title",
inputModel: "input",
textareaModel: "textarea",
selectModel: true,
imgBind: "https://ixk.me/assets/img/1.jpg",
ulFor: [
{
class: "class-1",
value: 1,
},
{
class: "class-2",
value: 2,
},
],
divShow: true,
btnIf: 1,
},
});
let vm = new Vm({
el: "#app",
data: {
titleText: "title",
titleHtml: "title",
inputModel: "input",
textareaModel: "textarea",
selectModel: true,
imgBind: "https://ixk.me/assets/img/1.jpg",
ulFor: [
{
class: "class-1",
value: 1,
},
{
class: "class-2",
value: 2,
},
],
divShow: true,
btnIf: 1,
},
});
结语
总算码完了这篇文章,具体的代码可以到 Github 上查看,文中可能有错误或不足之处,如果您发现了问题欢迎反馈。[]~( ̄ ▽  ̄)~*
MVVM 简单实现
https://blog.ixk.me/post/mvvm-simple-implementation许可协议
BY-NC-SA
本文作者
Otstar Lin
发布于
2020/04/20
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!