模擬 Vue 手寫一個 MVVM

PandaShen發表於2019-03-04

模擬 Vue 手寫一個 MVVM


閱讀原文


MVVM 的前世今生

MVVM 設計模式,是由 MVC(最早來源於後端)、MVP 等設計模式進化而來,M - 資料模型(Model),VM - 檢視模型(ViewModel),V - 檢視層(View)。

在 MVC 模式中,除了 Model 和 View 層以外,其他所有的邏輯都在 Controller 中,Controller 負責顯示頁面、響應使用者操作、網路請求及與 Model 的互動,隨著業務的增加和產品的迭代,Controller 中的處理邏輯越來越多、越來越複雜,難以維護。為了更好的管理程式碼,為了更方便的擴充套件業務,必須要為 Controller “瘦身”,需要更清晰的將使用者介面(UI)開發從應用程式的業務邏輯與行為中分離,MVVM 為此而生。

很多 MVVM 的實現都是通過資料繫結來將 View 的邏輯從其他層分離,可以用下圖來簡略的表示:


模擬 Vue 手寫一個 MVVM


使用 MVVM 設計模式的前端框架很多,其中漸進式框架 Vue 是典型的代表,並在開發使用中深得廣大前端開發者的青睞,我們這篇就根據 Vue 對於 MVVM 的實現方式來簡單模擬一版 MVVM 庫。


MVVM 的流程分析

在 Vue 的 MVVM 設計中,我們主要針對 Compile(模板編譯)、Observer(資料劫持)、Watcher(資料監聽)和 Dep(釋出訂閱)幾個部分來實現,核心邏輯流程可參照下圖:


模擬 Vue 手寫一個 MVVM


類似這種 “造輪子” 的程式碼毋庸置疑一定是通過物件導向程式設計來實現的,並嚴格遵循開放封閉原則,由於 ES5 的物件導向程式設計比較繁瑣,所以,在接下來的程式碼中統一使用 ES6 的 class 來實現。


MVVM 類的實現

在 Vue 中,對外只暴露了一個名為 Vue 的建構函式,在使用的時候 new 一個 Vue 例項,然後傳入了一個 options 引數,型別為一個物件,包括當前 Vue 例項的作用域 el、模板繫結的資料 data 等等。

我們模擬這種 MVVM 模式的時候也構建一個類,名字就叫 MVVM,在使用時同 Vue 框架類似,需要通過 new 指令建立 MVVM 的例項並傳入 options

// MVVM.js 檔案
class MVVM {
    constructor(options) {
        // 先把 el 和 data 掛在 MVVM 例項上
        this.$el = options.el;
        this.$data = options.data;

        // 如果有要編譯的模板就開始編譯
        if (this.$el) {
            // 資料劫持,就是把物件所有的屬性新增 get 和 set
            new Observer(this.$data);

            // 將資料代理到例項上
            this.proxyData(this.$data);

            // 用資料和元素進行編譯
            new Compile(this.el, this);
        }
    }
    proxyData(data) { // 代理資料的方法
        Object.keys(data).forEach(key => {
            Object.defineProperty(this, key, {
                get() {
                    return data[key];
                }
                set(newVal) {
                    data[key] = newVal;
                }
            });
        });
    }
}
複製程式碼

通過上面程式碼,我們可以看出,在我們 new 一個 MVVM 的時候,在引數 options 中傳入了一個 Dom 的根元素節點和資料 data 並掛在了當前的 MVVM 例項上。

當存在根節點的時候,通過 Observer 類對 data 資料進行了劫持,並通過 MVVM 例項的方法 proxyDatadata 中的資料掛在當前 MVVM 例項上,同樣對資料進行了劫持,是因為我們在獲取和修改資料的時候可以直接通過 thisthis.$data,在 Vue 中實現資料劫持的核心方法是 Object.defineProperty,我們也使用這個方式通過新增 gettersetter 來實現資料劫持。

最後使用 Compile 類對模板和繫結的資料進行了解析和編譯,並渲染在根節點上,之所以資料劫持和模板解析都使用類的方式實現,是因為程式碼方便維護和擴充套件,其實不難看出,MVVM 類其實作為了 Compile 類和 Observer 類的一個橋樑。


模板編譯 Compile 類的實現

Compile 類在建立例項的時候需要傳入兩個引數,第一個引數是當前 MVVM 例項作用的根節點,第二個引數就是 MVVM 例項,之所以傳入 MVVM 的例項是為了更方便的獲取 MVVM 例項上的屬性。

Compile 類中,我們會盡量的把一些公共的邏輯抽取出來進行最大限度的複用,避免冗餘程式碼,提高維護性和擴充套件性,我們把 Compile 類抽取出的例項方法主要分為兩大類,輔助方法和核心方法,在程式碼中用註釋標明。

1、解析根節點內的 Dom 結構

// Compile.js 檔案
class Compile {
    constructor(el, vm) {
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;

        // 如過傳入的根元素存在,才開始編譯
        if (this.el) {
            // 1、把這些真實的 Dom 移動到記憶體中,即 fragment(文件碎片)
            let fragment = this.node2fragment(this.el);
        }
    }

    /* 輔助方法 */
    // 判斷是否是元素節點
    isElementNode(node) {
        return node.nodeType === 1;
    }

    /* 核心方法 */
    // 將根節點轉移至文件碎片
    node2fragment(el) {
        // 建立文件碎片
        let fragment = document.createDocumentFragment();
        // 第一個子節點
        let firstChild;

        // 迴圈取出根節點中的節點並放入文件碎片中
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment;
    }
}
複製程式碼

上面編譯模板的過程中,前提條件是必須存在根元素節點,傳入的根元素節點允許是一個真實的 Dom 元素,也可以是一個選擇器,所以我們建立了輔助方法 isElementNode 來幫我們判斷傳入的元素是否是 Dom,如果是就直接使用,是選擇器就獲取這個 Dom,最終將這個根節點存入 this.el 屬性中。

解析模板的過程中為了效能,我們應取出根節點內的子節點存放在文件碎片中(記憶體),需要注意的是將一個 Dom 節點內的子節點存入文件碎片的過程中,會在原來的 Dom 容器中刪除這個節點,所以在遍歷根節點的子節點時,永遠是將第一個節點取出存入文件碎片,直到節點不存在為止。

2、編譯文件碎片中的結構

在 Vue 中的模板編譯的主要就是兩部分,也是瀏覽器無法解析的部分,元素節點中的指令和文字節點中的 Mustache 語法(雙大括號)。

// Compile.js 檔案
class Compile {
    constructor(el, vm) {
        this.el = this.isElementNode(el) ? el : document.querySelector(el);
        this.vm = vm;

        // 如過傳入的根元素存在,才開始編譯
        if (this.el) {
            // 1、把這些真實的 Dom 移動到記憶體中,即 fragment(文件碎片)
            let fragment = this.node2fragment(this.el);

            // ********** 以下為新增程式碼 **********
            // 2、將模板中的指令中的變數和 {{}} 中的變數替換成真實的資料
            this.compile(fragment);

            // 3、把編譯好的 fragment 再塞回頁面中
            this.el.appendChild(fragment);
            // ********** 以上為新增程式碼 **********
        }
    }

    /* 輔助方法 */
    // 判斷是否是元素節點
    isElementNode(node) {
        return node.nodeType === 1;
    }

    // ********** 以下為新增程式碼 **********
    // 判斷屬性是否為指令
    isDirective(name) {
        return name.includes("v-");
    }
    // ********** 以上為新增程式碼 **********

    /* 核心方法 */
    // 將根節點轉移至文件碎片
    node2fragment(el) {
        // 建立文件碎片
        let fragment = document.createDocumentFragment();
        // 第一個子節點
        let firstChild;

        // 迴圈取出根節點中的節點並放入文件碎片中
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment;
    }

    // ********** 以下為新增程式碼 **********
    // 解析文件碎片
    compile(fragment) {
        // 當前父節點節點的子節點,包含文字節點,類陣列物件
        let childNodes = fragment.childNodes;

        // 轉換成陣列並迴圈判斷每一個節點的型別
        Array.from(childNodes).forEach(node => {
            if (this.isElementNode(node)) { // 是元素節點
                // 遞迴編譯子節點
                this.compile(node);

                // 編譯元素節點的方法
                this.compileElement(node);
            } else { // 是文字節點
                // 編譯文字節點的方法
                this.compileText(node);
            }
        });
    }
    // 編譯元素
    compileElement(node) {
        // 取出當前節點的屬性,類陣列
        let attrs = node.attributes;
        Array.form(attrs).forEach(attr => {
            // 獲取屬性名,判斷屬性是否為指令,即含 v-
            let attrName = attr.name;

            if (this.isDirective(attrName)) {
                // 如果是指令,取到該屬性值得變數在 data 中對應得值,替換到節點中
                let exp = attr.value;

                // 取出方法名
                let [, type] = attrName.split("-");

                // 呼叫指令對應得方法
                CompileUtil[type](node, this.vm, exp);
            }
        });

    }
    // 編譯文字
    compileText(node) {
        // 獲取文字節點的內容
        let exp = node.contentText;

        // 建立匹配 {{}} 的正規表示式
        let reg = /\{\{([^}+])\}\}/g;

        // 如果存在 {{}} 則使用 text 指令的方法
        if (reg.test(exp)) {
            CompileUtil["text"](node, this.vm, exp);
        }
    }
    // ********** 以上為新增程式碼 **********
}
複製程式碼

上面程式碼新增內容得主要邏輯就是做了兩件事:

  • 呼叫 compile 方法對 fragment 文件碎片進行編譯,即替換內部指令和 Mustache 語法中變數對應的值;
  • 將編譯好的 fragment 文件碎片塞回根節點。

在第一個步驟當中邏輯是比較繁瑣的,首先在 compile 方法中獲取所有的子節點,迴圈進行編譯,如果是元素節點需要遞迴 compile,傳入當前元素節點。在這個過程當中抽取出了兩個方法,compileElementcompileText 用來對元素節點的屬性和文字節點進行處理。

compileElement 中的核心邏輯就是處理指令,取出元素節點所有的屬性判斷是否是指令,是指令則呼叫指令對應的方法。compileText 中的核心邏輯就是取出文字的內容通過正規表示式匹配出被 Mustache 語法的 “{{ }}” 包裹的內容,並呼叫處理文字的 text 方法。

文字節點的內容有可能存在 “{{ }} {{ }} {{ }}”,正則匹配預設是貪婪的,為了防止第一個 “{” 和最後一個 “}” 進行匹配,所以在正規表示式中應使用非貪婪匹配。

在呼叫指令的方法時都是呼叫的 CompileUtil 下對應的方法,我們之所以單獨把這些指令對應的方法抽離出來儲存在 CompileUtil 物件下的目的是為了解耦,因為後面其他的類還要使用。

3、CompileUtil 物件中指令方法的實現

CompileUtil 中儲存著所有的指令方法及指令對應的更新方法,由於 Vue 的指令很多,我們這裡只實現比較典型的 v-model 和 “{{ }}” 對應的方法,考慮到後續更新的情況,我們統一把設定值到 Dom 中的邏輯抽取出對應上面兩種情況的方法,存放到 CompileUtilupdater 物件中。

// CompileUtil.js 檔案
CompileUtil = {};

// 更新節點資料的方法
CompileUti.updater = {
    // 文字更新
    textUpdater(node, value) {
        node.textContent = value;
    },
    // 輸入框更新
    modelUpdater(node, value) {
        node.value = value;
    }
};
複製程式碼

這部分的整個思路就是在 Compile 編譯模板後處理 v-model 和 “{{ }}” 時,其實都是用 data 中的資料替換掉 fragment 文件碎片中對應的節點中的變數。因此會經常性的獲取 data 中的值,在更新節點時又會重新設定 data 中的值,所以我們抽離出了三個方法 getValgetTextValsetVal 掛在了 CompileUtil 物件下。

// CompileUtil.js 檔案
// 獲取 data 值的方法
CompileUtil.getVal = function (vm, exp) {
    // 將匹配的值用 . 分割開,如 vm.data.a.b
    exp = exp.split(".");

    // 歸併取值
    return exp.reduce((prev, next) => {
        return prev[next];
    }, vm.$data);
};

// 獲取文字 {{}} 中變數在 data 對應的值
CompileUtil.getTextVal = function (vm, exp) {
    // 使用正則匹配出 {{ }} 間的變數名,再呼叫 getVal 獲取值
    return exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
        return this.getVal(vm, args[1]);
    });
};

// 設定 data 值的方法
CompileUtil.setVal = function (vm, exp, newVal) {
    exp = exp.split(".");
    return exp.reduce((prev, next, currentIndex) => {
        // 如果當前歸併的為陣列的最後一項,則將新值設定到該屬性
        if(currentIndex === exp.length - 1) {
            return prev[next] = newVal;
        }

        // 繼續歸併
        return prev[next];
    }, vm.$data);
}
複製程式碼

獲取和設定 data 的值兩個方法 getValsetVal 思路相似,由於獲取的變數層級不定,可能是 data.a,也可能是 data.obj.a.b,所以都是使用歸併的思路,借用 reduce 方法實現的,區別在於 setVal 方法在歸併過程中需要判斷是不是歸併到最後一級,如果是則設定新值,而 getTextVal 就是在 getVal 外包了一層處理 “{{ }}” 的邏輯。

在這些準備工作就緒以後就可以實現我們的主邏輯,即對 Compile 類中解析的文字節點和元素節點指令中的變數用 data 值進行替換,還記得前面說針對 v-model 和 “{{ }}” 進行處理,因此設計了 modeltext 兩個核心方法。

CompileUtil.model 方法的實現:

// CompileUtil.js 檔案
// 處理 v-model 指令的方法
CompileUtil.model = function (node, vm, exp) {
    // 獲取賦值的方法
    let updateFn = this.updater["modelUpdater"];

    // 獲取 data 中對應的變數的值
    let value = this.getVal(vm, exp);

    // 新增觀察者,作用與 text 方法相同
    new Watcher(vm, exp, newValue => {
        updateFn && updateFn(node, newValue);
    });

    // v-model 雙向資料繫結,對 input 新增事件監聽
    node.addEventListener('input', e => {
        // 獲取輸入的新值
        let newValue = e.target.value;

        // 更新到節點
        this.setVal(vm, exp, newValue);
    });

    // 第一次設定值
    updateFn && updateFn(vm, value);
};
複製程式碼

CompileUtil.text 方法的實現:

// CompileUtil.js 檔案
// 處理文字節點 {{}} 的方法
CompileUtil.text = function (node, vm, exp) {
    // 獲取賦值的方法
    let updateFn = this.updater["textUpdater"];

    // 獲取 data 中對應的變數的值
    let value = this.getTextVal(vm, exp);

    // 通過正則替換,將取到資料中的值替換掉 {{ }}
    exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
        // 解析時遇到了模板中需要替換為資料值的變數時,應該新增一個觀察者
        // 當變數重新賦值時,呼叫更新值節點到 Dom 的方法
        new Watcher(vm, args[1], newValue => {
            // 如果資料發生變化,重新獲取新值
            updateFn && updateFn(node, newValue);
        });
    });

    // 第一次設定值
    updateFn && updateFn(vm, value);
};
複製程式碼

上面兩個方法邏輯相似,都獲取了各自的 updater 中的方法,對值進行設定,並且在設定的同時為了後續 data 中的資料修改,檢視的更新,建立了 Watcher 的例項,並在內部用新值重新更新節點,不同的是 Vue 的 v-model 指令在表單中實現了雙向資料繫結,只要表單元素的 value 值發生變化,就需要將新值更新到 data 中,並響應到頁面上。

所以我們的實現方式是給這個繫結了 v-model 的表單元素監聽了 input 事件,並在事件中實時的將新的 value 值更新到 data 中,至於 data 中的改變後響應到頁面中需要另外三個類 WatcherObserverDep 共同實現,我們下面就來實現 Watcher 類。


觀察者 Watcher 類的實現

CompileUtil 物件的方法中建立 Watcher 例項的時候傳入了三個引數,即 MVVM 的例項、模板繫結資料的變數名 exp 和一個 callback,這個 callback 內部邏輯是為了更新資料到 Dom,所以我們的 Watcher 類內部要做的事情就清晰了,獲取更改前的值儲存起來,並建立一個 update 例項方法,在值被更改時去執行例項的 callback 以達到檢視的更新。

// Watcher.js 檔案
class Watcher {
    constructor(vm, exp, callback) {
        this.vm = vm;
        this.exp = exp;
        this.callback = callback;

        // 更改前的值
        this.value = this.get();
    }
    get() {
        // 將當前的 watcher 新增到 Dep 類的靜態屬性上
        Dep.target = this;

        // 獲取值觸發資料劫持
        let value = CompileUtil.getVal(this.vm, this.exp);

        // 清空 Dep 上的 Watcher,防止重複新增
        Dep.target = null;
        return value;
    }
    update() {
        // 獲取新值
        let newValue = CompileUtil.getVal(this.vm, this.exp);
        // 獲取舊值
        let oldValue = this.value;

        // 如果新值和舊值不相等,就執行 callback 對 dom 進行更新
        if(newValue !== oldValue) {
            this.callback();
        }
    }
}
複製程式碼

看到上面程式碼一定有兩個疑問:

  • 使用 get 方法獲取舊值得時候為什麼要將當前的例項掛在 Dep 上,在獲取值後為什麼又清空了;
  • update 方法內部執行了 callback 函式,但是 update 在什麼時候執行。

這就是後面兩個類 Depobserver 要做的事情,我們首先來介紹 Dep,再介紹 Observer 最後把他們之間的關係整個串聯起來。


釋出訂閱 Dep 類的實現

其實發布訂閱說白了就是把要執行的函式統一儲存在一個陣列中管理,當達到某個執行條件時,迴圈這個陣列並執行每一個成員。

// Dep.js 檔案
class Dep {
    constructor() {
        this.subs = [];
    }
    // 新增訂閱
    addSub(watcher) {
        this.subs.push(watcher);
    }
    // 通知
    notify() {
        this.subs.forEach(watcher => watcher.update());
    }
}
複製程式碼

Dep 類中只有一個屬性,就是一個名為 subs 的陣列,用來管理每一個 watcher,即 Watcher 類的例項,而 addSub 就是用來將 watcher 新增到 subs 陣列中的,我們看到 notify 方法就解決了上面的一個疑問,Watcher 類的 update 方法是怎麼執行的,就是這樣迴圈執行的。

接下來我們整合一下盲點:

  • Dep 例項在哪裡建立宣告,又是在哪裡將 watcher 新增進 subs 陣列的;
  • Depnotify 方法應該在哪裡呼叫;
  • Watcher 內容中,使用 get 方法獲取舊值得時候為什麼要將當前的例項掛在 Dep 上,在獲取值後為什麼又清空了。

這些問題在最後一個類 Observer 實現的時候都將清晰,下面我們重點來看最後一部分核心邏輯。


資料劫持 Observer 類的實現

還記得實現 MVVM 類的時候就建立了這個類的例項,當時傳入的引數是 MVVM 例項的 data 屬性,在 MVVM 中把資料通過 Object.defineProperty 掛到了例項上,並新增了 gettersetter,其實 Observer 類主要目的就是給 data 內的所有層級的資料都進行這樣的操作。

// Observer.js 檔案
class Observer {
    constructor (data) {
        this.observe(data);
    }
    // 新增資料監聽
    observe(data) {
        // 驗證 data
        if(!data || typeof data !== 'object') {
            return;
        }

        // 要對這個 data 資料將原有的屬性改成 set 和 get 的形式
        // 要將資料一一劫持,先獲取到 data 的 key 和 value
        Object.keys(data).forEach(key => {
            // 劫持(實現資料響應式)
            this.defineReactive(data, key, data[key]);
            this.observe(data[key]); // 深度劫持
        });
    }
    // 資料響應式
    defineReactive (object, key, value) {
        let _this = this;
        // 每個變化的資料都會對應一個陣列,這個陣列是存放所有更新的操作
        let dep = new Dep();

        // 獲取某個值被監聽到
        Object.defineProperty(object, key, {
            enumerable: true,
            configurable: true,
            get () { // 當取值時呼叫的方法
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            set (newValue) { // 當給 data 屬性中設定的值適合,更改獲取的屬性的值
                if(newValue !== value) {
                    _this.observe(newValue); // 重新賦值如果是物件進行深度劫持
                    value = newValue;
                    dep.notify(); // 通知所有人資料更新了
                }
            }
        });
    }
}
複製程式碼

在的程式碼中 observe 的目的是遍歷物件,在內部對資料進行劫持,即新增 gettersetter,我們把劫持的邏輯單獨抽取成 defineReactive 方法,需要注意的是 observe 方法在執行最初就對當前的資料進行了資料型別驗證,然後再迴圈物件每一個屬性進行劫持,目的是給同為 Object 型別的子屬性遞迴呼叫 observe 進行深度劫持。

defineReactive 方法中,建立了 Dep 的例項,並對 data 的資料使用 getset 進行劫持,還記得在模板編譯的過程中,遇到模板中繫結的變數,就會解析,並建立 watcher,會在 Watcher 類的內部獲取舊值,即當前的值,這樣就觸發了 get,在 get 中就可以將這個 watcher 新增到 Depsubs 陣列中進行統一管理,因為在程式碼中獲取 data 中的值操作比較多,會經常觸發 get,我們又要保證 watcher 不會被重複新增,所以在 Watcher 類中,獲取舊值並儲存後,立即將 Dep.target 賦值為 null,並且在觸發 get 時對 Dep.target 進行了短路操作,存在才呼叫 DepaddSub 進行新增。

data 中的值被更改時,會觸發 set,在 set 中做了效能優化,即判斷重新賦的值與舊值是否相等,如果相等就不重新渲染頁面,不等的情況有兩種,如果原來這個被改變的值是基本資料型別沒什麼影響,如果是引用型別,我們需要對這個引用型別內部的資料進行劫持,因此遞迴呼叫了 observe,最後呼叫 Depnotify 方法進行通知,執行 notify 就會執行 subs 中所有被管理的 watcherupdate,就會執行建立 watcher 時的傳入的 callback,就會更新頁面。

MVVM 類將 data 的屬性掛在 MVVM 例項上並劫持與通過 Observer 類對 data 的劫持還有一層聯絡,因為整個釋出訂閱的邏輯都是在 datagetset 上,只要觸發了 MVVM 中的 getset 內部會自動返回或設定 data 對應的值,就會觸發 datagetset,就會執行釋出訂閱的邏輯。

通過上面長篇大論的敘述後,這個 MVVM 模式用到的幾個類的關係應該完全敘述清晰了,雖然比較抽象,但是細心琢磨還是會明白之間的關係和邏輯,下面我們就來對我們自己實現的這個 MVVM 進行驗證。


驗證 MVVM

我們按照 Vue 的方式根據自己的 MVVM 實現的內容簡單的寫了一個模板如下:

<!-- index.html 檔案 -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>MVVM</title>
</head>
<body>
    <div id="app">
        <!-- 雙向資料繫結 靠的是表單 -->
        <input type="text" v-model="message">
        <div>{{message}}</div>
        <ul>
            <li>{{message}}</li>
        </ul>
        {{message}}
    </div>

    <!-- 引入依賴的 js 檔案 -->
    <script src="./js/Watcher.js"></script>
    <script src="./js/Observer.js"></script>
    <script src="./js/Compile.js"></script>
    <script src="./js/CompileUtil.js"></script>
    <script src="./js/Dep.js"></script>
    <script src="./js/MVVM.js"></script>
    <script>
        let vm = new MVVM({
            el: '#app',
            data: {
                message: 'hello world!'
            }
        });
    </script>
</body>
</html>
複製程式碼

開啟 Chrom 瀏覽器的控制檯,在上面通過下面操作來驗證:

  • 輸入 vm.message = "hello" 看頁面是否更新;
  • 輸入 vm.$data.message = "hello" 看頁面是否更新;
  • 改變文字輸入框內的值,看頁面的其他元素是否更新。

總結

通過上面的測試,相信應該理解了 MVVM 模式對於前端開發重大的意義,實現了雙向資料繫結,實時保證 View 層與 Model 層的資料同步,並可以讓我們在開發時基於資料程式設計,而最少的操作 Dom,這樣大大提高了頁面渲染的效能,也可以使我們把更多的精力用於業務邏輯的開發上。


相關文章