模擬 Vue 手寫一個 MVVM


閱讀原文


MVVM 的前世此生

MVVM 設計模式,是由 MVC(最先來源於後端)、MVP 等設計模式進化而來,M - 數據模型(Model),VM - 視圖模型(ViewModel),V - 視圖層(View)。javascript

在 MVC 模式中,除了 Model 和 View 層之外,其餘全部的邏輯都在 Controller 中,Controller 負責顯示頁面、響應用戶操做、網絡請求及與 Model 的交互,隨着業務的增長和產品的迭代,Controller 中的處理邏輯愈來愈多、愈來愈複雜,難以維護。爲了更好的管理代碼,爲了更方便的擴展業務,必需要爲 Controller 「瘦身」,須要更清晰的將用戶界面(UI)開發從應用程序的業務邏輯與行爲中分離,MVVM 爲此而生。html

不少 MVVM 的實現都是經過數據綁定來將 View 的邏輯從其餘層分離,能夠用下圖來簡略的表示:前端



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


MVVM 的流程分析

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



相似這種 「造輪子」 的代碼毋庸置疑必定是經過面向對象編程來實現的,並嚴格遵循開放封閉原則,因爲 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 類抽取出的實例方法主要分爲兩大類,輔助方法和核心方法,在代碼中用註釋標明。

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

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

        // 如過傳入的根元素存在,纔開始編譯
        if (this.el) {
            // 一、把這些真實的 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 容器中刪除這個節點,因此在遍歷根節點的子節點時,永遠是將第一個節點取出存入文檔碎片,直到節點不存在爲止。

二、編譯文檔碎片中的結構

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

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

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

            // ********** 如下爲新增代碼 **********
            // 二、將模板中的指令中的變量和 {{}} 中的變量替換成真實的數據
            this.compile(fragment);

            // 三、把編譯好的 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 對象下的目的是爲了解耦,由於後面其餘的類還要使用。

三、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,這樣大大提升了頁面渲染的性能,也可使咱們把更多的精力用於業務邏輯的開發上。

相關文章
相關標籤/搜索