基于Vue的MVVM学习笔记

2019-06-14 admin

什么是MVVM

MVVM——Model-View-ViewModle的缩写,MVC设计模式的改进版。Model是我们应用中的数据模型,View是我们的UI层,通过ViewModle,可以把我们Modle中的数据映射到View视图上,同时,在View层修改了一些数据,也会反应更新我们的Modle。

上面的话,未免太官方了。简单理解就是双向数据绑定,即当数据发生变化的时候,视图也就发生变化,当视图发生变化的时候,数据也会跟着同步变化。

图片描述

MVVM这种思想的前端框架其实老早就有了,我记得是在13年,自己在公司的主要工作是做后台管理系统的UI设计和开发,当时就思考,如何让那些专注后台的开发,既简单又方便的使用前端开发的一些组件。当时有三种方案:

  • 使用Easy-ui,但easy-ui好像官方要求收费,当然也可以破解使用
  • 自己开发UI框架,其实当时想做的东西就是后来BootStrap
  • 使用谷歌的Angular,进行二次开发

后来的评估是:

  1. 使用easy-ui,工作量太多
  2. 使用Angular和easy-ui不仅工作量很大,后台也要做相应的修改
  3. 自己写UI框架,比较合适,当时的做法是写一些jQuery相关的插件,先给后台一个js插件包,后续的UI修改,慢慢进行。

当时自己还是比较推崇Angular的,我记得后来还买了一本《基于MVC的Javascript Web富应用开发》专门去了解这种模式在工作中可能用的情况,以及实现它的一些基本思路。

当时热点比较高的MVVM框架有:

  • Angular:谷歌出品,名气很大,入门高,使用麻烦,它提供了很多新的概念。
  • Backbone.js,入门要求级别很高,我记得当时淘宝有些项目应用了这个,《基于MVC富应用开发》书里面也是以这个框架为主介绍MVC的。
  • Ember:大而全的框架,开始写代码之前就已经有很多的工作要做了。

当年的环境和条件都没有现在好,无论从技术完善的情况,还是工作的实际情况上面看,都是如此——那时候前后端分离都是理想。

当然现在环境好了,各种框架的出现也极大方便了我们,提高了我们开发的工作效率。时代总是在进步,大浪淘沙,MVVM的框架现在比较热门和流行的,我相信大家现在都知道,就是下面三种了:

现在Angular除了一些忠实的拥趸,基本上也就没落了。Angular无论从入门还是实际应用方面,都要比其他两个框架发费的时间成本更大。 Angular现在有种英雄末路的感觉,但不能不承认,之前它确实散发了光芒。

Angular的1.x版本,是通过脏值检测来实现双向绑定的。

而最新的Angular版本和Vue,以及React都是通过数据劫持+发布订阅模式来实现的。

脏值检测

简单理解就是,把老数据和新数据进行比较,就表示之前存在过,有过痕迹,通过比较新旧数据,来判断是否要更新。感兴趣的可以看看这篇文章 构建自己的AngularJS,第一部分:作用域和digest

数据劫持 发布订阅

数据劫持:在访问或者修改对象的某个属性时,通过代码拦截这个行为,进行额外的操作或者修改返回结果。在ES5当中新增了Object.defineProperty()可以帮我们实现这个功能。

发布订阅:现在每个人应该都用微信吧,一个人可以关注多个公众号,多个人可以同时关注相同的公众号。关注的动作就相当于订阅。公众号每周都会更新内容,并推送给我们,把写好的文章在微信管理平台更新就好了,点击推送,就相当于发布。更详细的可以深入阅读 javascript设计模式——发布订阅模式

怎么实现一个MVVM

我们静下心好好思考下,如果才能实现双向数据绑定的功能。可能需要:

  • 一个初始化实例的类
  • 一个存放数据的对象Object
  • 一个可以把我们的数据映射到HTML页面上的“模板解析”工具
  • 一个更新数据的方法
  • 一个通过监听数据的变化,更新视图的方法
  • 一个挂载模板解析的HTML标签

通过上面这样的思考,我们可以简单的写一下大概的方法。

class MVVM {
    constructor(data){
        this.$option = option;
        const data = this._data = this.$option.data;

        //数据劫持
        observe(data)

        //数据代理
        proxyData(data)

        //编译模板
        const dom = this._el = this.$option.el;
        complie(dom,this);

        //发布订阅

        //连接视图和数据

        //实现双向数据绑定   
    }
}

// Observe类
function Observe(){}

// Observe实例化函数
function observe(data){
    return new Observe(data);
}

// Compile类
function Compile(){}

// Compile实例化函数
function compile(el){
    return new Compile(el)
}

数据劫持

我们有下面这样一个对象

let obj = {
    name:"mc",
    age:"29",
    friends:{
        name:"hanghang",
        name:"jiejie"
    }
}

我们要对这个对象执行某些操作(读取,修改),通常像下面就可以

// 取值
const name = obj.name;
console.log(obj.age)
const friends = obj.friends;

// 修改
obj.name = "mmcai";
obj.age =  30;

在VUE中,我们知道,如果data对象中的某个属性,在template当中绑定的话,当我们修改了这个属性值,我们的视图也就更新了。这就是双向数据绑定,数据变化,视图更新,同时反过来也一样。

要实现这个功能,我们就需要知道data当中的数据是如何变动了,ES5当中提供了Object.defineProperty()函数,我们可以通过这个函数对我们data对象当中的数据进行监听。当数据变动,就会触发这个函数里面的set方法,通过判断数据是否变化,就可以执行一些方法,更新我们的视图了。所以我们现在需要实现一个数据监听器Observe,来对我们data中的所有属性进行监听。

// Observe类的实例化函数
function observe(data){
    // 判断数据是否是一个对象
    if(typeof data !== 'object'){
        return;
    }
    // 返回一个Observe的实例化对象
    return new Observe(data)
}

// Observer类的实现
class Observe{
    constructor(data){
        this.data = data;
        this.init(data)
    }

    init(data){
        for(let k in data){
            let val = data[k];

            //如果data是一个对象,我们递归调用自身
            if(typeof val === 'object'){
                observe(val);
            }

            Object.defineProperty(data,k,{
                enumerable:true,
                get(){
                    return val;
                },
                set(newVal){
                    //如果值相同,直接返回
                    if(newVal === val){
                        return;
                    };
                    //赋值
                    val = newVal;

                    //如果新设置的值是一个对象,递归调用observe方法,给新数据也添加上监听
                    if(typeof newVal === 'object'){
                        observe(newVal);
                    }
                }
            })
        }
    }

}

了解了数据劫持,我们就可以明白,为什么我们实例化vue的时候,必须事先在data当中定义好我们的需要的属性了,因为我们新增的属性,没有经过observe进行监听,没有通过observe监听,后面complie(模板解析)也就不会执行。

所以,虽然你可以在data上面设置新的属性,并读取,但视图却不能更新。

数据代理

我们常见的代理有nginx,就是我们不直接去访问(操作)我们实际要访问的数据,而是通过访问一个代理,然后代理帮我们去拿我们真正需要的数据。

一般的特点是:

  • 安全,不把真实内容暴露
  • 方便,可以把一些复杂的操作,通过代理进行简化

下面是VUE简单的一个使用实例:

cosnt vm = new Vue({
    el:"#app",
    data:{
        name:"mmcai"
    }
});

我们的实例化对象vm,想要读取data里面的数据的时候,不做任何处理的正常情况下,使用下面方式读取:

const name = vm.data.name;

这样操作起来,显然麻烦了一些,我们就可以通过数据代理,直接把data绑定到我们的实例上,所以在vue当中,我们一般获取数据像下面一样:

cosnt vm = new Vue({
    el:"#app",
    data:{
        name:"mmcai"
    },
    created(){

        // 直接通过实例就可以访问到data当中的数据
        const name = this.name;

        // 通过this.data.name 也可以访问,但是显然,麻烦了一些
    }
});

同样,我们通过Object.defineProperty函数,把data对象中的数据,绑定到我们的实例上就可以了,代码如下:

class MVVM {
    constructor(option){
        //此处代码省略
        this.$option = option;
        const data = this._data = this.$option.data;

        //调用代理
        this._proxyData(data);
    }

    _proxyData(data){
        const that = this;
        for(let k in data){
            let val = data[k];
            Object.defineProperty(that,k,{
                enumerable:true,
                get(){
                    return that._data[k];
                },
                set(newVal){
                    that._data[k] = newVal;
                }
            })
        }
    }
}

编译模板

利用正则表达式识别模板标识符,并利用数据替换其中的标识符。 VUE里面的标识符是 {{}} 双大括号,数据就是我们定义在data上面的内容。

实现原理

  1. 确定我们的模板范围
  2. 遍历DOM节点,循环找到我们的标识符
  3. 将标识符的内容用数据进行填充填充

遍历解析需要替换的根元素el下的HTML标签,一定会使用遍历对DOM节点进行操作,对DOM操作就会引发页面的重排和重绘,为了提高性能和效率,可以把el根节点下的所有节点替换为文档碎片fragment进行解析编译操作,解析完成,再将fragment添加到根节点el中

如果想对文档碎片进行,更多的了解,可以查看文章底部的参考资料

<!--定义模板编译类-->
class Complie{
    constructor(el,vm){
        this.$vm = vm;
        this.$el = document.querySelector(el);

        //第一步,把DOM转换成文档碎片
        this.$fragment = this.nodeToFragment(this.$el);

        //第二步,匹配标识符,填充数据
        this.compileElement(this.$fragment);

        //把文档碎片,添加到el根节点上面
        this.$el.appendChild(this.$fragment);  
    }

    // 把DOM节点转换成文档碎片
    nodeToFragment(el){
        let nodeFragment = document.createDocumentFragment();
        // 循环遍历el下面的节点,填充到文档碎片nodeFragment中
        while(child = el.firstChild){
            nodeFragment.appendChild(child);
        }

        // 把文档碎片返回
        return nodeFragment;
    }

    // 遍历目标,查找标识符,并替换
    compileElement(node){
        let reg = /\{\{(.*)\}\}/;
        Array.from(node.childNodes).forEach((node)=>{
            let text = node.textContent;
            if(node.nodeType === 3 && reg.test(text)){
                let arr = RegExp.$1.split('.');
                // vm 是实例的整个data对象
                let val = vm;
                arr.forEach((k)=>{
                    val = val[k]
                })

                node.textContent = text.replace(/\{\{(.*)\}\}/,val);
            }

            // 如果节点包含字节的,递归调用自身
            if(node.childNodes){
                this.compileElement(node)
            }

        })
    }

}

<!--实例化的方法-->
const complie = (el,vm)=>{
    return new Compile(el,vm)
}

发布订阅

在软件架构中,发布订阅是一种消息范式,消息的发送者(成为发布者)不会将消息直接发送给特定的接收者(成为订阅者)。二十将发布的消息分为不同的类别,无需了解哪些订阅者是否存在。同样的,订阅者可以表达对一个或多个类别的兴趣,直接受感兴趣的消息,无需了解哪些发布者是否存在——维基。

上述的表达中,既然说发布者不关心订阅者,订阅者也不关心发布者,那么他们是如何通信呢?

其实就是通过第三方,通常在函数中我们,称他们为观察者watcher

在VUE的里面,我们要确认几个概念,谁是发布者,谁是订阅者,为什么需要发布订阅?

上面我们说了数据劫持Observe,也说了Compile,其实,Observe和Compile 他们即使发布者,也是订阅者,帮助他们之间的通讯,就是watcher的工作。 通过下面的代码,我们简单了解下,发布订阅模式的实现情况。

// 创建一个类
// 发布订阅,本质上是维护一个函数的数组列表,订阅就是放入函数,发布就是让函数执行

class Dep{
    consturctor(){
        this.subs=[];
    }

    // 添加订阅者
    addSub(sub){
        this.subs.push(sub);
    }

    // 通知订阅者
    notify(){
        // 订阅者,都有
        this.subs.forEach((sub=>sub.update());
    }
}

// 监听函数,watcher
// 通过Watcher类创建的实例,都有update方法
class Watcher{

    // watcher的实例,都需要传入一个函数
    constructor(fn){
        this.fn = fn;
    }

    // watcher的实例,都拥有update方法
    update(){
        this.fn();
    }
}

// 把函数作为参数传入,实例化一个watcher
const watcher = new Watcher(()=>{
    consoole.log('1')
});

// 实例化Dep 类
const dep = new Dep();

// 将watcher放到dep维护的数组中,watcher实例本身具有update方法
// 可以理解成函数的订阅
dep.addSub(watcher);

// 执行,可以理解成,函数的发布,
// 不关心,addSub方法订阅了谁,只要订阅了,就通过遍历循环subs数组,执行数组每一项的update
dep.notify();

通过以上代码的了解,我们继续实现我们MVVM中的代码,实现数据和视图的关联。 这种关联的结果就是,当我们修改data中的数据的时候,我们的视图更新。或者我们视图中修改了相关内容,我们的data也进行相关的更新,所以这里主要的逻辑代码,就是我们watcher当中的update方法。

我们根据上面的内容,对我们的Observe和Compile以及Watcher进行修改,代码如下:

class MVVM{
    constructor(option){
        this.$option = option;
        const data = this._data = this.$option.data;
        this.$el = this.$option.el;

        // 数据劫持
        this._observe(data);

        // 数据代理
        this._proxyData(data);

        //模板解析
        this._compile(this.$el,this)
    }

    // 数据代理
    _proxyData(data){
        for(let k in data){
            let val = data[k];
            Object.defineProperty(this,k,{
                enumerable:true,
                get(){
                    return this._data[k];
                },
                set(newVal){
                    this._data[k] = newVal;
                }
            })
        }
    }

}

// 数据劫持
class Observe{
    constructor(data){
        this.init(data);
    }

    init(data){
        let dep = new Dep();
        for(let k in data){
            let val = data[k];

            // val 可能是一个对象,递归调用
            if(typeof val === 'object'){
                observe(val);
            }
            Object.defineProperty(data,k,{
                enumerable:true,
                get(){
                    // 订阅,

                    // Dep.target 是Watcher的实例
                    Dep.target && dep.addSub(Dep.target);
                    return val;
                },
                set(newVal){
                    if(newVal === val){
                        return;
                    }

                    val = newVal;
                    observe(newVal);

                    dep.notify();
                }

            })
        }
    }
}

// 数据劫持实例
function observe(data){
    if(typeof data !== 'object'){
        return
    };
    return new Observe(data);
}

// 模板编译
class Compile{
    constructor(el,vm){
        vm.$el = document.querySelector(el);

        //1.把DOM节点,转换成文档碎片
        const Fragment = this.nodeToFragment(vm.$el)

        //2.通过正则匹配,填充数据
        this.replace(Fragment,vm);

        //3.把填充过数据的文档碎片,插入模板根节点
        vm.$el.appendChild(Fragment);

    }

    // DOM节点转换
    nodeToFragment(el){
        // 创建文档碎片,
        const fragment = document.createDocumentFragment();
        //遍历DOM节点,把DOM节点,添加到文档碎片上
        while(child ===el.firstChild){
            fragment.appendChild(child);    
        }
        // 返回文档碎片
        return fragment;
    }

    //匹配标识,填充数据
    replace(fragment,vm){
        // 使用Array.from方法,把DOM节点,转化成数据,进行循环遍历
        Array.from(fragment.childNodes).forEach((node)=>{
            // 遍历节点,拿到每个内容节点
            let text = node.textContent;
            // 定义标识符的正则
            let reg = /\{\{(.*)\}\}/;

            //如果节点是文本,且节点的内容当中匹配到了模板标识符

            // 数据渲染视图
            if(node.nodeType===3 && reg.test(text)){
                // 用数据替换标识符
                let arr = RegExp.$1.split('.');
                let val = vm;
                arr.forEach((item)=>{
                    val = val[item];
                })
                // 添加一个watcher,当我们的数据发生变化的时候,更新我们的view
                new Watcher(vm,RegExp.$1,(newVal)=>{
                    node.textContent = text.replace(reg,newVal); 
                })

                //把数据填充到节点上
                node.textContent = text.replace(reg,val);
            }

            // 视图更新数据
            if(node.nodeType === 1){
                let nodeAttrs = node.attributes;
                Array.from(nodeAttrs).forEach((attr)=>{
                    let name = attr.name;
                    // 获取标识符的内容,也就是v-mode="a"的内容
                    let exp = attr.value;
                    if(name.indexOf('v-model')===0){
                        node.value = vm[exp];
                    };
                    new Watcher(vm,exp,(newVal)=>{
                        node.value = newVal;
                    });

                    node.addEventListener('input',function(e){
                        let newVal = e.target.value;
                        vm[exp] = newVal;
                    });
                });
            }

            // 如果节点包含子节点,递归调用自身
            if(node.childNodes){
                this.replace(node,vm);
            }
        })
    }
}

// 模板编译实例
function compile(el,vm){
    return new Compile(el,vm)
}

// 发布订阅
class Dep{
    constructor(){
        this.subs = [];
    }

    // 订阅函数
    addSub(fn){
        this.subs.push(fn);
    }

    // 发布执行函数
    notify(){
        this.subs.forEach((fn)=>{
            fn();
        })
    }
}

// Dep实例
function dep(){
    return new Dep();
}

// 观察者
class Watcher{
    // vm,我们的实例
    // exp,我们的标识符
    // fn,回调
    constructor(vm,exp,fn){
        this.fn = fn;
        this.vm = vm;
        this.exp = exp;
        Dep.target = this;
        let val = vm;
        let arr = exp.split('.');
        arr.forEach((k)=>{
            val = val[k]
        });
        // 完成之后,我们把target 删除;
        Dep.target = null;
    }
    update(){
        let val = this.vm;
        let arr = this.exp.split('.');
        arr.forEach((k)=>{
            val = val[k];
        })
        this.fn();
    }
}

function watcher(){
    return new Watcher()
}

Wathcer干了那些好事:

  • 在自身实例化的时候,往订阅器(dep)里面添加自己
  • 自身有一个update方法
  • 待data属性发生修改的时候,dep.notify()通知的时候,可以调用自身的update()方法,在update()方法出发绑定的回调

Watcher连接了两个部分,包括Observe和Compile;

在Observe方法执行的时候,我们给data的每个属性都添加了一个dep,这个dep被闭包在get/set函数内。

当我们new Watcher,在之后访问data当中属性的时候,就会触发通过Object.defineProperty()函数当中的get方法。 get方法的调用,就会在属性的订阅器实例dep中,添加当前Watcher的实例。

当我们尝试修改data属性的时候,就会出发dep.notify()方法,该方法会调用每个Watcher实例的update方法,从而更新我们的视图。

结束语

回顾下整个MVVM实现的整个过程

  • 使用Object.defineProperty()函数,给每个data属性添加get/set,并为每个属性创建一个dep实例,监听数据变化
  • 同样使用Object.defineProperty()函数,把data对象的属性,绑定到我们MVVM实例vm对象上,简化使用
  • 通过document.createDocumentFragment,把我们el节点下的dom转换成文档碎片
  • 遍历文档碎片,找到模板标识符,进行数据的替换,添加Watcher观察者,当数据发生变化的时候,再次更新我们的文档碎片
  • 把文档碎片插入到我们的el节点中。
  • 我们修改data,执行dep.notify()方法,然后调用Watcher实例上的update方法,更新视图。

我这里有一个简短的视频,是某培训机构讲解MVVM的内容,大家有兴趣,可以自取。

视频链接

提取码:1i0r

如果失效,可以私聊我。

参考

廖雪峰谈MVVM

…,让MVVM原理还给你

观察者模式与发布订阅模式

基于vue实现一个简单的MVVM框架

文档碎片

[转载]原文链接:https://segmentfault.com/a/1190000019483581

本站文章除注明转载外,均为本站原创或编译。欢迎任何形式的转载,但请务必注明出处。

转载请注明:文章转载自 JavaScript中文网 [https://www.javascriptcn.com]

本文地址:https://www.javascriptcn.com/read-67314.html

文章标题:基于Vue的MVVM学习笔记

相关文章
vue 数组遍历方法forEach和map的原理解析和实际应用
一、前言 forEach和map是数组的两个方法,作用都是遍历数组。在vue项目的处理数据中经常会用到,这里介绍一下两者的区别和具体用法示例。 二、代码 1. 相同点 都是数组的方法 都用来遍历数组 两个函数都有4个参数:匿名函数中可传3...
2018-11-15
js性能优化 如何更快速加载你的JavaScript页面
确保代码尽量简洁 不要什么都依赖JavaScript。不要编写重复性的脚本。要把JavaScript当作糖果工具,只是起到美化作用。别给你的网站添加大量的JavaScript代码。只有必要的时候用一下。只有确实能改善用户体验的时候用一下。 ...
2015-11-12
10个强大的纯CSS3动画案例分享
我们的网页外观主要由CSS控制,编写CSS代码可以任意改变我们的网页布局以及网页内容的样式。CSS3的出现,更是可以让网页增添了不少动画元素,让我们的网页变得更加生动有趣,并且更易于交互。本文分享了10个非常炫酷的CSS3动画案例,希望大家...
2015-11-16
v-charts | 饿了么团队开源的基于 Vue 和 ECharts 的图表工具
在使用echarts生成图表时,经常需要做繁琐的数据类型转化、修改复杂的配置项,v-charts的出现正是为了解决这个 痛点。基于Vue2.0和echarts封装的v-charts图表组件,只需要统一提供一种对前后端都友好的数据格式 设置简...
2018-05-24
Vue获取DOM元素样式和样式更改示例
在 vue 中用 document 获取 dom 节点进行节点样式更改的时候有可能会出现 ‘style’ is not definde的错误,这时候可以在 mounted 里用 $refs 来获取样式,并进行更改: &lt;template...
2017-03-13
Vue.js组件tab实现选项卡切换
本文实例为大家分享了vue插件tab选项卡的具体代码,供大家参考,具体内容如下 效果图: 代码如下: &lt;!DOCTYPE html&gt; &lt;html lang=&quot;en&quot;&gt; &lt;head&gt; ...
2017-03-13
从2014年的发展来展望JS的未来将会如何
&lt;font face=&quot;寰�杞�闆呴粦, Arial, sans-serif &quot;&gt;2014骞达紝杞�浠惰�屼笟鍙戝睍杩呴€燂紝鍚勭�嶈��瑷€灞傚嚭涓嶇┓锛屼互婊¤冻鐢ㄦ埛涓嶆柇鍙樺寲鐨勯渶姹傘€傝繖浜涜��...
2015-11-12
12个你未必知道的CSS小知识
虽然CSS并不是一种很复杂的技术,但就算你是一个使用CSS多年的高手,仍然会有很多CSS用法/属性/属性值你从来没使用过,甚至从来没听说过。 1.CSS的color属性并非只能用于文本显示 对于CSS的color属性,相信所有Web开发人员...
2015-11-12
ajax为什么令人惊异?ajax的优缺点
使用Ajax的最大优点,就是能在不更新整个页面的前提下维护数据。这使得Web应用程序更为迅捷地回应用户动作,并避免了在网络上发送那些没有改变的信息。 Ajax不需要任何浏览器插件,但需要用户允许JavaScript在浏览器上执行。就像DHT...
2015-11-12
HTML5的5个不错的开发工具推荐
HTML5规范终于在今年正式定稿,对于从事多年HTML5开发的人员来说绝对是一个重大新闻。数字天堂董事长,DCloud CEO王安也发表了文章,从开发者和用户两个角度分析了HTML对两个人群的优势。其实,关于HTML5的开发工具,我们以往的...
2015-11-12
回到顶部