模擬 Vue 手寫(xiě)一個(gè) MVVM


原文出自:https://www.pandashen.com


MVVM 的前世今生

MVVM 設(shè)計(jì)模式,是由 MVC(最早來(lái)源于后端)、MVP 等設(shè)計(jì)模式進(jìn)化而來(lái),M - 數(shù)據(jù)模型(Model),VM - 視圖模型(ViewModel),V - 視圖層(View)。

在 MVC 模式中,除了 Model 和 View 層以外,其他所有的邏輯都在 Controller 中,Controller 負(fù)責(zé)顯示頁(yè)面、響應(yīng)用戶操作、網(wǎng)絡(luò)請(qǐng)求及與 Model 的交互,隨著業(yè)務(wù)的增加和產(chǎn)品的迭代,Controller 中的處理邏輯越來(lái)越多、越來(lái)越復(fù)雜,難以維護(hù)。為了更好的管理代碼,為了更方便的擴(kuò)展業(yè)務(wù),必須要為 Controller “瘦身”,需要更清晰的將用戶界面(UI)開(kāi)發(fā)從應(yīng)用程序的業(yè)務(wù)邏輯與行為中分離,MVVM 為此而生。

很多 MVVM 的實(shí)現(xiàn)都是通過(guò)數(shù)據(jù)綁定來(lái)將 View 的邏輯從其他層分離,可以用下圖來(lái)簡(jiǎn)略的表示:


image


使用 MVVM 設(shè)計(jì)模式的前端框架很多,其中漸進(jìn)式框架 Vue 是典型的代表,并在開(kāi)發(fā)使用中深得廣大前端開(kāi)發(fā)者的青睞,我們這篇就根據(jù) Vue 對(duì)于 MVVM 的實(shí)現(xiàn)方式來(lái)簡(jiǎn)單模擬一版 MVVM 庫(kù)。


MVVM 的流程分析

在 Vue 的 MVVM 設(shè)計(jì)中,我們主要針對(duì) Compile(模板編譯)、Observer(數(shù)據(jù)劫持)、Watcher(數(shù)據(jù)監(jiān)聽(tīng))和 Dep(發(fā)布訂閱)幾個(gè)部分來(lái)實(shí)現(xiàn),核心邏輯流程可參照下圖:



類(lèi)似這種 “造輪子” 的代碼毋庸置疑一定是通過(guò)面向?qū)ο缶幊虂?lái)實(shí)現(xiàn)的,并嚴(yán)格遵循開(kāi)放封閉原則,由于 ES5 的面向?qū)ο缶幊瘫容^繁瑣,所以,在接下來(lái)的代碼中統(tǒng)一使用 ES6 的 class 來(lái)實(shí)現(xiàn)。


MVVM 類(lèi)的實(shí)現(xiàn)

在 Vue 中,對(duì)外只暴露了一個(gè)名為 Vue 的構(gòu)造函數(shù),在使用的時(shí)候 new 一個(gè) Vue 實(shí)例,然后傳入了一個(gè) options 參數(shù),類(lèi)型為一個(gè)對(duì)象,包括當(dāng)前 Vue 實(shí)例的作用域 el、模板綁定的數(shù)據(jù) data 等等。

我們模擬這種 MVVM 模式的時(shí)候也構(gòu)建一個(gè)類(lèi),名字就叫 MVVM,在使用時(shí)同 Vue 框架類(lèi)似,需要通過(guò) new 指令創(chuàng)建 MVVM 的實(shí)例并傳入 options。

// 文件:MVVM.js
class MVVM {
    constructor(options) {
        // 先把 el 和 data 掛在 MVVM 實(shí)例上
        this.$el = options.el;
        this.$data = options.data;

        // 如果有要編譯的模板就開(kāi)始編譯
        if (this.$el) {
            // 數(shù)據(jù)劫持,就是把對(duì)象所有的屬性添加 get 和 set
            new Observer(this.$data);

            // 將數(shù)據(jù)代理到實(shí)例上
            this.proxyData(this.$data);

            // 用數(shù)據(jù)和元素進(jìn)行編譯
            new Compile(this.el, this);
        }
    }
    proxyData(data) { // 代理數(shù)據(jù)的方法
        Object.keys(data).forEach(key => {
            Object.defineProperty(this, key, {
                get() {
                    return data[key];
                }
                set(newVal) {
                    data[key] = newVal;
                }
            });
        });
    }
}

通過(guò)上面代碼,我們可以看出,在我們 new 一個(gè) MVVM 的時(shí)候,在參數(shù) options 中傳入了一個(gè) Dom 的根元素節(jié)點(diǎn)和數(shù)據(jù) data 并掛在了當(dāng)前的 MVVM 實(shí)例上。

當(dāng)存在根節(jié)點(diǎn)的時(shí)候,通過(guò) Observer 類(lèi)對(duì) data 數(shù)據(jù)進(jìn)行了劫持,并通過(guò) MVVM 實(shí)例的方法 proxyDatadata 中的數(shù)據(jù)掛在當(dāng)前 MVVM 實(shí)例上,同樣對(duì)數(shù)據(jù)進(jìn)行了劫持,是因?yàn)槲覀冊(cè)讷@取和修改數(shù)據(jù)的時(shí)候可以直接通過(guò) thisthis.$data,在 Vue 中實(shí)現(xiàn)數(shù)據(jù)劫持的核心方法是 Object.defineProperty,我們也使用這個(gè)方式通過(guò)添加 gettersetter 來(lái)實(shí)現(xiàn)數(shù)據(jù)劫持。

最后使用 Compile 類(lèi)對(duì)模板和綁定的數(shù)據(jù)進(jìn)行了解析和編譯,并渲染在根節(jié)點(diǎn)上,之所以數(shù)據(jù)劫持和模板解析都使用類(lèi)的方式實(shí)現(xiàn),是因?yàn)榇a方便維護(hù)和擴(kuò)展,其實(shí)不難看出,MVVM 類(lèi)其實(shí)作為了 Compile 類(lèi)和 Observer 類(lèi)的一個(gè)橋梁。


模板編譯 Compile 類(lèi)的實(shí)現(xiàn)

Compile 類(lèi)在創(chuàng)建實(shí)例的時(shí)候需要傳入兩個(gè)參數(shù),第一個(gè)參數(shù)是當(dāng)前 MVVM 實(shí)例作用的根節(jié)點(diǎn),第二個(gè)參數(shù)就是 MVVM 實(shí)例,之所以傳入 MVVM 的實(shí)例是為了更方便的獲取 MVVM 實(shí)例上的屬性。

Compile 類(lèi)中,我們會(huì)盡量的把一些公共的邏輯抽取出來(lái)進(jìn)行最大限度的復(fù)用,避免冗余代碼,提高維護(hù)性和擴(kuò)展性,我們把 Compile 類(lèi)抽取出的實(shí)例方法主要分為兩大類(lèi),輔助方法和核心方法,在代碼中用注釋標(biāo)明。

1、解析根節(jié)點(diǎn)內(nèi)的 Dom 結(jié)構(gòu)

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

        // 如過(guò)傳入的根元素存在,才開(kāi)始編譯
        if (this.el) {
            // 1、把這些真實(shí)的 Dom 移動(dòng)到內(nèi)存中,即 fragment(文檔碎片)
            let fragment = this.node2fragment(this.el);
        }
    }

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

    /* 核心方法 */
    // 將根節(jié)點(diǎn)轉(zhuǎn)移至文檔碎片
    node2fragment(el) {
        // 創(chuàng)建文檔碎片
        let fragment = document.createDocumentFragment();
        // 第一個(gè)子節(jié)點(diǎn)
        let firstChild;

        // 循環(huán)取出根節(jié)點(diǎn)中的節(jié)點(diǎn)并放入文檔碎片中
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment;
    }
}

上面編譯模板的過(guò)程中,前提條件是必須存在根元素節(jié)點(diǎn),傳入的根元素節(jié)點(diǎn)允許是一個(gè)真實(shí)的 Dom 元素,也可以是一個(gè)選擇器,所以我們創(chuàng)建了輔助方法 isElementNode 來(lái)幫我們判斷傳入的元素是否是 Dom,如果是就直接使用,是選擇器就獲取這個(gè) Dom,最終將這個(gè)根節(jié)點(diǎn)存入 this.el 屬性中。

解析模板的過(guò)程中為了性能,我們應(yīng)取出根節(jié)點(diǎn)內(nèi)的子節(jié)點(diǎn)存放在文檔碎片中(內(nèi)存),需要注意的是將一個(gè) Dom 節(jié)點(diǎn)內(nèi)的子節(jié)點(diǎn)存入文檔碎片的過(guò)程中,會(huì)在原來(lái)的 Dom 容器中刪除這個(gè)節(jié)點(diǎn),所以在遍歷根節(jié)點(diǎn)的子節(jié)點(diǎn)時(shí),永遠(yuǎn)是將第一個(gè)節(jié)點(diǎn)取出存入文檔碎片,直到節(jié)點(diǎn)不存在為止。

2、編譯文檔碎片中的結(jié)構(gòu)

在 Vue 中的模板編譯的主要就是兩部分,也是瀏覽器無(wú)法解析的部分,元素節(jié)點(diǎn)中的指令和文本節(jié)點(diǎn)中的 Mustache 語(yǔ)法(雙大括號(hào))。

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

        // 如過(guò)傳入的根元素存在,才開(kāi)始編譯
        if (this.el) {
            // 1、把這些真實(shí)的 Dom 移動(dòng)到內(nèi)存中,即 fragment(文檔碎片)
            let fragment = this.node2fragment(this.el);

            // ********** 以下為新增代碼 **********
            // 2、將模板中的指令中的變量和 {{}} 中的變量替換成真實(shí)的數(shù)據(jù)
            this.compile(fragment);

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

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

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

    /* 核心方法 */
    // 將根節(jié)點(diǎn)轉(zhuǎn)移至文檔碎片
    node2fragment(el) {
        // 創(chuàng)建文檔碎片
        let fragment = document.createDocumentFragment();
        // 第一個(gè)子節(jié)點(diǎn)
        let firstChild;

        // 循環(huán)取出根節(jié)點(diǎn)中的節(jié)點(diǎn)并放入文檔碎片中
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment;
    }

    // ********** 以下為新增代碼 **********
    // 解析文檔碎片
    compile(fragment) {
        // 當(dāng)前父節(jié)點(diǎn)節(jié)點(diǎn)的子節(jié)點(diǎn),包含文本節(jié)點(diǎn),類(lèi)數(shù)組對(duì)象
        let childNodes = fragment.childNodes;

        // 轉(zhuǎn)換成數(shù)組并循環(huán)判斷每一個(gè)節(jié)點(diǎn)的類(lèi)型
        Array.from(childNodes).forEach(node => {
            if (this.isElementNode(node)) { // 是元素節(jié)點(diǎn)
                // 遞歸編譯子節(jié)點(diǎn)
                this.compile(node);

                // 編譯元素節(jié)點(diǎn)的方法
                this.compileElement(node);
            } else { // 是文本節(jié)點(diǎn)
                // 編譯文本節(jié)點(diǎn)的方法
                this.compileText(node);
            }
        });
    }
    // 編譯元素
    compileElement(node) {
        // 取出當(dāng)前節(jié)點(diǎn)的屬性,類(lèi)數(shù)組
        let attrs = node.attributes;
        Array.form(attrs).forEach(attr => {
            // 獲取屬性名,判斷屬性是否為指令,即含 v-
            let attrName = attr.name;

            if (this.isDirective(attrName)) {
                // 如果是指令,取到該屬性值得變量在 data 中對(duì)應(yīng)得值,替換到節(jié)點(diǎn)中
                let exp = attr.value;

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

                // 調(diào)用指令對(duì)應(yīng)得方法
                CompileUtil[type](node, this.vm, exp);
            }
        });

    }
    // 編譯文本
    compileText(node) {
        // 獲取文本節(jié)點(diǎn)的內(nèi)容
        let exp = node.contentText;

        // 創(chuàng)建匹配 {{}} 的正則表達(dá)式
        let reg = /\{\{([^}+])\}\}/g;

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

上面代碼新增內(nèi)容得主要邏輯就是做了兩件事:

  • 調(diào)用 compile 方法對(duì) fragment 文檔碎片進(jìn)行編譯,即替換內(nèi)部指令和 Mustache 語(yǔ)法中變量對(duì)應(yīng)的值;
  • 將編譯好的 fragment 文檔碎片塞回根節(jié)點(diǎn)。

在第一個(gè)步驟當(dāng)中邏輯是比較繁瑣的,首先在 compile 方法中獲取所有的子節(jié)點(diǎn),循環(huán)進(jìn)行編譯,如果是元素節(jié)點(diǎn)需要遞歸 compile,傳入當(dāng)前元素節(jié)點(diǎn)。在這個(gè)過(guò)程當(dāng)中抽取出了兩個(gè)方法,compileElementcompileText 用來(lái)對(duì)元素節(jié)點(diǎn)的屬性和文本節(jié)點(diǎn)進(jìn)行處理。

compileElement 中的核心邏輯就是處理指令,取出元素節(jié)點(diǎn)所有的屬性判斷是否是指令,是指令則調(diào)用指令對(duì)應(yīng)的方法。compileText 中的核心邏輯就是取出文本的內(nèi)容通過(guò)正則表達(dá)式匹配出被 Mustache 語(yǔ)法的 “{{ }}” 包裹的內(nèi)容,并調(diào)用處理文本的 text 方法。

文本節(jié)點(diǎn)的內(nèi)容有可能存在 “{{ }} {{ }} {{ }}”,正則匹配默認(rèn)是貪婪的,為了防止第一個(gè) “{” 和最后一個(gè) “}” 進(jìn)行匹配,所以在正則表達(dá)式中應(yīng)使用非貪婪匹配。

在調(diào)用指令的方法時(shí)都是調(diào)用的 CompileUtil 下對(duì)應(yīng)的方法,我們之所以單獨(dú)把這些指令對(duì)應(yīng)的方法抽離出來(lái)存儲(chǔ)在 CompileUtil 對(duì)象下的目的是為了解耦,因?yàn)楹竺嫫渌念?lèi)還要使用。

3、CompileUtil 對(duì)象中指令方法的實(shí)現(xiàn)

CompileUtil 中存儲(chǔ)著所有的指令方法及指令對(duì)應(yīng)的更新方法,由于 Vue 的指令很多,我們這里只實(shí)現(xiàn)比較典型的 v-model 和 “{{ }}” 對(duì)應(yīng)的方法,考慮到后續(xù)更新的情況,我們統(tǒng)一把設(shè)置值到 Dom 中的邏輯抽取出對(duì)應(yīng)上面兩種情況的方法,存放到 CompileUtilupdater 對(duì)象中。

// 文件:CompileUtil.js
CompileUtil = {};

// 更新節(jié)點(diǎn)數(shù)據(jù)的方法
CompileUti.updater = {
    // 文本更新
    textUpdater(node, value) {
        node.textContent = value;
    },
    // 輸入框更新
    modelUpdater(node, value) {
        node.value = value;
    }
};

這部分的整個(gè)思路就是在 Compile 編譯模板后處理 v-model 和 “{{ }}” 時(shí),其實(shí)都是用 data 中的數(shù)據(jù)替換掉 fragment 文檔碎片中對(duì)應(yīng)的節(jié)點(diǎn)中的變量。因此會(huì)經(jīng)常性的獲取 data 中的值,在更新節(jié)點(diǎn)時(shí)又會(huì)重新設(shè)置 data 中的值,所以我們抽離出了三個(gè)方法 getVal、getTextValsetVal 掛在了 CompileUtil 對(duì)象下。

// 文件:CompileUtil.js —— 取值方法
// 獲取 data 值的方法
CompileUtil.getVal = function (vm, exp) {
    // 將匹配的值用 . 分割開(kāi),如 vm.data.a.b
    exp = exp.split(".");

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

// 獲取文本 {{}} 中變量在 data 對(duì)應(yīng)的值
CompileUtil.getTextVal = function (vm, exp) {
    // 使用正則匹配出 {{ }} 間的變量名,再調(diào)用 getVal 獲取值
    return exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
        return this.getVal(vm, args[1]);
    });
};

// 設(shè)置 data 值的方法
CompileUtil.setVal = function (vm, exp, newVal) {
    exp = exp.split(".");
    return exp.reduce((prev, next, currentIndex) => {
        // 如果當(dāng)前歸并的為數(shù)組的最后一項(xiàng),則將新值設(shè)置到該屬性
        if(currentIndex === exp.length - 1) {
            return prev[next] = newVal
        }

        // 繼續(xù)歸并
        return prev[next];
    }, vm.$data);
}

獲取和設(shè)置 data 的值兩個(gè)方法 getValsetVal 思路相似,由于獲取的變量層級(jí)不定,可能是 data.a,也可能是 data.obj.a.b,所以都是使用歸并的思路,借用 reduce 方法實(shí)現(xiàn)的,區(qū)別在于 setVal 方法在歸并過(guò)程中需要判斷是不是歸并到最后一級(jí),如果是則設(shè)置新值,而 getTextVal 就是在 getVal 外包了一層處理 “{{ }}” 的邏輯。

在這些準(zhǔn)備工作就緒以后就可以實(shí)現(xiàn)我們的主邏輯,即對(duì) Compile 類(lèi)中解析的文本節(jié)點(diǎn)和元素節(jié)點(diǎn)指令中的變量用 data 值進(jìn)行替換,還記得前面說(shuō)針對(duì) v-model 和 “{{ }}” 進(jìn)行處理,因此設(shè)計(jì)了 modeltext 兩個(gè)核心方法。

CompileUtil.model 方法的實(shí)現(xiàn):

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

    // 獲取 data 中對(duì)應(yīng)的變量的值
    let value = this.getVal(vm, exp);

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

    // v-model 雙向數(shù)據(jù)綁定,對(duì) input 添加事件監(jiān)聽(tīng)
    node.addEventListener('input', e => {
        // 獲取輸入的新值
        let newValue = e.target.value;

        // 更新到節(jié)點(diǎn)
        this.setVal(vm, exp, newValue);
    });

    // 第一次設(shè)置值
    updateFn && updateFn(vm, value);
};

CompileUtil.text 方法的實(shí)現(xiàn):

// 文件:CompileUtil.js —— text 方法
// 處理文本節(jié)點(diǎn) {{}} 的方法
CompileUtil.text = function (node, vm, exp) {
    // 獲取賦值的方法
    let updateFn = this.updater["textUpdater"];

    // 獲取 data 中對(duì)應(yīng)的變量的值
    let value = this.getTextVal(vm, exp);

    // 通過(guò)正則替換,將取到數(shù)據(jù)中的值替換掉 {{ }}
    exp.replace(/\{\{([^}]+)\}\}/g, (...args) => {
        // 解析時(shí)遇到了模板中需要替換為數(shù)據(jù)值的變量時(shí),應(yīng)該添加一個(gè)觀察者
        // 當(dāng)變量重新賦值時(shí),調(diào)用更新值節(jié)點(diǎn)到 Dom 的方法
        new Watcher(vm, arg[1], newValue => {
            // 如果數(shù)據(jù)發(fā)生變化,重新獲取新值
            updateFn && updateFn(node, newValue);
        });
    });

    // 第一次設(shè)置值
    updateFn && updateFn(vm, value);
};

上面兩個(gè)方法邏輯相似,都獲取了各自的 updater 中的方法,對(duì)值進(jìn)行設(shè)置,并且在設(shè)置的同時(shí)為了后續(xù) data 中的數(shù)據(jù)修改,視圖的更新,創(chuàng)建了 Watcher 的實(shí)例,并在內(nèi)部用新值重新更新節(jié)點(diǎn),不同的是 Vue 的 v-model 指令在表單中實(shí)現(xiàn)了雙向數(shù)據(jù)綁定,只要表單元素的 value 值發(fā)生變化,就需要將新值更新到 data 中,并響應(yīng)到頁(yè)面上。

所以我們的實(shí)現(xiàn)方式是給這個(gè)綁定了 v-model 的表單元素監(jiān)聽(tīng)了 input 事件,并在事件中實(shí)時(shí)的將新的 value 值更新到 data 中,至于 data 中的改變后響應(yīng)到頁(yè)面中需要另外三個(gè)類(lèi) Watcher、ObserverDep 共同實(shí)現(xiàn),我們下面就來(lái)實(shí)現(xiàn) Watcher 類(lèi)。


觀察者 Watcher 類(lèi)的實(shí)現(xiàn)

CompileUtil 對(duì)象的方法中創(chuàng)建 Watcher 實(shí)例的時(shí)候傳入了三個(gè)參數(shù),即 MVVM 的實(shí)例、模板綁定數(shù)據(jù)的變量名 exp 和一個(gè) callback,這個(gè) callback 內(nèi)部邏輯是為了更新數(shù)據(jù)到 Dom,所以我們的 Watcher 類(lèi)內(nèi)部要做的事情就清晰了,獲取更改前的值存儲(chǔ)起來(lái),并創(chuàng)建一個(gè) update 實(shí)例方法,在值被更改時(shí)去執(zhí)行實(shí)例的 callback 以達(dá)到視圖的更新。

// 文件:Watcher.js
class Watcher {
    constructor(vm, exp, callback) {
        this.vm = vm;
        this.exp = exp;
        this.callback = callback;

        // 更改前的值
        this.value = this.get();
    }
    get() {
        // 將當(dāng)前的 watcher 添加到 Dep 類(lèi)的靜態(tài)屬性上
        Dep.target = this;

        // 獲取值觸發(fā)數(shù)據(jù)劫持
        let value = CompileUtil.getVal(this.vm, this.exp);

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

        // 如果新值和舊值不相等,就執(zhí)行 callback 對(duì) dom 進(jìn)行更新
        if(newValue !== oldValue) {
            this.callback();
        }
    }
}

看到上面代碼一定有兩個(gè)疑問(wèn):

  • 使用 get 方法獲取舊值得時(shí)候?yàn)槭裁匆獙?dāng)前的實(shí)例掛在 Dep 上,在獲取值后為什么又清空了;
  • update 方法內(nèi)部執(zhí)行了 callback 函數(shù),但是 update 在什么時(shí)候執(zhí)行。

這就是后面兩個(gè)類(lèi) Depobserver 要做的事情,我們首先來(lái)介紹 Dep,再介紹 Observer 最后把他們之間的關(guān)系整個(gè)串聯(lián)起來(lái)。


發(fā)布訂閱 Dep 類(lèi)的實(shí)現(xiàn)

其實(shí)發(fā)布訂閱說(shuō)白了就是把要執(zhí)行的函數(shù)統(tǒng)一存儲(chǔ)在一個(gè)數(shù)組中管理,當(dāng)達(dá)到某個(gè)執(zhí)行條件時(shí),循環(huán)這個(gè)數(shù)組并執(zhí)行每一個(gè)成員。

// 文件:Dep.js
class Dep {
    constructor() {
        this.subs = [];
    }
    // 添加訂閱
    addSub(watcher) {
        this.subs.push(watcher);
    }
    // 通知
    notify() {
        this.subs.forEach(watcher => watcher.update());
    }
}

Dep 類(lèi)中只有一個(gè)屬性,就是一個(gè)名為 subs 的數(shù)組,用來(lái)管理每一個(gè) watcher,即 Watcher 類(lèi)的實(shí)例,而 addSub 就是用來(lái)將 watcher 添加到 subs 數(shù)組中的,我們看到 notify 方法就解決了上面的一個(gè)疑問(wèn),Watcher 類(lèi)的 update 方法是怎么執(zhí)行的,就是這樣循環(huán)執(zhí)行的。

接下來(lái)我們整合一下盲點(diǎn):

  • Dep 實(shí)例在哪里創(chuàng)建聲明,又是在哪里將 watcher 添加進(jìn) subs 數(shù)組的;
  • Depnotify 方法應(yīng)該在哪里調(diào)用;
  • Watcher 內(nèi)容中,使用 get 方法獲取舊值得時(shí)候?yàn)槭裁匆獙?dāng)前的實(shí)例掛在 Dep 上,在獲取值后為什么又清空了。

這些問(wèn)題在最后一個(gè)類(lèi) Observer 實(shí)現(xiàn)的時(shí)候都將清晰,下面我們重點(diǎn)來(lái)看最后一部分核心邏輯。


數(shù)據(jù)劫持 Observer 類(lèi)的實(shí)現(xiàn)

還記得實(shí)現(xiàn) MVVM 類(lèi)的時(shí)候就創(chuàng)建了這個(gè)類(lèi)的實(shí)例,當(dāng)時(shí)傳入的參數(shù)是 MVVM 實(shí)例的 data 屬性,在 MVVM 中把數(shù)據(jù)通過(guò) Object.defineProperty 掛到了實(shí)例上,并添加了 gettersetter,其實(shí) Observer 類(lèi)主要目的就是給 data 內(nèi)的所有層級(jí)的數(shù)據(jù)都進(jìn)行這樣的操作。

// 文件:Observer.js
class Observer {
    constructor (data) {
        this.observe(data);
    }
    // 添加數(shù)據(jù)監(jiān)聽(tīng)
    observe(data) {
        // 驗(yàn)證 data
        if(!data || typeof data !== 'object') {
            return;
        }

        // 要對(duì)這個(gè) data 數(shù)據(jù)將原有的屬性改成 set 和 get 的形式
        // 要將數(shù)據(jù)一一劫持,先獲取到 data 的 key 和 value
        Object.keys(data).forEach(key => {
            // 劫持(實(shí)現(xiàn)數(shù)據(jù)響應(yīng)式)
            this.defineReactive(data, key, data[key]);
            this.observe(data[key]); // 深度劫持
        });
    }
    // 數(shù)據(jù)響應(yīng)式
    defineReactive (object, key, value) {
        let _this = this;
        // 每個(gè)變化的數(shù)據(jù)都會(huì)對(duì)應(yīng)一個(gè)數(shù)組,這個(gè)數(shù)組是存放所有更新的操作
        let dep = new Dep();

        // 獲取某個(gè)值被監(jiān)聽(tīng)到
        Object.defineProperty(object, key, {
            enumerable: true,
            configurable: true,
            get () { // 當(dāng)取值時(shí)調(diào)用的方法
                Dep.target && dep.addSub(Dep.target);
                return value;
            },
            set (newValue) { // 當(dāng)給 data 屬性中設(shè)置的值適合,更改獲取的屬性的值
                if(newValue !== value) {
                    _this.observe(newValue); // 重新賦值如果是對(duì)象進(jìn)行深度劫持
                    value = newValue;
                    dep.notify(); // 通知所有人數(shù)據(jù)更新了
                }
            }
        });
    }
}

在的代碼中 observe 的目的是遍歷對(duì)象,在內(nèi)部對(duì)數(shù)據(jù)進(jìn)行劫持,即添加 gettersetter,我們把劫持的邏輯單獨(dú)抽取成 defineReactive 方法,需要注意的是 observe 方法在執(zhí)行最初就對(duì)當(dāng)前的數(shù)據(jù)進(jìn)行了數(shù)據(jù)類(lèi)型驗(yàn)證,然后再循環(huán)對(duì)象每一個(gè)屬性進(jìn)行劫持,目的是給同為 Object 類(lèi)型的子屬性遞歸調(diào)用 observe 進(jìn)行深度劫持。

defineReactive 方法中,創(chuàng)建了 Dep 的實(shí)例,并對(duì) data 的數(shù)據(jù)使用 getset 進(jìn)行劫持,還記得在模板編譯的過(guò)程中,遇到模板中綁定的變量,就會(huì)解析,并創(chuàng)建 watcher,會(huì)在 Watcher 類(lèi)的內(nèi)部獲取舊值,即當(dāng)前的值,這樣就觸發(fā)了 get,在 get 中就可以將這個(gè) watcher 添加到 Depsubs 數(shù)組中進(jìn)行統(tǒng)一管理,因?yàn)樵诖a中獲取 data 中的值操作比較多,會(huì)經(jīng)常觸發(fā) get,我們又要保證 watcher 不會(huì)被重復(fù)添加,所以在 Watcher 類(lèi)中,獲取舊值并保存后,立即將 Dep.target 賦值為 null,并且在觸發(fā) get 時(shí)對(duì) Dep.target 進(jìn)行了短路操作,存在才調(diào)用 DepaddSub 進(jìn)行添加。

data 中的值被更改時(shí),會(huì)觸發(fā) set,在 set 中做了性能優(yōu)化,即判斷重新賦的值與舊值是否相等,如果相等就不重新渲染頁(yè)面,不等的情況有兩種,如果原來(lái)這個(gè)被改變的值是基本數(shù)據(jù)類(lèi)型沒(méi)什么影響,如果是引用類(lèi)型,我們需要對(duì)這個(gè)引用類(lèi)型內(nèi)部的數(shù)據(jù)進(jìn)行劫持,因此遞歸調(diào)用了 observe,最后調(diào)用 Depnotify 方法進(jìn)行通知,執(zhí)行 notify 就會(huì)執(zhí)行 subs 中所有被管理的 watcherupdate,就會(huì)執(zhí)行創(chuàng)建 watcher 時(shí)的傳入的 callback,就會(huì)更新頁(yè)面。

MVVM 類(lèi)將 data 的屬性掛在 MVVM 實(shí)例上并劫持與通過(guò) Observer 類(lèi)對(duì) data 的劫持還有一層聯(lián)系,因?yàn)檎麄€(gè)發(fā)布訂閱的邏輯都是在 datagetset 上,只要觸發(fā)了 MVVM 中的 getset 內(nèi)部會(huì)自動(dòng)返回或設(shè)置 data 對(duì)應(yīng)的值,就會(huì)觸發(fā) datagetset,就會(huì)執(zhí)行發(fā)布訂閱的邏輯。

通過(guò)上面長(zhǎng)篇大論的敘述后,這個(gè) MVVM 模式用到的幾個(gè)類(lèi)的關(guān)系應(yīng)該完全敘述清晰了,雖然比較抽象,但是細(xì)心琢磨還是會(huì)明白之間的關(guān)系和邏輯,下面我們就來(lái)對(duì)我們自己實(shí)現(xiàn)的這個(gè) MVVM 進(jìn)行驗(yàn)證。


驗(yàn)證 MVVM

我們按照 Vue 的方式根據(jù)自己的 MVVM 實(shí)現(xiàn)的內(nèi)容簡(jiǎn)單的寫(xiě)了一個(gè)模板如下:

<!-- 文件:index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>MVVM</title>
</head>
<body>
    <div id="app">
        <!-- 雙向數(shù)據(jù)綁定 靠的是表單 -->
        <input type="text" v-model="message">
        <div>{{message}}</div>
        <ul>
            <li>{{message}}</li>
        </ul>
        {{message}}
    </div>

    <!-- 引入依賴(lài)的 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>

打開(kāi) Chrom 瀏覽器的控制臺(tái),在上面通過(guò)下面操作來(lái)驗(yàn)證:

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


總結(jié)

通過(guò)上面的測(cè)試,相信應(yīng)該理解了 MVVM 模式對(duì)于前端開(kāi)發(fā)重大的意義,實(shí)現(xiàn)了雙向數(shù)據(jù)綁定,實(shí)時(shí)保證 View 層與 Model 層的數(shù)據(jù)同步,并可以讓我們?cè)陂_(kāi)發(fā)時(shí)基于數(shù)據(jù)編程,而最少的操作 Dom,這樣大大提高了頁(yè)面渲染的性能,也可以使我們把更多的精力用于業(yè)務(wù)邏輯的開(kāi)發(fā)上。


?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 開(kāi)發(fā)模式是大頭。但真正掌握了,對(duì)自己的整個(gè)開(kāi)發(fā)思想是大大的提高。 曾幾何時(shí),為自己的面向過(guò)程而沉迷著。興嘆好多代碼...
    LIT樂(lè)言閱讀 5,058評(píng)論 0 8
  • vue理解淺談 一 理解vue的核心理念 使用vue會(huì)讓人感到身心愉悅,它同時(shí)具備angular和react的優(yōu)點(diǎn)...
    ndxs2008閱讀 24,402評(píng)論 2 18
  • 本文是lhyt本人原創(chuàng),希望用通俗易懂的方法來(lái)理解一些細(xì)節(jié)和難點(diǎn)。轉(zhuǎn)載時(shí)請(qǐng)注明出處。文章最早出現(xiàn)于本人github...
    lhyt閱讀 2,296評(píng)論 0 4
  • 這方面的文章很多,但是我感覺(jué)很多寫(xiě)的比較抽象,本文會(huì)通過(guò)舉例更詳細(xì)的解釋。(此文面向的Vue新手們,如果你是個(gè)大牛...
    Ivy_2016閱讀 15,576評(píng)論 8 63
  • 假如別人灑你一身水 你可能會(huì)大怒 甚至?xí)?zé)怪對(duì)方 可是天空忽然下雨 把你淋濕了 即便你脾氣再不好 也不會(huì)憤怒 若是...
    成都的娃兒閱讀 140評(píng)論 0 0

友情鏈接更多精彩內(nèi)容