【源码解析】开心,一个小demo让我轻松掌握了vue2中MVVM的实现原理,原来只需这三步就够了:数据劫持、模板编译和双向绑定

3,296 阅读2分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

前言

大家好,今天我们来一起学习一下vue2中的mvvm的原理,并用代码简单模拟一下mvvm的实现流程。在学习原理之前我们先来了解下什么是mvvm,所谓的MVVM其实就是Model、View 和ViewModel:

  • Model:模型层(数据层),主要用于保存一些数据
  • View: 视图层,主要用于将后端数据借助各种元素呈现给用户,同时也可提供用户操作的入口
  • ViewModel:视图模型层:该层也是mvvm中的核心层,主要用于作为Model个View两个层的数据连接层,负责两个层之间的数据传递。该层主要包含两大功能点:
    • DOM监听(DOM Listener) 用于监听dom元素的一些事件,如果dom元素发生变化在需要的时候会改变对应的data
    • 数据绑定(Data bindings)用于将model的改变反应在view上及时呈现给用户

MVVM的原理(Vue2)

上面简单介绍了什么是MVVM,那么接下来我们以Vue2为例再看看MVVM是如何实现的,它的实现原理又是什么?首先我们可以把MVVM的实现分为三步:数据劫持、模板编译和双向绑定。

  • 数据劫持 为什么要数据劫持?带着这个问题我们先来看下如何实现数据劫持,在vue2的源码中有个名为defineReactive$$1的方法,该方法就是用来实现数据劫持的,但该方法也只是个壳子,最终实现数据劫持的还是靠的js原生的Object.defineProperty方法,这也是vue2死活不支持ie8的原因之一。

Object.defineProperty方法接受三个参数:第一个参数是被劫持的对象,第二个参数是被劫持的对象中的属性(key),第三个参数是一个配置项对象(包括:value、enumerable、configurable、get和set等几个属性),如下:

Object.defineProperty(obj,key,{
    enumerable: true,
    configurable: true,
    get(){
    },
    set(){
    }
})

我们在做数据劫持时主要用到的就是get和set两个属性,通过该方法被劫持的对象属性,只要在外界获取或者修改属性值都会触发get或set方法,这样我们就可以在get或set中对属性做一些额外对操作了。了解了数据劫持的实现,也就知道了我们为什么要做数据劫持。是因为我们可以通过数据劫持对数据做一些额外对操作从而实现响应式数据。下面我们以vue的data为例实现一个简单的数据劫持。

function observe(data){
    // data必须是一个对象
    if(({}).toString.call(data) !== '[ojbect Object]') return;
    //获取data中所有的属性
    const keys = Object.keys(data);
    //循环遍历keys为data中的每个属性做数据劫持
    keys.foreach(key=>{
        defineReactive$$1(data, key, data[key]);
    })    
}

function defineReactive$$1(obj, key, val){
    Object.defineProperty(obj, key, {
        get(){
            return val;
        },
        set(newV){
            if(newV !== val){
                val = newV;
            }
        }
    })
}
  • 模板编译 第一步数据劫持已经实现了,接下来就是模板编译。同样还是先提出一个问题:为什么要模板编译? 我们知道在vue中是通过一些指令或者小胡子语法来实现数据绑定的,而浏览器并不认识这些指令或者小胡子语法,因此在页面加载后需要将这些语法转换成真正的数据呈现给用户。下面我们input元素和v-model指令为例来实现一个简单的模板编译。 本案例中实现模板编译的流程:
  • 遍历#app下所有的节点,然后根据节点的类型做相应的操作
  • 如果是元素节点,获取该节点中所有的属性(attributes)并遍历看是否有v-model指令
    • 如果有v-model指令,则根据该指令绑定的属性名(data中的属性名)获取到对应到值,并赋值给节点的value属性
  • 如果是文本节点,则看该文本内容中是否包含小胡子语法
    • 如果有小胡子语法,同样需要解析出小胡子中绑定的属性名(data中的属性名)并获取到对应到值替换该文本内容
  • 遍历完每个节点后再将该节点作为子节点添加到html到文档碎片中
  • 最后再将整个文档碎片添加到dom中 需要说明到是:在vue中实现是借助虚拟dom实现的,而这里为来简单就借助文档碎片来模拟虚拟dom实现,另外为什么一定要用文档碎片,不能直接遍历节点吗?直接遍历也是可以的但是这样一来由于不停的修改节点势必会造成大量的性能消耗,而通过文档碎片在所有节点遍历完成后只需要一次消耗,这样就大大降低了回流重汇带来的性能损耗。
function nodeTofragment(el, vm){
    let fragment = document.createDocumentFragment();
    let child;
    while(child = el.firstChild){
        compile(child, vm);//模板编译
        fragment.appendChild(child);//将节点添加到文档碎片中
    }
}

function compile(node, vm){
    // 每个节点都有个节点类型属性(nodeType)对应的值分别是:1元素、2文本、8注释和9根节点
    if(node.nodeType === 1){//  如果是元素节点
        // 遍历所有的属性,看是否有v-model指令
        [...node.attributes].forEach(item=>{
            if(/^v-/.test(item.nodeName)){//nodeName就是属性名,如:class、type、v-model等
                node.value = vm.$data[item.nodeValue]; //nodeValue就是属性名中对应的值,如v-model="name"中的name
            }
        });
        
        // 元素节点还可能有很多子节点或孙子节点等,因此还需递归处理
        [...node.childNodes].forEach(item=>{
            compile(item, vm);
        });
    }else if(node.nodeType === 3){ // 如果是文本节点
        // 检测该文本中是否包含小胡子语法
        if(/\{\{\w+\}\}/.test(node.textContent)){
            // 将小胡子替换为真正的数据
            node.textContent = node.textContent.replace(/\{\{(\w+)\}\}/, function(a,b){
                // 参数a是匹配到到大的正则内容
                // 参数b是小分组中匹配到到内容 所以b就对应的data中定义的属性
                return vm.$data[b];
            })
        }
    }
}
  • 双向绑定 vue主要是利用数据劫持加发布订阅模式来实现数据的双向绑定的,那么具体是如何实现的呢?在前面数据劫持的时候我们提到,数据劫持的目的就是为了在获取数据或给数据赋值之前对数据做一些额外的操作,那么这些额外的操作其实就是利用发布订阅模式对数据属性进行监控,比如说data中的name属性,首先需要知道这个name属性都在哪里用到了,以便后面如果name值发生改变时及时通知用到name的地方同步更新,这个在vue中叫做依赖收集。怎么才能知道name属性都在哪里用到了呢,这个时候数据劫持就派上用场了,前面说过只要外界对name进行访问都会触发Object.defineProperty中的get函数,那么我们就可以利用这个特点在get函数中对name属性进行监听收集。大概实现思路如下:
  • 首先我们需要定义一个Dep类,用于对属性进行依赖收集和通知用到属性到地方进行同步更新
  • 然后再定义一个Watcher类,用于对属性进行监听,并实现属性值的同步更新
  • 在模板编译的时候,通过watcher来监听属性
  • 在数据劫持的get函数中进行依赖收集
  • 在数据劫持的set函数中通知各个watcher进行数据更新
class Dep{
    constructor(){
        this.subs = [];//事件池 存储watcher实例对象
    }
    addSub(sub){
        //sub就是watcher的实例
        this.subs.push(sub);
    }
    notify(){
        this.subs.forEach(item=>{
            item.update();//调用watcher的update
        })
    }
}

class Watcher{
    constructor(node, key, vm){
        Dep.target = this;//用于标识只用通过Watcher监听过的属性才会进行依赖收集
        this.node = node;
        this.key = key;
        this.vm = vm;
        this.getValue();
        Dep.target = null;
    }
    update(){
        this.getValue();//首先获取下最新值
        if(this.node.nodeType === 1){
            this.node.value = this.value;
        }else if(this.node.nodeType === 3){
            this.node.textContent = this.value;
        }
    }
    getValue(){
        this.value = this.vm.$data[this.key];
    }    
}

至此我们就实现来一个非常简单的MVVM,关于MVVM小demo的完整代码,请参考另一篇文章MVVM完整代码

caolouyaqian.gif

总结

今天我们简单梳理了下vue2中mvvm的实现原理,并根据我们了解也简单的编写了一个我们自己的MVVM,其实这也只是冰山一角,真正的Vue2源码中远比这复杂的多,本次分享只是大概介绍了下其基本思路和流程。今天的分享就到这里了。