站点图标

MVVM 简单实现

2020-04-20折腾记录Vue / JavaScript / 前端
本文最后更新于 607 天前,文中所描述的信息可能已发生改变

什么是 MVVM?

MVVMModel-View-ViewModel 的缩写,最早由微软提出,其中的 Model 和 View 想必大家都很清楚了,这就只讲 ViewModel 吧,ViewModel 是 View 和 Model 中的一座桥梁,将 View 和 Model 进行绑定,使得 Model 中数据的变化可以传递给 View 引起视图的变化,反之亦可,这一过程是自动化的,这种轻量级的架构使得开发更加高效便捷,同时也保证视图和数据的一致性。

f3a4b197 fca4 45d0 beb2 78c7169f41b9

MVVM 模型

谈谈 MVVM 框架

MVVM 在挺多的地方都有使用,如 Android,Web 等,这里就只讨论 Web。

在前端 Vue.js 提供了类似于 MVVM 的数据双向绑定方式,它的核心是 ViewModel,主要负责 View 和 Model 的数据绑定。

实例如下:


_18
<!-- View -->
_18
<div id="app">
_18
<input type="text" v-model="msg" />
_18
<input type="text" v-model="msg" />
_18
</div>
_18
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.min.js"></script>
_18
<script>
_18
// Model
_18
let data = {
_18
msg: "value"
_18
};
_18
_18
// ViewModel
_18
let vm = new Vue({
_18
el: "#app",
_18
data: data
_18
});
_18
</script>

当第一个 input 改变值的时候,第二个 input 也会跟着改变,反过来也是可以的,然而我们并没有写两者绑定的代码,这一过程是 ViewModel 自动为我们完成的。

实现一个 MVVM

这里就不谈 Vue.js 是如何实现的了,原理都差不多,网上相关的文章也很多,可以自行搜索查看,本篇文章主要讲述 MVVM Demo 的实现原理和过程。

流程

首先先放一张流程图:

493facdd 0059 4bbf af03 6204af8d62f5

从图中可以比较清楚的看到创建 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 方法:


_10
functon get(obj, prop) {
_10
// 一个临时指向 watcher 的指针,当有新的 watcher 创建时,会首次获取绑定数据,此时就会将 watcher 绑定到 dispatcher
_10
if (Dispatcher.target) {
_10
dispatcher.bind(prop);
_10
}
_10
if (Dispatcher.targetFor) {
_10
dispatcher.bindFor();
_10
}
_10
return obj[prop];
_10
}

在创建 Watcher 的时候,会设置 Dispatcher.target 为当前 Watcher,并且因为模板中并没有绑定数据,所以要从 Model 中首次获取数据,此时就会调用对象的 get 陷阱方法,也就是上面的代码,判断 Dispatcher.target 不为空的时候就会将 Dispatcher.target 的 Watcher 绑定到该对象的 Dispatcher 上此时 Model 就和 View 绑定在了一起。Dispatcher.targetFor 同理,只不过这个指向的 Watcher 是父元素,用于 for 循环。

然后是 set 方法:


_12
function set(obj, prop, value) {
_12
if (obj[prop] !== value) {
_12
obj[prop] = _this.observable(value);
_12
// 通知数据更新
_12
dispatcher.notify();
_12
}
_12
// Hook length 属性以监听数组的增加
_12
if (prop === "length" && dispatcher.for) {
_12
dispatcher.notifyFor();
_12
}
_12
return true;
_12
}

由于设置值的时候也可以设置相同的值,此时一样会触发 set 陷阱方法,会造成不必要的更新,同时可能会导致死循环,所以需要在真正设置值和 notify 前对其进行节流,当新值和旧值相同时就不操作,直接返回 true(Proxy 的 set 方法要求返回 true,返回 false 或不返回会抛出异常),如果值不同,就可以设置,不过由于设置的可能是一个对象,这个对象可能不是 Proxy 对象所以还需要递归的代理该对象。设置完毕后就可以调用 notify 方法来通知 Watcher 更新视图了。

由于数组有可能新增或减少,所以可以判断更改的是否是 length 属性,如果是则通知 for 循环的 Watcher

deleteProperty 和 set 差不多,这里就不讲了。

模板编译

劫持数据说完了,接下来就是模板编译了。编译普通的模板其实挺简单的,比如这个编译纯文本:


_10
// <div x-text="msg">
_10
function compileText(node, before = "") {
_10
node.textContent = new Watcher(
_10
before + node.getAttribute("x-text"),
_10
(value) => {
_10
node.textContent = value;
_10
},
_10
this.vm
_10
).value;
_10
}

代码其实很简单就是创建一个 Watcher,创建 Watcher 的时候 Watcher 会将获取到的值(第一次的值)保存到 value 属性中,只需要将指定节点的 textContent 属性设置为这个 value 就可以了。

不过除了像文本节点和属性节点外,还有 input 这种输入节点,此时还需要为 input 增加监听器,当 input 更改的时候触发监听器设置 Model:


_13
function compileModel(node, before = "") {
_13
let key = node.getAttribute("x-model");
_13
node.value = new Watcher(
_13
before + key,
_13
(value) => {
_13
node.value = value;
_13
},
_13
this.vm
_13
).value;
_13
node.addEventListener("change", (e) => {
_13
this.vm.set(key, node.value);
_13
});
_13
}

Dispatcher & Watcher

Dispatcher 和 Watcher 这里就不介绍了,其实就是订阅者模式稍微改变下而已,具体可以自行查阅。( ̄︶ ̄)↗

使用

使用的方法有点类似于 Vue:


_31
<!DOCTYPE html>
_31
<html lang="zh">
_31
<head>
_31
<meta charset="UTF-8" />
_31
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
_31
<title>Document</title>
_31
</head>
_31
<body>
_31
<div id="app">
_31
<h1 x-text="titleText"></h1>
_31
<h2 x-html="titleHtml"></h2>
_31
<input x-model="inputModel" />
_31
<textarea x-model="textareaModel" cols="5" rows="5"></textarea>
_31
<select x-model="selectModel">
_31
<option value="true">true</option>
_31
<option value="false"></option>
_31
</select>
_31
<img x-bind="src:imgBind" />
_31
<ul x-for="item in ulFor">
_31
<li>
_31
<p x-class="item.class" x-text="item.value"></p>
_31
</li>
_31
</ul>
_31
<div x-show="divShow">Show</div>
_31
<div>
_31
<button x-if="btnIf === 1">1</button>
_31
</div>
_31
</div>
_31
<script src="vm.js"></script>
_31
</body>
_31
</html>


_23
let vm = new Vm({
_23
el: "#app",
_23
data: {
_23
titleText: "title",
_23
titleHtml: "title",
_23
inputModel: "input",
_23
textareaModel: "textarea",
_23
selectModel: true,
_23
imgBind: "https://ixk.me/assets/img/1.jpg",
_23
ulFor: [
_23
{
_23
class: "class-1",
_23
value: 1
_23
},
_23
{
_23
class: "class-2",
_23
value: 2
_23
}
_23
],
_23
divShow: true,
_23
btnIf: 1
_23
}
_23
});

结语

总算码完了这篇文章,具体的代码可以到 Github 上查看,文中可能有错误或不足之处,如果您发现了问题欢迎反馈。[]( ̄ ▽  ̄)*

MVVM 简单实现

https://blog.ixk.me/post/mvvm-simple-implementation
  • 许可协议

    BY-NC-SA

  • 发布于

    2020-04-20

  • 本文作者

    Otstar Lin

转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!

从零实现一个 PHP 微框架 - 前言浅谈 DI 和 IoC