1. 程式人生 > >手寫VUE mvvm雙向資料繫結

手寫VUE mvvm雙向資料繫結

當你開啟這篇文章時,你肯定已經使用過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]}}這種複雜的解析。 如果你覺得不錯,給個贊吧~~~有問題可以評論~