MVVM模式原理与实现
序言
看到vue的框架是参考了mvvm的模式,因为概念有点模糊,所以我做了一定的搜索及学习,整理了以下文章。
参考的文章及网页网址在第四部分,整理的是我个人的理解和实践过程。如果有哪里不正确,请大家给予合适的建议,谢谢。
一、什么是MVVM
MVVM是Model-View-ViewModel的简写。本质上是MVC的改进版,MVVM,其中的View是视图状态及行为抽象化出来,Model去实现业务逻辑,而ViewModel作为中枢,实现视图UI和业务数据的更新。
二、MVVM对象实现的整体思路 图片来源
(1)主体是实现一个MVVM类对象
(2)该对象包含三个对象,第一个对象是Observer,即监听者;第二个对象是Compile,即解析器;第三个对象是Watcher,即订阅者。
(3)Observer用来监听和劫持MVVM类的对象所有属性的数据变化,并且对变化的数据发布通知(告诉相对应的订阅者,Dep:某个定义的属性对应相关的订阅者)。
(4)Compile对视图模板(template
)进行编译,包括编译元素(指令v-
)、编译文本({{text}}
)等。达到可初始化视图、绑定更新视图的函数的目的。更新数据设置了更新器方法(Updater),做对相关数据相关更新的解析编译处理。
(5)Watcher作为一个中枢,接收Observer发来更新数据的通知和执行compile相应的视图更新方法,其中,Watcher并不是直接获取所有Observer的数据,而是通过一个中间者,添加一个订阅器(Dep),通过订阅相应监听的属性进行接收并使Compile更新数据。
简要理解:Observer是M,即Model;Compile是V,即View;Watcher是VM,即ViewModel
三、实现方法
vue虽然没有完全的遵循MVVM模式,但是这里参考vue的一些语法来写一个mvvm模式的对象。
1. 一个html文件看主体框架
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MVVM模式的简单尝试</title>
</head>
<body>
<!-- 视图模板区 -->
<div id="app">
<!-- 数据的双向绑定 指令v-model需要进行渲染 -->
<input type="text" v-model="message">
<!-- 数据文本更新 文本节点需要进行渲染-->
<div>{{ message }}</div>
<!-- 对DOM元素的控制,生成新数据 -->
</div>
</body>
<!-- 注意脚本的引用顺序 -->
<script src="js/watcher.js"></script>
<script src="js/observer.js"></script>
<script src="js/compile.js"></script>
<script src="js/mvvm.js"></script>
<script>
// 初始化视图前的逻辑声明
let vm = new MVVM({
el: '#app',
data: {
message: 'hello mvvm!',
obj: {
k: 'hello world!'
}
}
})
</script>
</html>
2.MVVM对象的初始定义(MVVM对象实例化之后作为程序入口)
// mvvm.js
// MVVM对象声明
class MVVM {
constructor (options) { // 定义构造函数 options 对象类型
this.$el = options.el
this.$data = options.data
}
}
3.Compile对象的定义(实例对元素节点的控制,作为模板编译)
根据一开始的文本定义,compile要实现两个大功能:1.视图初始化 2.绑定视图模板数据更新函数
视图初始化:将MVVM对象一开始挂载时的视图数据进行页面初始化挂载,即,将MVVM设置数据进行模板编译
绑定视图模板数据更新函数:这里要分两个方面:(1)视图页面节点处理 (2)视图涉及的属性,指令及文本进行编译
// compile.js
// Compile对象声明
class Compile {
// 功能1. 视图进行初始化
// 获取MVVM对象传递过来的el, el挂载的页面元素,及vm自身的数据
constructor (el, vm) {
// el是元素节点时,返回el,否则返回el对应字符的元素节点
this.el = this.isElementNode(el) ? el : document.querySelector(el)
this.vm = vm
// 数据初始化及数据or更新编译渲染页面
let fragment = this.nodeToFragment(this.el)
this.compile(fragment)
this.el.appendChild(fragment)
}
// 判断node是否为元素节点
isElementNode (node) { // nodeType为1时指元素节点,2为属性节点,3为文本节点,11为片段节点
return node.nodeType === 1
}
// 判断属性中是否包含v-,是否为v-指令
isDirective (name) {
return name.includes('v-')
}
// 功能2. 绑定视图模板数据的更新函数
// (1)视图页面节点处理
// 直接对元素节点操作,页面会进行多次渲染,降低性能
// 这里使用DocumentFragment节点做数据更新
// 创造没有根的虚拟DOM的Fragment片段存储el元素节点
// 最后,对Fragment节点操作后加入DOM,提高性能
// el元素节点内容存入Fragment节点中并返回
nodeToFragment (el) {
let fragment = document.createDocumentFragment()
let firstChild
// 遍历取出el的子元素节点加入fragment中,直至为空
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild)
}
return fragment
}
// (2)视图涉及的属性,指令及文本进行编译
// 递归方法
// 递归提取 元素节点、文本节点与指令(如:v-model)进行编译
compile (fragment) {
let childNodes = fragment.childNodes
Array.from(childNodes).forEach(node => {
// 是元素节点
if (this.isElementNode(node)) {
this.compileElement(node) // 调用元素节点编译方法
this.compile(node)// 递归深入查找是否还有节点
} else { // 不是元素节点
this.compileText(node) // 调用文本节点编译方法
}
})
}
// 元素节点编译方法:对元素节点属性及指令进行编译
compileElement (node) {
let attrs = node.attributes
Array.from(attrs).forEach(attr => {
let attrName = attr.name // 获取元素节点属性名数组
// 判断是否包含v-指令
if(this.isDirective(attrName)) {
// 获取该指令的值,类型,如:v-model="message"
let expr = attr.value // 值: message
let [,type] = attrName.split('-') // 类型:model
compileUtil[type](node, this.vm, expr) // 解析对应指令更新数据
}
})
}
// 文本节点编译方法:对文本节点进行编译
compileText (node) {
let expr = node.textContent // 获取文本节点的内容
let reg = /\{\{([^}]+)\}\}/g // {{ message }}
if (reg.test(expr)) { // 是否匹配给文本渲染格式
compileUtil['text'](node, this.vm, expr) // 对该文本进行绑定渲染更新
} // 不是的话保持原样
}
}
// 工具类
// 解析不同指令和文本节点编译
const compileUtil = {
text(node, vm, expr) { // 文本
let updater = this.updater['textUpdater']
updater && updater(node, getTextValue(vm, expr))
},
model(node, vm, expr) { // 指令:model
let updater = this.updater['modelUpdater']
updater && updater(node, getValue(vm, expr))
},
// 数据函数更新器
updater: {
// 文本渲染
textUpdater(node, value) {
node.textContent = value
},
// 指令属性数据更新:model
modelUpdater(node, value) {
node.value = value
}
}
}
// 工具辅助类
// 绑定key对应的value 从vm.$data中匹配获取
const getValue = (vm, expr) => {
expr = expr.split('.')
return expr.reduce((prev, next) => {
return prev[next]
}, vm.$data)
}
// 获取文本节点编译后的数据
const getTextValue = (vm, expr) => {
return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
return getValue(vm, arguments[1].replace(/[\s]+/g, ''))
})
}
// 更新key对应的value数据
const setValue = (vm, expr, newValue) => {
expr = expr.split('.')
vm.$data[expr[0]] = newValue
}
4.Observer对象的定义(数据劫持、监听及通知)
Observer实现的是对属性的监听或劫持,并在属性对应数据发生改变时,发布消息通知给相应的订阅器(Dep),使触发相应页面数据更新回调。
属性数据的设置,涉及原生的javascript
方法Object.defineProperty()
,数据的变化需要设置属性的setter
与getter
方法数据变化的回调。仅设置劫持与监听时,数据变化不会影响到页面数据的变化,因为没有通知订阅者去更新页面数据的变化。
订阅器的设置, 设置存储当前对象的所有属性,在数据变化时,通知(触发)订阅者执行页面数据更新方法。这里仅设置了定义,还未引用。
// observer.js
class Observer {
constructor (data) {
// 获取并监听对象的vm.$data数据
// 递归遍历数据对象,包含子数据对象的属性,设置对象的setter和getter
this.observer(data)
}
// 为数据的属性设置get和set方式
observer (data) {
if (!data || typeof data !== 'object') {
return
}
console.log('原数据对象:', data)
// 对数据一一劫持处理,获取key和value并设置getter和setter
Object.keys(data).forEach((key) => {
this.defineReactive(data, key, data[key]) // 劫持getter,监听setter
this.observer(data[key]) // 深度递归劫持,子数据对象的属性
})
}
// 定义响应的方式:设置getter和setter
defineReactive (obj, key, value) {
let _this = this
let dep = new Dep() // 设置订阅器
// 设置数据属性
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() { // 定义getter,取值
return value
},
set(newValue) { // 定义setter,新值时赋值
if (newValue !== value) {
_this.observer(newValue) // 新值是对象,进行劫持处理
console.log('原数据:', value, '--> 新值:', newValue)
value = newValue
}
}
})
}
}
// 消息订阅器Dep
class Dep {
constructor () {
// 订阅属性的数量
this.subs = []
}
// 添加订阅的属性
addSub (watcher) {
this.subs.push(watcher)
}
// 通知订阅者,并执行订阅者的更新数据回调
notify () {
this.subs.forEach(watcher => watcher.update())
}
}
5.Watcher对象的定义(订阅中心)
Watcher是作为一个中枢,连接Observer和Compile之间的通信,需要做到:实现两者的连接,并在获取到Observer的数据变化,及时让Compile执行页面数据变化。
(1)在自身的构造函数中:1. 设置触发compile的更新页面属性的数据回调方法 2.设置自身实例化时,在Observer的属性订阅器里添加自己的原数据
(2)当属性数据变化时,自身有update
方法,能接收Observer
的notify
方法通知,并触发Compile
中,自身绑定的更新回调方法。
// watcher.js
class Watcher {
constructor (vm, expr, cb) {
this.vm = vm
this.expr = expr
this.cb = cb // 回调函数,执行compile对象数据的更新方法
// 实例化时,往订阅器里添加自己,以作初始化原数据存储
this.value = this.get()
}
// 获取实例上变化的数据
getValue (vm, expr) {
expr = expr.split('.')
return expr.reduce((prev, next) => {
return prev[next]
}, vm.$data)
}
// 获取Observer订阅器的属性数据
get () {
// 获取当前订阅器
Dep.target = this
// 触发observer的劫持监听数据vm.$data方法
let value = this.getValue(this.vm, this.expr)
// 重置订阅器
Dep.target = null
return value
}
// 获取到通知信号时,执行的更新方法
update () {
let newValue = this.getValue(this.vm, this.expr)
let oldValue = this.value
if (newValue !== oldValue) {
this.cb(newValue) // 执行Compile数据更新回调函数
}
}
}
6.初始化页面【使用步骤一】
在mvvm.js中,加入设置compile对象编译模板,页面就初始化成功,此时,没有observer与watcher的页面,修改输入框的数据,message的数据不会变化。
// mvvm.js
// MVVM对象声明
class MVVM {
constructor (options) { // 定义构造函数 options 对象类型
this.$el = options.el
this.$data = options.data
if (this.$el) { // $el不为空的时候,进行模板编译
new Compile(this.$el, this)
}
}
}
初始化页面显示:
修改输入框数据: 打印的this.$data
数据显示
7.加入Observer,实现对MVVM对象数据所有的属性劫持,监听与通知【使用步骤二】
在mvvm.js中,加入Observer。
只有劫持getter
和监听setter
时,可以在控制台劫持到节点的原数据,监听到控制台控制数据的变化。此时,订阅器的消息发给Watcher,没有加入Watcher,则页面元素数据没有变化。
// mvvm.js
// MVVM对象声明
class MVVM {
constructor (options) { // 定义构造函数 options 对象类型
this.$el = options.el
this.$data = options.data
console.log(this.$data)
if (this.$el) { // $el不为空的时候,进行模板编译
new Observer(this.$data) // 加入数据劫持和监听
new Compile(this.$el, this) // 视图模板编译
}
}
}
劫持到的初始化页面原数据:
监听到的控制台控制的数据变化数据:
8.加入Watcher,实现数据的双向绑定传输【使用步骤三】
在observer.js引入订阅器,在数据变化时,使watcher能劫持监听到数据。
在compile.js中使用watcher对象,使watcher收到数据更新时,能及时更新compile的数据并进行页面数据渲染。
// observer.js 中(1)添加订阅器 (2)通知订阅者
// 定义响应的方式:设置getter和setter
defineReactive (obj, key, value) {
let _this = this
let dep = new Dep() // 设置订阅器
// 设置数据属性
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() { // 定义getter,取值
// (1)添加订阅器,在取值时,将订阅者存入订阅器数组中
Dep.target && dep.addSub(Dep.target)
return value
},
set(newValue) { // 定义setter,新值时赋值
if (newValue !== value) {
_this.observer(newValue) // 新值是对象,进行劫持处理
console.log('原数据:', value, '--> 新值:', newValue)
value = newValue
dep.notify() // (2)通知订阅者,数据更新,去执行数据更新方法
}
}
})
}
// compile.js 这里仅实现了对model的属性数据变化,在工具类中的model设置订阅中心变化
// 工具类
// 解析不同指令和文本节点编译
const compileUtil = {
text(node, vm, expr) { // 文本
let updater = this.updater['textUpdater']
updater && updater(node, getTextValue(vm, expr))
},
model(node, vm, expr) { // 指令:model
let updater = this.updater['modelUpdater']
// 设置订阅中心,做数据相通
new Watcher(vm, expr, (newValue) => {
updater && updater(node, getValue(vm, expr))
})
// 监听input事件,input数据变化时,进行数据更新
node.addEventListener('input', (e) => {
let newValue = e.target.value
setValue(vm, expr, newValue)// 数据更新
})
updater && updater(node, getValue(vm, expr))
},
// 数据函数更新器
updater: {
// 文本渲染
textUpdater(node, value) {
node.textContent = value
},
// 指令属性数据更新:model
modelUpdater(node, value) {
node.value = value
}
}
}
最终实现效果:
四、参考文章
参考链接:
1.[vue中MVVM原理及其实现](https://segmentfault.com/a/1190000018399478/)
2.[vue官方文档:vue实例](https://cn.vuejs.org/v2/guide/instance.html)
3.[MVVM-百度百科](https://baike.baidu.com/item/MVVM/96310?fr=aladdin)
4.[Fragment节点](https://wangdoc.com/javascript/dom/text.html)
5.[JavaScript对象面向编程](https://wangdoc.com/javascript/oop/index.html)