MVVM原理及實現——VUE

MVVM框架

在講MVVM框架的時候,就繞不開MVC框架

MVC框架

將整個前端頁面分成View,Controller,Modal,視圖上發生變化,通過Controller(控件)將響應傳入到Model(數據源),由數據源改變View上面的數據。
clipboard.png
但是由於MVC框架允許view和model直接俄通信,所以隨着業務量的擴大,可能會出現很難處理的依賴關係,完全背離了開發所應該遵循的“開放封閉原則”。

MVVM詳解

面對這個問題,MVVM框架就出現了,它與MVC框架的主要區別有兩點:
1、實現數據與視圖的分離
2、通過數據來驅動視圖,開發者只需要關心數據變化,DOM操作被封裝了。
MVVM
數據就是簡單的javascript對象,需要將數據綁定到模板上。監聽視圖的變化,視圖變化後通知數據更新,數據更新會再次導致視圖的變化!

VUE雙向綁定原理

clipboard.png

Vue的雙向綁定主要通過compile(編譯模板)、數據劫持、發佈者-訂閱者模式模式來實現的。初始化數據時通過Object.defineProperty來劫持各個屬性的getter和setter。在編譯模板時,把依賴數據的元素創建觀察者,在get屬性的時候,將watcher實例放入訂閱列表中,在set數據的時候,notify所有的訂閱者,觸發訂閱者的update方法來更新數據。達到數據變化 —>視圖更新;視圖交互變化(input)—>數據model變更雙向綁定效果。

Object.defineProperty

Object.defineProperty可以又兩種方式在對象上直接定義一個屬性,或者修改一個已經存在的屬性的值。

方法1:屬性描述符

let obj = {};
    Object.defineProperty(obj, 'test', {
        // 可配置
        configurable: true,
        // 可寫
        writable: true,
        value: 'liuliu',
        // 是否可遍歷
        enumerable: true
    })
    

運行結果:
clipboard.png
可以通過definePorperty方法給對象添加一個test屬性,此屬性configurable時,此屬性可刪除;writeable時,可以重新給此屬性賦值。enumable時,此時行可以被Object.keys()等方法遍歷。

方法2:存取描述符

let obj = {};
let val = null;
Object.defineProperty(obj, 'test', {
    get() {
        console.log('get value')
        return val;
    },  
    set(value) {
        console.log('set value')
        val = value;
    }
})

clipboard.png
由一對 getter、setter 函數功能來描述的屬性。

注意:兩種方法不可同時使用

vue數據劫持

index.html

<body>
</body>
<script src="./mvvm.js"></script>
<script>
    let vue =  new Vue({
        el: '#app',
        data: {
            a: {b: 1},
            c: 2
        }
    })

</script>

mvvm.js

function Vue(options) {
    this.$options = options;
    let data = this._data = this.$options.data;
    observe(data);
}

//觀察對象 給對象添加defineProperty
function observer(data) {
    for (let key in data) {
        let val = data[key];
        // 如果val不是基本數據類型,則需要繼續劫持
        if (val != null && typeof val === "object") {
            observer(val);
        }
        Object.defineProperty(data, key, {
            enumerable: true,
            get() {
                console.log('get data')
                // 返回data[key]的值。
                return val;
            },
            set(newval) {
               console.log('set data');
                // 如果數據發生變化
                if (newval === val) {
                    return;
                }
                //將新值賦予val
                val = newval;
                // 新值如果不是基本數據類型,則需要繼續
                if (val != null && typeof val === "object") {
                    observer(val);
                }
            }
        })
    }
}

測試結果
clipboard.png

在初始化數據的時候,我們通過observe函數來觀察初始數據,設置數據的getter和setter來劫持數據。我們在獲取a的值的時候,會執行其get方法,打印數據並返回a的值,在給屬性賦值的時候執行set方法,如此這樣我們就可以在get和set數據的時候做一些其它的操作。

數據代理

mvvm.js

function Vue(options) {
    this.$options = options;
    let data = this._data = this.$options.data;
    observe(data);

    // this 代理this._data
    Object.keys(data).forEach(key =>{
        Object.defineProperty(this, key, {
            enumerable: true,
            get() {
                return this._data[key];
            },
            set(value) {
                this._data[key] = value;
            }
        })
    })
}

運行結果

clipboard.png
Vue在訪問data數據的時候是使用this.a的方式而不是this._data.a,所以遍歷data的屬性,將其通過defineProperty的方法代理到Vue上面。在獲取vue.a值的時候,get中返回vue._data.a的值,這時候會觸發observe中對a屬性get的劫持,返回this._data.a的值。在給vue.a進行賦值時,由於get的是vue._data.a的數值,則需要將新值set給vue._data.a。
通過數據代理,我們將vue._data中的數據代理到vue中。

編譯模板

index.html

<body>
   <div id="app">
    <p>b的值爲:{{a.b}}</p>
    <p>c的值爲:{{c}}</p>
   </div>
</body>
<script src="./mvvm.js"></script>
<script>
    let vue =  new Vue({
        el: '#app',
        data: {
            a: {b: 1},
            c: 2
        }
    })

</script>

mvvm.js

function Vue(options) {
    ...
    //和上文保持一致

    // 編譯模板
    new Compile(options.el, this);
}

function Compile(el, vm) {
    // 獲取vue實例的根元素
    vm.$el = document.querySelector(el);
    let fragment = document.createDocumentFragment();
    // 將dom節點移動到內存中
    while(child = vm.$el.firstChild) {
        fragment.appendChild(child);
    }
    function replace(fragment) {
        // fragement是一個類似數組結構
        Array.from(fragment.childNodes).forEach(node =>{
            // 獲取節點的文本內容
            let content = node.textContent;
            // {{}}的正則
            let reg = /\{\{(.*)\}\}/;
            
            //如果node是文本節點且有需要編譯的{{}}
            if(node.nodeType === 3 && reg.test(content)) {
                debugger
                // 獲取匹配正則表達式中的第一個匹配 (a.b) 並將其分割成字符數組[a,b]
                let arr = RegExp.$1.split('.');
                var val = vm;
                // 獲取vue.a.b的值
                arr.forEach(key =>{
                    // 會劫持vue._data中的get方法來獲取返回的數據
                    val = val[key];
                })
                // 把獲取的數據替換掉模板
                node.textContent = content.replace(/\{\{(.*)\}\}/, val);
            }
            // 如果當前結點還有子節點 則遞歸編譯
            if(node.childNodes) {
                replace(node);
            }
        })
    }
    //編譯vue實例的根節點
    replace(fragment);
    // 將在內存中的節點重新入到dom中
    vm.$el.appendChild(fragment);
}

運行結果:

clipboard.png
劫持並代理數據之後,我們開始編譯文檔中存在的模板。獲取根節點app的文檔元素,並將其移入到內存中,遞歸循環判斷節點文本中的{{}},並將其替換爲data中對應的數據。最後將替換完成的文檔片段插入到dom中,從而完成編譯。

觀察者

mvvm.js

function Compile(el, vm) {
    // 獲取vue實例的根元素
    vm.$el = document.querySelector(el);
    let fragment = document.createDocumentFragment();
    // 將dom節點移動到內存中
    while(child = vm.$el.firstChild) {
        fragment.appendChild(child);
    }
    function replace(fragment) {
        // fragement是一個類似數組結構
        Array.from(fragment.childNodes).forEach(node =>{
            // 獲取節點的文本內容
            let content = node.textContent;
            // {{}}的正則
            let reg = /\{\{(.*)\}\}/;
            
            //如果node是文本節點且有需要編譯的{{}}
            if(node.nodeType === 3 && reg.test(content)) {
                debugger
                // 獲取匹配正則表達式中的第一個匹配 (a.b) 並將其分割成字符數組[a,b]
                let arr = RegExp.$1.split('.');
                var val = vm;
                // 獲取vue.a.b的值
                arr.forEach(key =>{
                    // 會劫持vue._data中的get方法來獲取返回的數據
                    val = val[key];
                })
                // 添加watcher 當data中依賴的數據改變時,通過watcher的update方法更新到頁面中去
                new Watcher(vm, RegExp.$1, function(newVal){
                    node.textContent = content.replace(/\{\{(.*)\}\}/, newVal);
                })
                // 把獲取的數據替換掉模板
                node.textContent = content.replace(/\{\{(.*)\}\}/, val);
            }
            // 如果當前結點還有子節點 則遞歸編譯
            if(node.childNodes) {
                replace(node);
            }
        })
    }
    //編譯vue實例的根節點
    replace(fragment);
    // 將在內存中的節點重新入到dom中
    vm.$el.appendChild(fragment);
}

// 觀察者
function Watcher(vm, exp, fn) {
    // 當前對象
    this.vm = vm;
    // 正則
    this.exp = exp;
    //回掉函數
    this.fn = fn;
    // 獲取引用對應的值
    let arr = this.exp.split('.');
    let val = vm;
    arr.forEach(key => {
        val = val[key];
    })
}
Watcher.prototype.update = function() {
    // 獲取data中的值
    let val = this.vm;
    let arr = this.exp.split('.');
    arr.forEach(key =>{
        val = val[key];
    })
    // 執行watcher的update方法,使更新的值渲染到文檔中
    this.fn(val);
}

觀察者需要在依賴的數據該生改變時,執行wathcer的update方法,將新的數據渲染到文檔中去。所以我們要給需要編譯替換的元素添加Watcher實例,並將當前元素的data和表達式傳入。更新值的時候可以根據這兩個參數獲取到最新的數據。

發佈訂閱

function Sub()

//發佈訂閱
function Dep() {
    // subs中存儲訂閱實例
    this.subs = [];
}
// 添加訂閱,給依賴當前數據的實例的watcher添加到訂閱列表中
Dep.prototype.addSub = function(sub) {
    this.subs.push(sub);
}
// 發佈信息函數
Dep.prototype.notify = function() {
    // 依次執行訂閱者的update方法使得訂閱者也更新
    this.subs.forEach(item => {
        item.update();
    })
}

function Watcher()

// 觀察者
function Watcher(vm, exp, fn) {
    // 當前對象
    this.vm = vm;
    // 正則
    this.exp = exp;
    //回掉函數
    this.fn = fn;
    //將當前實例綁定到Dep構造的屬性上
    Dep.target = this;
    // 獲取引用對應的值
    let arr = this.exp.split('.');
    let val = vm;
    arr.forEach(key => {
        // 獲取data數據的時候 因爲target不爲空,所以會將當前實例放入val的訂閱列表中
        val = val[key];
    })
    // 循環完依賴 將target置空,以免將當前實例添加在非依賴的訂閱列表中
    Dep.target = null;
}

function observe()

//觀察對象 給對象添加defineProperty
function observe(data) {
    for(let key in data) {
        let val = data[key];
        let dep = new Dep();
        //如果data的屬性值還是對象,則遞歸做劫持
        if (data !== null &&typeof val=== 'object') {
          observe(val);
        }
        // 使用defineProperty的方式定義屬性
        Object.defineProperty(data, key, {
            enumerable: true,
            get() {
                // 當target不爲空時,則當前data爲target的依賴, 所以將其添加到訂閱列表中
                if (Dep.target) {
                    dep.addSub(Dep.target);
                }
                return val;
            },
            set(newVal) {
                // 如果舊值不等於新值 則賦值
                if(val === newVal) {
                    return ;
                }
                // 在get數據的時候可以將新值返回
                val = newVal;
                // 如果新值爲一個對象,則需要繼續對屬性做劫持
                if(val !== null && typeof val === 'object') {
                    observe(val);
                }
                // 數據發生變化時,通知訂閱者更新
                dep.notify();
            }
        })
    }

}

運行結果:
dep
當獲取編譯模板時,生成Watcher實例,在觀察者的構造方法中循環獲取依賴數據的value,此時observe會get劫持,將當前觀察實例放入訂閱列表中。若模板依賴的數據發生改變,observe劫持set,將新值複製給當前屬性,並通知subs中所有的依賴,使其執行update方法來進行更新。

雙向綁定

index.html

<body>
   <div id="app">
    <p>b的值爲:{{a.b}}</p>
    <p>c的值爲:{{c}}</p>
    <input type="text" v-model="a.b">
   </div>
</body>

function Compile()

function Compile(el, vm) {
    // 獲取vue實例的根元素
    vm.$el = document.querySelector(el);
    let fragment = document.createDocumentFragment();
    // 將dom節點移動到內存中
    while(child = vm.$el.firstChild) {
        fragment.appendChild(child);
    }
    function replace(fragment) {
        // fragement是一個類似數組結構
        Array.from(fragment.childNodes).forEach(node =>{
            // 獲取節點的文本內容
            let content = node.textContent;
            // {{}}的正則
            let reg = /\{\{(.*)\}\}/;
            
            //如果node是文本節點且有需要編譯的{{}}
            if(node.nodeType === 3 && reg.test(content)) {
                // 獲取匹配正則表達式中的第一個匹配 (a.b) 並將其分割成字符數組[a,b]
                let arr = RegExp.$1.split('.');
                var val = vm;
                // 獲取vue.a.b的值
                arr.forEach(key =>{
                    // 會劫持vue._data中的get方法來獲取返回的數據
                    val = val[key];
                })
                // 添加watcher 當data中依賴的數據改變時,通過watcher的update方法更新到頁面中去
                new Watcher(vm, RegExp.$1, function(newVal){
                    node.textContent = content.replace(/\{\{(.*)\}\}/, newVal);
                })
                // 把獲取的數據替換掉模板
                node.textContent = content.replace(/\{\{(.*)\}\}/, val);
            }

            //如果是元素節點
            if(node.nodeType === 1) {
                // 獲取元素的所有屬性
                let attrs = node.attributes;
                Array.from(attrs).forEach(attr =>{
                      //attr = 'v-model= "b" '
                      let name = attr.name; //name = v-model
                      let exp = attr.value  //exp = b;
                    // 如果屬性以v-開頭
                    if (name.indexOf('v-') === 0) {
                        let val = vm;
                        exp.split('.').forEach(key =>{
                            val = val[key];
                        })
                        node.value = val;
                        // 訂閱數據更新事件
                        new Watcher(vm, exp, function (newVal) {
                            node.value = newVal;
                        })
                        // 輸入框變化時, 將值賦予到vm上
                        node.addEventListener('input', function (e) {
                            let newVal = e.target.value; //獲取新值
                            // 觸發observe的set
                            expr = exp.split('.');
                            expr.reduce((prev, next, index) => {
                                if(index === expr.length -1) {
                                    prev[next] = newVal;
                                } else {
                                    return prev[next];
                                }
                            }, vm)
                        })
                    }
                })
            }
            // 如果當前結點還有子節點 則遞歸編譯
            if(node.childNodes) {
                replace(node);
            }
        })
    }
    //編譯vue實例的根節點
    replace(fragment);
    // 將在內存中的節點重新入到dom中
    vm.$el.appendChild(fragment);
}

運行結果:
v-model
監聽輸入框的input事件,將新值複製給data, set會通知訂閱者進行更新。直接改變data數據,劫持數據set的時候會通知依賴update,從而實現了數據的雙向綁定。

計算屬性

index.html

<body>
   <div id="app">
    <p>b的值爲:{{a.b}}</p>
    <p>c的值爲:{{c}}</p>
    <input type="text" v-model="a.b">
    {{hello}}
   </div>
</body>
<script src="./mvvm.js"></script>
<script>
    let vue =  new Vue({
        el: '#app',
        data: {
            a: {b: 1},
            c: 'sdsd'
        },
        computed: {
            hello() {
                return this.a.b + this.c;
            }
        }
    })

</script>

mvvm.js

function Vue(options) {
    this.$options = options;
    let data = this._data = this.$options.data;
    // 觀察數據data
    observe(data);

    // this 代理this._data
    Object.keys(data).forEach(key =>{
        Object.defineProperty(this, key, {
            enumerable: true,
            get() {
                return this._data[key];
            },
            set(value) {
                this._data[key] = value;
            }
        })
    })
    initComputed.call(this);
    // 編譯模板
    new Compile(options.el, this);
}

// 初始化計算屬性
function initComputed() {
    debugger
    let vm = this;
    let computed = this.$options.computed;
    for(k in computed) {
        Object.defineProperty(vm, k, {
            enumerable: true,
            //  判斷計算屬性是個函數還是一個對象 hello() {} or hello: {get(){}, set() {}}
            get: typeof computed[k] === 'function' ? computed[k] : computed[k].get,
            set() {

            } 
        })
  

運行結果:
computed
盼盼計算屬性是一個函數還是有get和set的對象。執行其方法,獲取data的數據並返回。由於data中的數據都是在緩存中的,所有computed屬性具有緩存依賴性。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章