MVVM模式原理与实现

MVVM模式原理与实现

序言

看到vue的框架是参考了mvvm的模式,因为概念有点模糊,所以我做了一定的搜索及学习,整理了以下文章。
参考的文章及网页网址在第四部分,整理的是我个人的理解和实践过程。如果有哪里不正确,请大家给予合适的建议,谢谢。

一、什么是MVVM

MVVM是Model-View-ViewModel的简写。本质上是MVC的改进版,MVVM,其中的View是视图状态及行为抽象化出来,Model去实现业务逻辑,而ViewModel作为中枢,实现视图UI和业务数据的更新。

二、MVVM对象实现的整体思路 图片来源

https://segmentfault.com/a/1190000018399478/

(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()数据的变化需要设置属性的settergetter方法数据变化的回调。仅设置劫持与监听时,数据变化不会影响到页面数据的变化,因为没有通知订阅者去更新页面数据的变化。

订阅器的设置, 设置存储当前对象的所有属性,在数据变化时,通知(触发)订阅者执行页面数据更新方法。这里仅设置了定义,还未引用。

// 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方法,能接收Observernotify方法通知,并触发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) // 视图模板编译
        }
    }
}

劫持到的初始化页面原数据:

getter

监听到的控制台控制的数据变化数据:

setter

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)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值