基于vue实现一个简单的MVVM框架(源码分析)

不知不觉接触前端的时间已通过去半年了,愈来愈发觉对知识的学习不该该只停留在会用的层面,这在我学jQuery的一段时间后便有这样的体会。html

虽然jQuery只是一个JS的代码库,只要会一些JS的基本操做学习一两天就能很快掌握jQuery的基本语法并熟练使用,可是若是不了解jQUery库背后的实现原理,相信只要你一段时间再也不使用jQuery的话就会把jQuery忘得一干二净,这也许就是知其然不知其因此然的后果。前端

最近在学vue的时候又再一次经历了这样的困惑,虽然可以比较熟练的掌握vue的基本使用,也可以对MV*模式、数据劫持、双向数据绑定、数据代理侃上两句。可是要是稍微深刻一点就有点吃力了。因此这几天痛下决心研究大量技术文章(起初尝试看早期源码,无奈vue与jQuery不是一个层级的,相比于jQuery,vue是真正意义上的前端框架。只能无奈弃坑转而看技术博客),对vue也算有了一个管中窥豹的认识。最后尝试实践一下本身学到的知识,基于数据代理、数据劫持、模板解析、双向绑定实现了一个小型的vue框架。vue

-------------------------------------------------- 分割线,下面介绍vue的具体实现。node

舒适提示:文章是按照每一个模块的实现依赖关系来进行分析的,可是在阅读的时候能够按照vue的执行顺序来分析,这样对初学者更加的友好。推荐的阅读顺序为:实现VMVM、数据代理、实现Observe、实现Complie、实现Watcher。git

源码连接,因为只实现了v-model,v-on,v-bind等比较小的功能,因此更便于理解和掌握vue的实现过程。若是对您有帮助的话,但愿点一下star。github

功能演示以下所示:segmentfault

 

数据代理

如下面这个模板为例,要替换的根元素“#mvvm-app”内只有一个文本节点#text,#text的内容为{{name}}。咱们就如下面这个模板详细了解一下VUE框架的大致实现流程数组

<body>
    <div id="mvvm-app">
        {{name}}
    </div>
    <script src="./js/observer.js"></script>
    <script src="./js/watcher.js"></script>
    <script src="./js/compile.js"></script>
    <script src="./js/mvvm.js"></script>
    <script> let vm = new MVVM({ el: "#mvvm-app", data: { name: "hello world" }, }) </script> </body>

数据代理

一、什么是数据代理

在vue里面,咱们将数据写在data对象中。可是咱们在访问data里的数据时,既能够经过vm.data.name访问,也能够经过vm.name访问。这就是数据代理:在一个对象中,能够动态的访问和设置另外一个对象的属性。缓存

二、实现原理

咱们知道静态绑定(如vm.name = vm.data.name)能够一次性的将结果赋给变量,而使用Object.defineProperty()方法来绑定则能够经过set和get函数实现赋值的中间过程,从而实现数据的动态绑定。具体实现以下:前端框架

let obj = {};
let obj1 = {
    name: 'xiaoyu',
    age: 18,
}
//实现origin对象代理target对象
function proxyData(origin,target){
    Object.keys(target).forEach(function(key){
        Object.defineProperty(origin,key,{//定义origin对象的key属性
            enumerable: false,
            configurable: true,
            get: function getter(){
                return target[key];//origin[key] = target[key];
            },
            set: function setter(newValue){
                target[key] = newValue;
            }
        })
    })
}

vue中的数据代理也是经过这种方式来实现的。

function MVVM(options) {
    this.$options = options || {};
    var data = this._data = this.$options.data;
    var _this = this;//当前实例vm

    // 数据代理
    // 实现 vm._data.xxx -> vm.xxx 
    Object.keys(data).forEach(function(key) {
        _this._proxyData(key);
    });
    observe(data, this);
    this.$compile = new Compile(options.el || document.body, this);

}

MVVM.prototype = {
_proxyData: function(key) {
    var _this = this;
    if (typeof key == 'object' && !(key instanceof Array)){//这里只实现了对对象的监听,没有实现数组的
        this._proxyData(key);
    }
    Object.defineProperty(_this, key, {
        configurable: false,
        enumerable: true,
        get: function proxyGetter() {
            return _this._data[key];
        },
        set: function proxySetter(newVal) {
            _this._data[key] = newVal;
        }
    });
},
};

实现Observe

一、双向数据绑定

  • 数据变更  --->  视图更新
  • 视图更新  --->  数据变更 

要想实现当数据变更时视图更新,首先要作的就是如何知道数据变更了,能够经过Object.defineProperty()函数监听data对象里的数据,当数据变更了就会触发set()方法。因此咱们须要实现一个数据监听器Observe,来对数据对象中的全部属性进行监听,当某一属性数据发生变化时,拿到最新的数据通知绑定了该属性的订阅器,订阅器再执行相应的数据更新回调函数,从而实现视图的刷新。

当设置this.name = 'hello vue'时,就会执行set函数,通知订阅器里的订阅者执行相应的回调函数,实现数据变更,对应视图更新。

function observe(data){
    if (typeof data != 'object') {
        return ;
    }
    return new Observe(data);
}

function Observe(data){
    this.data = data;
    this.walk(data);
}

Observe.prototype = {
    walk: function(data){
        let _this  = this;
        for (key in data) {
            if (data.hasOwnProperty(key)){
                let value = data[key];
                if (typeof value == 'object'){
                    observe(value);
                }
                _this.defineReactive(data,key,data[key]);
            }
        }
    },
    defineReactive: function(data,key,value){
        Object.defineProperty(data,key,{
            enumerable: true,//可枚举
            configurable: false,//不能再define
            get: function(){
                console.log('你访问了' + key);return value;
            },
            set: function(newValue){
                console.log('你设置了' + key);
                if (newValue == value) return;
                value = newValue;
                observe(newValue);//监听新设置的值
            }
        })
    }
}

二、实现一个订阅器

要想通知订阅者,首先得要有一个订阅器(统一管理全部的订阅者)。为了方便管理,咱们会为每个data对象的属性都添加一个订阅器(new Dep)。

订阅器里存着的是订阅者Watcher(后面会讲到),因为订阅者可能会有多个,咱们须要创建一个数组来维护。一旦数据变化,就会触发订阅器的notify()方法,订阅者就会调用自身的update方法实现视图更新。

function Dep(){
    this.subs = [];
}
Dep.prototype = {
    addSub: function(sub){this.subs.push(sub);
    },
    notify: function(){
        this.subs.forEach(function(sub) {
            sub.update();
        })
    }
}

每次响应属性的set()函数调用的时候,都会触发订阅器,因此代码补充完整。

Observe.prototype = {
    //省略的代码未做更改
    defineReactive: function(data,key,value){
        let dep = new Dep();//建立一个订阅器,会被闭包在key属性的get/set函数内,所以每一个属性对应惟一一个订阅器dep实例
        Object.defineProperty(data,key,{
            enumerable: true,//可枚举
            configurable: false,//不能再define
            get: function(){
                console.log('你访问了' + key);
                return value;
            },
            set: function(newValue){
                console.log('你设置了' + key);
                if (newValue == value) return;
                value = newValue;
                observe(newValue);//监听新设置的值
                dep.notify();//通知全部的订阅者
            }
        })
    }
}

实现Complie

compile主要作的事情是解析模板指令,将模板中的data属性替换成data属性对应的值(好比将{{name}}替换成data.name值),而后初始化渲染页面视图,而且为每一个data属性添加一个监听数据的订阅者(new Watcher),一旦数据有变更,收到通知,更新视图。

遍历解析须要替换的根元素el下的HTML标签必然会涉及到屡次的DOM节点操做,所以不可避免的会引起页面的重排或重绘,为了提升性能和效率,咱们把根元素el下的全部节点转换为文档碎片fragment进行解析编译操做,解析完成,再将fragment添加回原来的真实dom节点中。

  • 注:文档碎片自己也是一个节点,可是当将该节点append进页面时,该节点标签做为根节点不会显示html文档中,其里面的子节点则能够彻底显示。

Compile解析模板,将模板内的子元素#text添加进文档碎片节点fragment。

function Compile(el,vm){
    this.$vm = vm;//vm为当前实例
    this.$el = document.querySelector(el);//得到要解析的根元素  
    if (this.$el){
        this.$fragment = this.nodeToFragment(this.$el);
        this.init();
        this.$el.appendChild(this.$fragment);
    }  
}
Compile.prototype = {
    nodeToFragment: function(el){
        let fragment = document.createDocumentFragment();
        let child;
        while (child = el.firstChild){
            fragment.appendChild(child);//append至关于剪切的功能
        }
        return fragment;
        
    },
};

compileElement方法将遍历全部节点及其子节点,进行扫描解析编译,调用对应的指令渲染函数进行数据渲染,并调用对应的指令更新函数进行绑定,详看代码及注释说明:

由于咱们的模板只含有一个文本节点#text,所以compileElement方法执行后会进入_this.compileText(node,reg.exec(node.textContent)[1]);//#text,'name'

Compile.prototype = {
    nodeToFragment: function(el){
        let fragment = document.createDocumentFragment();
        let child;
        while (child = el.firstChild){
            fragment.appendChild(child);//append至关于剪切的功能
        }
        return fragment;
        
    },
    
    init: function(){
        this.compileElement(this.$fragment);
    },
    
    compileElement: function(node){
        let childNodes = node.childNodes;
        const _this = this;
        let reg = /\{\{(.*)\}\}/g;
        [].slice.call(childNodes).forEach(function(node){
            
            if (_this.isElementNode(node)){//若是为元素节点,则进行相应操做
                _this.compile(node);
            } else if (_this.isTextNode(node) && reg.test(node.textContent)){
                //若是为文本节点,而且包含data属性(如{{name}}),则进行相应操做
                _this.compileText(node,reg.exec(node.textContent)[1]);//#text,'name'
            }
            
            if (node.childNodes && node.childNodes.length){
                //若是节点内还有子节点,则递归继续解析节点
                _this.compileElement(node);
                
            }
        })
    },
    compileText: function(node,exp){//#text,'name'
            compileUtil.text(node,this.$vm,exp);//#text,vm,'name'
    },
};

CompileText()函数实现初始化渲染页面视图(将data.name的值经过#text.textContent = data.name显示在页面上),而且为每一个DOM节点添加一个监听数据的订阅者(这里是为#text节点新增一个Wather)。

let updater = {
    textUpdater: function(node,value){   
        node.textContent = typeof value == 'undefined' ? '' : value;
    },
}
    
let compileUtil = {
    text: function(node,vm,exp){//#text,vm,'name'
        this.bind(node,vm,exp,'text');
    },
    
    bind: function(node,vm,exp,dir){//#text,vm,'name','text'
        let updaterFn = updater[dir + 'Updater'];
        updaterFn && updaterFn(node,this._getVMVal(vm,exp));
        new Watcher(vm,exp,function(value){
            updaterFn && updaterFn(node,value)
        });
        console.log('加进去了');
    }
};

如今咱们完成了一个能实现文本节点解析的Compile()函数,接下来咱们实现一个Watcher()函数。

实现Watcher

 咱们前面讲过,Observe()函数实现data对象的属性劫持,并在属性值改变时触发订阅器的notify()通知订阅者Watcher,订阅者就会调用自身的update方法实现视图更新。

Compile()函数负责解析模板,初始化页面,而且为每一个data属性新增一个监听数据的订阅者(new Watcher)。

Watcher订阅者做为Observer和Compile之间通讯的桥梁,因此咱们能够大体知道Watcher的做用是什么。

主要作的事情是:

  • 在自身实例化时往订阅器(dep)里面添加本身。
  • 自身必须有一个update()方法 。
  • 待属性变更dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调。

 先给出所有代码,再分析具体的功能。

//Watcher
function Watcher(vm, exp, cb) {
    this.vm = vm;
    this.cb = cb;
    this.exp = exp;
    this.value = this.get();//初始化时将本身添加进订阅器
};

Watcher.prototype = {
    update: function(){
        this.run();
    },
    run: function(){
        const value = this.vm[this.exp];
        //console.log('me:'+value);
        if (value != this.value){
            this.value = value;
            this.cb.call(this.vm,value);
        }
    },
    get: function() { 
        Dep.target = this;  // 缓存本身
        var value = this.vm[this.exp]  // 访问本身,执行defineProperty里的get函数         
        Dep.target = null;  // 释放本身
        return value;
    }
}

//这里列出Observe和Dep,方便理解
Observe.prototype = {
    defineReactive: function(data,key,value){
        let dep = new Dep();
        Object.defineProperty(data,key,{
            enumerable: true,//可枚举
            configurable: false,//不能再define
            get: function(){
                console.log('你访问了' + key);
                //说明这是实例化Watcher时引发的,则添加进订阅器
                if (Dep.target){
                    //console.log('访问了Dep.target');
                    dep.addSub(Dep.target);
                }
                return value;
            },
        })
    }
}

Dep.prototype = {
    addSub: function(sub){this.subs.push(sub);
    },
}

咱们知道在Observe()函数执行时,咱们为每一个属性都添加了一个订阅器dep,而这个dep被闭包在属性的get/set函数内。因此,咱们能够在实例化Watcher时调用this.get()函数访问data.name属性,这会触发defineProperty()函数内的get函数,get方法执行的时候,就会在属性的订阅器dep添加当前watcher实例,从而在属性值有变化的时候,watcher实例就能收到更新通知。

那么Watcher()函数中的get()函数内Dep.taeger = this又有什么特殊的含义呢?咱们但愿的是在实例化Watcher时将相应的Watcher实例添加一次进dep订阅器便可,而不但愿在之后每次访问data.name属性时都加入一次dep订阅器。因此咱们在实例化执行this.get()函数时用Dep.target = this来标识当前Watcher实例,当添加进dep订阅器后设置Dep.target=null。

实现VMVM

MVVM做为数据绑定的入口,整合Observer、Compile和Watcher三者,经过Observer来监听本身的model数据变化,经过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通讯桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变动的双向绑定效果。

function MVVM(options) {
    this.$options = options || {};
    var data = this._data = this.$options.data;
    var _this = this;
    // 数据代理
    // 实现 vm._data.xxx -> vm.xxx 
    Object.keys(data).forEach(function(key) {
        _this._proxyData(key);
    });
    observe(data, this);
    this.$compile = new Compile(options.el || document.body, this);
}

 学习连接

如下是vue的分析文章,对我理解vue起到很大的帮助。感谢做者对本身知识的分享。

vue 源码分析之如何实现 observer 和 watcher

剖析vue实现原理,本身动手实现mvvm

对vue早期源码的理解