知否?知否?Vue之MVVM

前端開發大本營發表於2019-03-04

什麼是MVVM模式?

MVVM 由 Model,View,ViewModel 三部分構成,Model 層代表資料模型,也可以在Model中定義資料修改和操作的業務邏輯;View 代表UI 元件,它負責將資料模型轉化成UI 展現出來,ViewModel 是一個同步View 和 Model的物件。

在MVVM架構下,View 和 Model 之間並沒有直接的聯絡,而是通過ViewModel進行互動,Model 和 ViewModel 之間的互動是雙向的, 因此View 資料的變化會同步到Model中,而Model 資料的變化也會立即反應到View 上。

ViewModel 通過雙向資料繫結把 View 層和 Model 層連線了起來,而View 和 Model 之間的同步工作完全是自動的,無需人為干涉,因此開發者只需關注業務邏輯,不需要手動操作DOM, 不需要關注資料狀態的同步問題,複雜的資料狀態維護完全由 MVVM 來統一管理。

知否?知否?Vue之MVVM

MVVM的流程實現

在 Vue 的 MVVM 設計中,假設我們自己模擬寫一個類似new Vue()的建構函式,名字叫MVVM,其流程分析參考下圖:

知否?知否?Vue之MVVM

Observer 資料監聽器,能夠對資料物件的所有屬性進行監聽,如有變動可拿到最新值並通知訂閱者,內部採用Object.defineProperty的getter和setter來實現。
Compile 指令解析器,它的作用對每個元素節點的指令進行掃描和解析,根據指令模板替換資料,以及繫結相應的更新函式。
Watcher 訂閱者, 作為連線 Observer 和 Compile 的橋樑,能夠訂閱並收到每個屬性變動的通知,執行指令繫結的相應回撥函式。
Dep 訊息訂閱器,內部維護了一個陣列,用來收集訂閱者(Watcher),資料變動觸發notify 函式,再呼叫訂閱者的 update 方法。

根據流程圖我們分別構建mvvm.js、compile.js、observer.js、watcher.js

mvvm.js:類似於new Vue()中的建構函式,傳入options物件引數,在引數 options 中傳入了一個 Dom 的根元素節點和資料 data 並掛在了當前的 MVVM 例項上。

當存在根節點的時候,通過 Observer 類的getter和setter對 data 資料進行了劫持,並通過 MVVM 例項的方法 proxyDatadata 中的資料掛在當前 MVVM 例項上,同樣對資料進行了劫持,這樣我們在獲取和修改資料的時候可以直接操作vm.message,其中vm是new MVVM()出來的例項,message是掛載到例項上的資料,在 Vue 中資料劫持的核心方法是 Object.defineProperty,我們也使用這個方式通過新增 gettersetter 來實現資料劫持。

程式碼如下:

class MVVM{
    constructor(options){
        // 一上來 先把可用的東西掛載在例項上
        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){//將this.$data上的資料代理到vm例項上
        Object.keys(data).forEach(key=>{
            Object.defineProperty(this,key,{
                get(){
                    return data[key]
                },
                set(newValue){
                    data[key] = newValue
                }
            })
        })
    }
}

複製程式碼複製程式碼


compile.js:對每個元素節點的指令進行掃描和解析,根據指令模板替換資料,以及繫結相應的更新函式。當資料變化了,會給元素新增new Watcher()監控資料的變化,並呼叫Watcher中的callback。

程式碼如下:

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.編譯 => 提取想要的元素節點 v-model 和文字節點 {{}}
            this.compile(fragment);
            // 3.把編譯號的fragment在塞回到頁面裡去
            this.el.appendChild(fragment);
        }
    }
    /* 專門寫一些輔助的方法 */
    isElementNode(node) {
        return node.nodeType === 1;
    }
    // 是不是指令
    isDirective(name) {
        return name.includes('v-');
    }
    /* 核心的方法 */
    compileElement(node) {
        // 帶v-model v-text 
        let attrs = node.attributes; // 取出當前節點的屬性
        Array.from(attrs).forEach(attr => {
            // 判斷屬性名字是不是包含v-model 
            let attrName = attr.name;
            if (this.isDirective(attrName)) {
                // 取到對應的值放到節點中
                let expr = attr.value;
                let [, type] = attrName.split('-');
                // node this.vm.$data expr 
                CompileUtil[type](node, this.vm, expr);
            }
        })
    }
    compileText(node) {
        // 帶{{asd}}
        let expr = node.textContent; // 取文字中的內容
        let reg = /\{\{([^}]+)\}\}/g; // {{a}} {{b}} {{c}}
        if (reg.test(expr)) {
            // node this.vm.$data text
            CompileUtil['text'](node, this.vm, expr);
        }
    }
    compile(fragment) {
        // 需要遞迴
        let childNodes = fragment.childNodes;
        Array.from(childNodes).forEach(node => {
            if (this.isElementNode(node)) {
                // 是元素節點,還需要繼續深入的檢查
                // 這裡需要編譯元素
                this.compileElement(node);
                this.compile(node)
            } else {
                // 文字節點
                // 這裡需要編譯文字
                this.compileText(node);
            }
        });
    }
    node2fragment(el) { // 需要講el中的內容全部放到記憶體中
        // 文件碎片 記憶體中的dom節點
        let fragment = document.createDocumentFragment();
        let firstChild;
        while (firstChild = el.firstChild) {
            fragment.appendChild(firstChild);
        }
        return fragment; // 記憶體中的節點
    }
}

CompileUtil = {
    getVal(vm, expr) { // 獲取例項上對應的資料
        expr = expr.split('.'); // [message,a]
        return expr.reduce((prev, next) => { // vm.$data.a
            return prev[next];
        }, vm.$data);
    },
    getTextVal(vm, expr) { // 獲取編譯文字後的結果
        return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
            return this.getVal(vm, arguments[1]);
        })
    },
    text(node, vm, expr) { // 文字處理
        let updateFn = this.updater['textUpdater'];
        // {{message.a}} => hello,zfpx;
        let value = this.getTextVal(vm, expr);
        // {{a}} {{b}}
        expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
            new Watcher(vm, arguments[1],(newValue)=>{
                // 如果資料變化了,文字節點需要重新獲取依賴的屬性更新文字中的內容
                updateFn && updateFn(node,this.getTextVal(vm,expr));
            });
        })
        updateFn && updateFn(node, value)
    },
    setVal(vm,expr,value){ // [message,a]
        expr = expr.split('.');
        // 收斂
        return expr.reduce((prev,next,currentIndex)=>{
            if(currentIndex === expr.length-1){
                return prev[next] = value;
            }
            return prev[next];
        },vm.$data);
    },
    model(node, vm, expr) { // 輸入框處理
        let updateFn = this.updater['modelUpdater'];
        // 這裡應該加一個監控 資料變化了 應該呼叫這個watch的callback 
        new Watcher(vm,expr,(newValue)=>{
            // 當值變化後會呼叫cb 將新的值傳遞過來 ()
            updateFn && updateFn(node, this.getVal(vm, expr));
        });
        node.addEventListener('input',(e)=>{
            let newValue = e.target.value;
            this.setVal(vm,expr,newValue)
        })
        updateFn && updateFn(node, this.getVal(vm, expr));
    },
    updater: {
        // 文字更新
        textUpdater(node, value) {
            node.textContent = value
        },
        // 輸入框更新
        modelUpdater(node, value) {
            node.value = value;
        }
    }
}複製程式碼複製程式碼

observer.js:資料監聽器,能夠對資料物件的所有屬性進行監聽,如有變動可拿到最新值並通知訂閱者,內部採用Object.defineProperty的getter和setter來實現。

程式碼如下:

class Observer{
    constructor(data){
       this.observe(data); 
    }
    observe(data){ 
        // 要對這個data資料將原有的屬性改成set和get的形式
        if(!data || typeof data !== 'object'){
            return;
        }
        // 要將資料 一一劫持 先獲取取到data的key和value
        Object.keys(data).forEach(key=>{
            // 劫持
            this.defineReactive(data,key,data[key]);
            this.observe(data[key]);// 深度遞迴劫持
        });
    }
    // 定義響應式
    defineReactive(obj,key,value){
        // 在獲取某個值的適合 想彈個框
        let that = this;
        let dep = new Dep(); // 每個變化的資料 都會對應一個陣列,這個陣列是存放所有更新的操作
        Object.defineProperty(obj,key,{
            enumerable:true,
            configurable:true,
            get(){ // 當取值時呼叫的方法
                Dep.target && dep.addSub(Dep.target);//新增watcher
                return value;
            },
            set(newValue){ // 當給data屬性中設定值的適合 更改獲取的屬性的值
                if(newValue!=value){
                    // 這裡的this不是例項 
                    that.observe(newValue);// 如果是物件繼續劫持
                    value = newValue;
                    dep.notify(); // 通知所有人 資料更新了  執行watcher中的update方法
                }
            }
        });
    }
}
class Dep{
    constructor(){
        // 訂閱的陣列
        this.subs = []
    }
    addSub(watcher){
        this.subs.push(watcher);
    }
    notify(){
        this.subs.forEach(watcher=>watcher.update());
    }
}複製程式碼複製程式碼

watcher.js: 作為連線 Observer 和 Compile 的橋樑,能夠訂閱並收到每個屬性變動的通知,執行指令繫結的相應回撥函式。

程式碼如下:

// 觀察者的目的就是給需要變化的那個元素增加一個觀察者,當資料變化後執行對應的方法
class Watcher{
    constructor(vm,expr,cb){
        this.vm = vm;
        this.expr = expr;
        this.cb = cb;
        // 先獲取一下老的值
        this.value = this.get();
    }
    getVal(vm, expr) { // 獲取例項上對應的資料
        expr = expr.split('.'); // [message,a]
        return expr.reduce((prev, next) => { // vm.$data.a
            return prev[next];
        }, vm.$data);
    }
    get(){
        Dep.target = this;
        let value = this.getVal(this.vm,this.expr);
        Dep.target = null;
        return value;
    }
    // 對外暴露的方法
    update(){
        let newValue = this.getVal(this.vm, this.expr);
        let oldValue = this.value;
        if(newValue != oldValue){
            this.cb(newValue); // 對應watch的callback
        }
    }
}
// 用新值和老值進行比對 如果放生變化 就呼叫更新方法
複製程式碼複製程式碼

驗證MVVM

按照Vue的引入方式來驗證我們自己模擬實現的MVVM模式吧!

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>MVVM</title>
</head>
<body>
    <div id="app">
        <!-- 雙向資料繫結 輸入框v-model繫結的資料 -->

        <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/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,這樣大大提高了頁面渲染的效能,提高開發的效率和維護成本,可以讓我們把更多的精力用於業務邏輯的開發上。


相關文章