欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

手写VUE mvvm双向数据绑定

程序员文章站 2024-02-01 14:29:34
...

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

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

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

手写VUE mvvm双向数据绑定

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

手写VUE mvvm双向数据绑定

就是当我在输入框输入文本的时候,下面能够同步,并且当我改变一个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]}}这种复杂的解析。
如果你觉得不错,给个赞吧~~~有问题可以评论~