MVVM模式到底是什麼?實現原理剖析

toymm發表於2018-06-05

Vue和React的興起,MVVM模式已經成為主流開發思想,那這種模式的實現原理是什麼?雙向資料繫結是怎樣工作的?釋出訂閱是什麼?本文以Vue的設計思想帶你解開這些迷團

Object.defineProperty()

Vue是不支援IE8以下的瀏覽器,因為它使用了IE8無法模擬的ECMAScript5特性:Object.defineProperty()

通常我們以字面量的方式定義一個物件,這種方式的屬性是不存在getset方法的,並且物件的屬性是可以隨意更改或刪除

MVVM模式到底是什麼?實現原理剖析

Object.defineProperty() 方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性, 並返回這個物件。Vue就是用了這個方法進行資料劫持,在把資料掛載到Vue例項上

var o = {}; // 建立一個新物件

// 在物件中新增一個屬性與資料描述符的示例
Object.defineProperty(o, "a", {
  value : 37,
  writable : true,
  enumerable : true,
  configurable : true
});

// 物件o擁有了屬性a,值為37
// 在物件中新增一個屬性與存取描述符的示例
var bValue;
Object.defineProperty(o, "b", {
  get : function(){
    return bValue;
  },
  set : function(newValue){
    bValue = newValue;
  },
  enumerable : true,
  configurable : true
});

o.b = 38;
// 物件o擁有了屬性b,值為38
// o.b的值現在總是與bValue相同,除非重新定義o.b
// 資料描述符和存取描述符不能混合使用
Object.defineProperty(o, "conflict", {
  value: 0x9f91102, 
  get: function() { 
    return 0xdeadbeef; 
  } 
});
// throws a TypeError: value appears only in data descriptors, get appears only in accessor descriptors
複製程式碼

更詳細的Object.defineProperty() 解釋,猛戳

釋出訂閱

先說一下發布訂閱模式和觀察者模式有什麼區別

釋出訂閱模式是最常用的一種觀察者模式的實現,並且從解耦和重用角度來看,更優於典型的觀察者模式

釋出訂閱模式多了個事件通道在觀察者模式中,觀察者需要直接訂閱目標事件;在目標發出內容改變的事件後,直接接收事件並作出響應

通俗點說:

A告訴B去做三件事(買鞋、買褲子、買領帶),每件事做完都要告訴A,

B買到鞋告訴A“鞋買到了”,A收到訊息後發了個朋友圈;

B買到褲子後告訴A“褲子買到了”,A收到訊息後發了個微博;

B買到領帶後告訴A“領帶買到了”,A收到訊息後來了個自拍;

MVVM模式到底是什麼?實現原理剖析
在釋出訂閱模式中,釋出者和訂閱者之間多了一個釋出通道;一方面從釋出者接收事件,另一方面向訂閱者釋出事件;訂閱者需要從事件通道訂閱事件以此避免釋出者和訂閱者之間產生依賴關係

通俗點說:

N個人關注了A的公眾號 (訂閱)

A寫好了文章提交到微信公眾號平臺 (釋出)

微信公眾號平臺推送到了這N個人的微信客戶端 (廣播)

所有的訂閱者接到訊息後可以選擇自己的動作閱讀/忽略/分享

MVVM模式到底是什麼?實現原理剖析
我們接下來說的MVVM就是通過Object.defineProperty()釋出訂閱實現的雙向資料繫結

MVVM

先看一下MVVM的原理,接下來我們根據這個圖一步一步深入

alt
我們以Vue的規則,自己寫的MyMVVM

<div id="app">
    <p>a的值{{a.a}}</p>
    <div>b的值{{b}}</div>
    <input type="text" v-model="b">
    {{hello}}
</div>
複製程式碼
  let myMVVM = new MyMVVM({
        el:"#app",
        data:{
            a:{a:"a"},
            b:"是b"
        }
    })
複製程式碼

new MyMVVM()

例項化的時候MyMVVM例項掛載了傳入的data

function MyMVVM(options = {}) {
    this.$options = options; // 把所有屬性掛載在$options
    let data = this._data = this.$options.data;
    // this 代理了this._data
    for(let key in data){
        Object.defineProperty(this,key,{
            enumerable:true,
            get(){
                return this._data[key]
            },
            set(newVal){
                this._data[key] = newVal
            }
        })
    }
}
複製程式碼

Compile編譯模板

function Compile(el,vm) {
    // el表示替換的範圍
    // DOM中的節點塞入fragment時,原節點會被刪除
    vm.$el = document.querySelector(el);
    let fragment = document.createDocumentFragment();
    while(child = vm.$el.firstChild){ // 獲取到的元素節點 塞入fragment 在記憶體中操作
        fragment.appendChild(child)
    }

    replace(fragment);
    function replace(fragment){
        Array.from(fragment.childNodes).forEach(function (node) { // 類陣列轉換為陣列,迴圈
            let text = node.textContent;
            let reg = /\{\{(.*)\}\}/;
            // 資料渲染檢視
            if(node.nodeType === 3 && reg.test(text)){ // 文字節點
                let arr = RegExp.$1.split('.');
                let val = vm;
                arr.forEach(function (k) { // 取this.a.a / this.b
                    val = val[k]
                });
                // 替換
                node.textContent = text.replace(reg,val) // 替換模板
            }
            // 檢視更新資料,資料再渲染檢視
            if(node.nodeType === 1){ // DOM節點 輸入內容時資料一起更新
                let nodeAttrs = node.attributes;
                Array.from(nodeAttrs).forEach(function (attr) {
                    let name = attr.name; 
                    let exp = attr.value;
                    if(name.indexOf("v-") === 0){ // 帶有v-指令的DOM節點
                        node.value = vm[exp];
                    }
                    node.addEventListener("input",function (e) {
                        let newVal = e.target.value;
                        vm[exp] = newVal  // 呼叫VM上data的set方法跟新檢視資料
                    })
                })
            }
            if(node.childNodes){ // 節點深度遞迴
                replace(node)
            }
        });
    }
    vm.$el.appendChild(fragment)
}
複製程式碼

Observer 資料劫持

資料劫持的過程就是把傳遞給例項的物件通過Object.defineProperty()重新定義屬性,這樣就擁有了getset方法,便於我們後續觀察資料的變化

vue特點是不能新增不存在的屬性,因為不存在的屬性在資料劫持的時候沒法重新定義,也就不能增加getset

// 觀察物件給物件增加ObjectDefineProperty
function Observe(data) {
    for(let key in data){  // 把data屬性通過Object.defineProperty()的方式 定義屬性
        let val = data[key];
        Object.defineProperty(data,key,{
            enumerable:true,
            get(){
                return val
            },
            set(newVal){
                if(val === newVal) {
                    return;
                }
                val = newVal;
            }
        })
    }
}
複製程式碼

Watcher

Watcher是一個類,通過這個類建立的例項都有update方法,用來執行資料發生變化後的更新動作

function Watcher(vm,exp,fn) {
    this.fn = fn;
    this.vm = vm;
    this.exp = exp;
    Dep.target = this;
    let val = vm;
    let arr = exp.split('.');
    arr.forEach(function (k) { // 目的是為了觸發取值時的get方法,get方法中把watcher新增到佇列裡
        val = val[k]
    });
    Dep.target = null; // 新增成功後target置為null

}
Watcher.prototype.update = function () {
    let val =this.vm;
    let arr = this.exp.split('.');
    arr.forEach(function (k) {
        val = val[k]
    });
    this.fn(val);
};
複製程式碼

釋出訂閱

先有訂閱再有釋出

function Dep() {
    this.subs = []; // 存放訂閱佇列
}
Dep.prototype.addSub = function (sub) { // 往容器中儲存訂閱資訊
    this.subs.push(sub)
};

Dep.prototype.notify = function () {  // 釋出
    this.subs.forEach(sub => sub.update())
};
複製程式碼

總結

我們完成了MVVM框架,正確的呼叫組合這些方法就能實現資料的雙向繫結了,原始碼請參考這裡

相關文章