手写VUE mvvm双向数据绑定

来源:互联网 发布:淘宝常见的售后问题 编辑:程序博客网 时间:2024/04/29 10:17

当你打开这篇文章时,你肯定已经使用过vue,当你改变数据时,与之绑定的UI自动更新,当你触发一些表单元素时,与之绑定的数据也会自动更新。我刚开始学vue的时候对vue的双向数据绑定很好奇,所以今天我给大家实现一个简单的vue。

首先,你得明白为什么我们要使用双向数据绑定,在没有什么mvc,mvvm之前,当数据改变,我们总是需要手动通过id class等方式找到我们的DOM,手动的调用什么inner Text,setAtrribute,addClass等去更新DOM的各种属性,样式,文本等等,这样做有两个问题,第一:程序员把太多精力放在UI更新上,也就是数据和UI的同步上。第二:页面数据的维护也比较困难。如果程序的结构不好,逻辑再复杂点,你会发现程序写不下去了。第三:UI和js代码耦合度太高。对上面这些痛点有所体会的话,能帮助你更好的理解我们实现vue的mvvm究竟要干些什么事,是怎样提高程序员的开发效率的!

先给大家上一个图,这是我在vue官网上截的


这里Data数据源都是响应式的,也就是说用Object.defineProperty定义了set和get,这样可以对数据源进行劫持,每当你set的时候你就能调用notify通知所有的Watcher,每个watcher会有一个update方法更新UI,这里上图中是更新(在内存中计算)虚拟DOM,再由虚拟DOM更新真实的DOM。但是我的要写的vue例子中是在watcher的中update中保留了真实DOM的引用,以实现更新,并没有用到虚拟dom,那么还有一个问题就是,怎么根据模板生成watcher,又怎么把watcher添加到他所观察的数据的闭包环境中的。下面我们先看看我们要实现的最终效果。


就是当我在输入框输入文本的时候,下面能够同步,并且当我改变一个isshow值,圆相应隐藏或显示。


一  、实现Observer,实现可响应数据

  
function observe(data) {        if (!data || typeof data !== 'object') {   //这里包括数组和对象  typeof [] === 'object' 为true            return;        }        Object.keys(data).forEach((key) => {            defineReactive(data, key, data[key]);        });    }

 function defineReactive(obj, key, value) {        observe(value);    //递归监听  如果属性的值为对象  则递归监听        Object.defineProperty(obj, key, {            configurable: false,        //不能再define            enumerable: true,           //可枚举            set: function (newValue) {                if (newValue == value) return;                value = newValue;
                console.log("不好,有人要改变我的值....");            },            get: function () {
                console.log("嘿,你触发我的取值器");
                return value;            }        })    }
var data = {name: 'kitty'};observe(data);data.name = 'wangwang'; // 不好,有人要改变我的值....
这样我们的data对象就是可观测的了,这里每次调用defineReactive实现对对象某属性进行观测时,要注意如果此属性的值还是一个对象或者数组,那么需要继续递归处理,直到对象属性是一个基本类型停止。那么问题又来了,如果属性是一个数组,以上代码能实现对于数组的每个元素进行监听,但是我怎么实现对数组push pop splice等方法也进行监听,这样当使用这些方法时,也在我们的监听管辖范围之内。
对以上代码进行如下改造:
function defineReactive(obj, key, value) {        observe(value);    //递归监听  如果属性的值为对象  则递归监听        if (value instanceof Array) {            //对该数组的push  pop splice shift等等可以改变数组的方法进行装饰  并挂载到数组实例上            ["push", "pop", "shift", "unfift", "splice"].forEach((method) => {                // let beforeDecorateMethod = Array.prototype[method];                value[method] = function (prop) {                    let result = Array.prototype[method].call(value, prop)                    //在这里  你可以插入你的代码  这样你每次push  pop  splice..的时候就能执行你的代码                    return result;                }            })        }        Object.defineProperty(obj, key, {            configurable: false,        //不能再define            enumerable: true,           //可枚举            set: function (newValue) {                if (newValue == value) return;                value = newValue;               },            get: function () {                return value;            }        })    }
原理很简单,就是先获得数组原型上对应的方法,然后对其进行改造(装饰),然后再把装饰后的同名方法挂载到数组势力上,这样你通过arr.push获得的方法就是你装饰后的push方法了,其原因就是访问对象方法或者属性时,会先在对象本身上找,找不到才会去__proto__原型对象上找。这样,你就能对这些改变数组方法也进行监听。。

那么,我们每当set的时候,我希望能够通知到该数据的所有观察者,观察者收到通知后去更新DOM,这个是观察者的事我们后面会讲到。在defineReactive这个闭包环境里我添加这个数据的观察者,由于观察者很多,所以我干脆添加一个Dep对象,这是个观察者容器。代码如下:
function defineReactive(obj, key, value) {        var dep = new Dep();        observe(value);    //递归监听  如果属性的值为对象  则递归监听        if (value instanceof Array) {            //对该数组的push  pop splice shift等等可以改变数组的方法进行装饰  并挂载到数组实例上            ["push", "pop", "shift", "unfift", "splice"].forEach((method) => {                // let beforeDecorateMethod = Array.prototype[method];                value[method] = function (prop) {                    let result = Array.prototype[method].call(value, prop)                    dep.notify();                    return result;                }            })        }        Object.defineProperty(obj, key, {            configurable: false,        //不能再define            enumerable: true,           //可枚举            set: function (newValue) {                if (newValue == value) return;                value = newValue;                dep.notify();            },            get: function () {                return value;            }        })    }
显然,Dep对象上应该有一个观察者集合,并且和一个notify通知方法,在这个方法里遍历所有的watcher ,依次触发watcher的update方法。Dep的实现如下
function Dep() {        this.subs = [];    }    Dep.prototype = {        addWatcher: function (watcher) {            this.subs.push(watcher);        },        notify: function () {            this.subs.forEach((watcher) => {                watcher.update();            })        }    }
那么问题又来了,subs数组里保存的watcher是怎么添加进去的???而且dep对象又在一个闭包环境里面,而watcher又只能是编译模板时生成的,也就是在闭包外面生成的,所以我现在希望,当new Watcher的时候他能自己把自己添加到dep的subs数组中,听起来挺不可思议的。但是你想啊,这可以通过全局变量传递watcher对象呀!,因为set是用来通知的,我们只能在get方法上做文章了,用get来收集watcher。是不是豁然开朗。下面的是代码。
 get: function () {                if (Dep.target) {                    dep.addWatcher(Dep.target);                }                return value;            }
function Wathcer(exp, vm, callback) {        this.exp = exp;        this.vm = vm;        this.callback = callback;        Dep.target = this;        this.get();        Dep.target = null;        this.callback(this.value);     //初始化试图    }
重点看Watcher的加粗部分代码,是不是想明白了!!!

二、实现Compile,编译模板,初始化页面

 在new Vue的时候,我们会编译el选择的DOM里面的所有元素,这是一个模板。比如:
<div id="app">    <input v-model="message"/>    <p v-bind:class="style">您输入的内容是{{message}}  {{message}} </p>    <div v-bind:class="style2" v-show="isShow"></div></div>

在compile阶段,我们需要编译模板,解析指令,并生成Watcher,传给watcher它的update函数。并生成初始视图。lets do it.
//Compile对象做的事情   解析el所有子元素的所有指令  初始化视图  创建Watcher 并绑定update函数  watcher会把自己加到相应的dep订阅器中    function Compile(el, vm) {        this.$vm = vm;        this.$el = document.querySelector(el);        this.$fragment = this.elementToFragment(this.$el);   //劫持el所有子元素  转化为fragment文档碎片   以免频繁在真实DOM树上读写 以提高性能        this.init();        this.$el.appendChild(this.$fragment);    }
 Compile.prototype = {        elementToFragment: function (el) {            var container = document.createDocumentFragment();            var child;            while (child = el.firstChild) {                container.appendChild(child);            }            return container;        },        init: function () {            this.compileElement(this.$fragment);        },
......
}
看到一个fragment没?这个是一个文档碎片的容器,之所以使用它,是我们想先把我们的模板里面的元素从真实的DOM中劫持到fragment中(fragment在内存中,他的改变不会引起浏览器的重新渲染),然后在对fragment里面的元素进行compile操作(这个操作频繁读写),编译完毕后再加入到真实的DOM树中,这样大大提高性能~
那么我们的compileElement方法又做了什么呢?
compileElement: function (el) {            var childNodes = el.childNodes, vm = this.$vm;            [].slice.call(childNodes).forEach((node) => {                var text = node.textContent;                var reg = /\{\{(.*)\}\}/;    // 表达式文本                if (node.nodeType == 1) {  //普通标签                    this.compileAttrs(node);                } else if (node.nodeType == 3 && reg.test(text)) {//文本节点 #text                    this.compileText(node);                }                if (node.childNodes && node.childNodes.length > 0) {                    this.compileElement(node);         //递归调用                }            })        }
在这里分了两种情况,属性编译,和文本节点编译,最后,如果元素还有子元素就继续递归调用compileElement,如此,就可以保证所有的节点上的v-属性和包含{{}}的文本都可以被编译处理。
Compile.prototype = {
.....省略
compileText: function (node) {     //当然这里需要匹配所有的{{exp}}  为每个不同的exp生成一个Watcher            var text = node.textContent;            var reg = /\{\{([a-z|1-9|_]+)\}\}/g;            reg.test(text);            var exp = RegExp.$1;            new Wathcer(exp, this.$vm, function (value) {                node.textContent = text.replace(reg, value);            });        },        compileAttrs: function (node) {            var complieUtils = this.complieUtils;            var attrs = node.attributes, me = this;            [].slice.call(attrs).forEach(function (attr) {                if (me.isDirective(attr)) {                    var dir = attr.name.substring(2).split(':')[0];                    var exp = attr.value;                    complieUtils[dir + '_compile'].call(me, node, attr, exp);                }            })        },        isDirective: function (attr) {   //通过name  value获取属性的键值            return /v-*/.test(attr.name);  //判断属性名是否以v-开头        },        complieUtils: {            model_compile: function (node, attr, exp) {                node.addEventListener("keyup", (e) => {                    this.$vm.$data[exp] = e.target.value;                });                node.removeAttribute(attr.name);                new Wathcer(exp, this.$vm, function (value) {                    node.value = value;                });            },            bind_compile: function (node, attr, exp) {                var attribute = attr.name.split(':')[1];                node.removeAttribute(attr.name);                new Wathcer(exp, this.$vm, function (value) {                    node.setAttribute(attribute, value);                });            },            show_compile: function (node, attr, exp) {                node.removeAttribute(attr.name);                new Wathcer(exp, this.$vm, function (value) {                    node.style.visibility = value ? 'visible' : 'hidden';                });            }    }

这里我添加了complieUtils对象,如果是v-text指令,就会使用调用text_compile,如果是v-bind指令,就会调用bind_compile函数,这样设计的目的是如果你想增加vue里面的指令,只需要扩展compileUtils这个对象即可~甚至你还可以给用户提供自定义属性指令的接口,然后本质是往complieUtils里面添加新的函数。
还值得一提的是Watcher对象,你在创建这个对象时需要给它传递一个callback,也就是更新时调用的函数。这里callback是个闭包,保留了对DOM的引用,以实现更新。
接下来看看Watcher

三  、实现Watcher,实现数据更新UI

function Wathcer(exp, vm, callback) {        this.exp = exp;        this.vm = vm;        this.callback = callback;        Dep.target = this;        this.get();        Dep.target = null;        this.callback(this.value);     //初始化试图    }    Wathcer.prototype = {        get: function () {            this.value = this.vm.$data[this.exp];        },        update: function () {            this.get();    //先获得value值            this.callback(this.value);        }    }
Dep.target是一个桥梁,用来传递wather实例,在调用Watcher的构造函数时,会把自己赋值给Dep.target,然后触发对应数据的get,在get方法里会把该watcher添加到观察者集合里,最后别忘了将Dep.target置成null.Wacher的更新函数里面会执行在编译阶段传递过来的callback。如果这里思路有点乱的话,再回顾下一下代码。

 Object.defineProperty(obj, key, {            configurable: false,        //不能再define            enumerable: true,           //可枚举            set: function (newValue) {                if (newValue == value) return;                value = newValue;                dep.notify();            },            get: function () {                if (Dep.target) {                    dep.addWatcher(Dep.target);                }                return value;            }        })
Dep.prototype = {        addWatcher: function (watcher) {            this.subs.push(watcher);        },        notify: function () {            this.subs.forEach((watcher) => {                watcher.update();            })        }    }
看一下加粗的地方,理一理,就明白了。

四  、实现MVVM,封装Vue对象

最后一步,封装vue对象,对前三者进行整合。
function Vue(options) {        this.$options = options;        var data = options.data;        this.$data = data;        this.$el = options.el;        observe(data);        //劫持监听data所有属性        this.$compile = new Compile(this.$el, this)  //模板解析    }
这样我们的一个简单的vue就实现了,当然这真的只是一个简单的vue mvvm的双向数据绑定,很多功能是不完善的~~~不过这个思路是挺棒的~
如果你感兴趣,可以考虑实现v-for指令,或者{{a.c.b[0]}}这种复杂的解析。
如果你觉得不错,给个赞吧~~~有问题可以评论~