實現一個自己的mvvm

任志鵬鵬發表於2019-09-04

相信大家對mvvm並不陌生吧,下面看下實現的程式碼,參考vue原始碼,整理出來的小demo。 想看整體程式碼 猛戳 github,如果要是覺得有對您有幫助 麻煩給個star。

<div id="app">
    <input type="text" v-model="message.a" />
    {{message.a}}
</div>
<script src="./mvvm/watcher.js"></script>
<script src="./mvvm/observer.js"></script>
<script src="./mvvm/compile.js"></script>
<script src="./mvvm/mvvm.js"></script>
<script>
    //將標籤放到記憶體中去,然後 編譯 => 提前想要的元素元素節點 v-model 和文字節點 {{}}
    let vm = new MVVM({
        el : "#app",
        data:{
            message:{
                a:'1212'
            }
        }
    })
</script>
複製程式碼

幾種實現雙向繫結的做法

1.釋出訂閱模式

一般通過sub,pub的方法實現資料和檢視的繫結監聽,更新資料方法通常做法是 vm.set('property', value)。

2.髒檢查

angular.js 就是通過髒值檢測的方法對資料是否有變更,來決定更新檢視,最簡單的方式就是setInterval()定時輪詢檢測資料變動,當然Google不會這麼low,angular只有在指定的事件觸發時進入髒值檢測,大致如下:

  • DOM事件,比如使用者輸入文字,點選按鈕等。(ng-click)
  • XHR響應事件($http)
  • 瀏覽器Location變更事件($location)
  • Timer事件($timeout, $interval)
  • 執行$digest()$apply()
3.資料劫持

vue.js 採用的就是資料劫持結合釋出訂閱模式,通過Object.defineProperty() 來劫持各個屬性的 setter, getter,在資料變動時釋出訊息給訂閱者,觸發相應的監聽回撥。

思路整理

  1. mvvm 會初始化兩個方法 Observer - 劫持所有屬性,Compile - 解析指令
  2. Compile 生成檢視的同時 會訂閱資料變化 new Watcher。生成更新檢視的回撥, (這個回撥什麼時候呼叫呢?)new Watcher 會新增訂閱者到 Dep陣列中,方便修改資料的時候通知變化。
  3. Observer 如果劫持到變化會通知 DepDep會執行Dep陣列裡面所有的通知(new Watcher)。

alt

1. Observer

我們知道 可以利用 Obeject.defineProperty() 來監聽 setter, getter。

class Observer{
    constructor(data){
        this.observer(data);
    }
    observer(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.observer(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,所以通過Dep定義一個全域性target屬性,暫存watcher, 新增完移除
                return value;
            },
            set(newValue){ //當給data屬性中設定值到時候,更改獲取的屬性到值
                if(newValue != value){
                    //這裡的this不是例項
                    that.observer(newValue);//如果是物件,繼續劫持
                    value = newValue;
                    dep.notify(); //通知所有人資料更新了
                }
            }
        })
    }
}
class Dep{
    constructor(){
        //訂閱的陣列
        this.subs = [];
    }
    addSub(watcher){
        this.subs.push(watcher);
    }
    notify(){
        this.subs.forEach(watcher => watcher.update());
    }
}
複製程式碼

要點總結

  1. 利用遞迴 深度監聽(由於Object.defineProperty 無法深度監聽)
  2. get() 的時候也就是誰需要展示的時候, 要把new watcher push到陣列中去(訂閱),方便修改值去通知所有的訂閱者(釋出)
  3. set()的時候,要通知所有的訂閱者,你們要修改值到檢視啦(釋出)

2. Compile

compile 主要做的事情就是解析模版指令,將模版中的變數替換成資料,然後初始化渲染頁面檢視,並將每個指令對應的節點繫結更新函式,新增監聽資料的訂閱者,一旦資料有變動,收到通知,更新檢視。

alt
因為操作中需要多次操作dom節點,為了提高效率及效能,先將文件轉化為文件片段 fragment進行解析編譯操作,解析完成,再將fragment新增回原來的真實dom節點中

//將真實的DOM移入到記憶體中 fragment
//同樣定義一個類
class Compile{
    constructor(el,vm){
        //el可能是 #app or dom,所以要進行判斷
        this.el = this.isElementNode(el)?el:document.querySelector(el); 
        this.vm = vm;
        if(this.el){
            //如果這個元素能獲取到,我們才開始編譯
            //1.先把這些真實的DOM移入到記憶體中 fragment
            //2、編譯 =》 提前想要的元素元素節點 v-model 和文字節點 {{}}
            //3、把編譯好的 fragment 在塞回到頁面裡去

            //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-');
    }
    /*核心的方法*/

    //1、需要將el中的內容全部放到記憶體中
    node2fragment(el){
        //文件碎片 記憶體中的dom節點
        let fragment = document.createDocumentFragment();
        let firstChild;
        while(firstChild = el.firstChild){
            fragment.appendChild(firstChild);
        }
        return fragment; //記憶體中的節點
    }
    //2、編譯 =》 提前想要的元素元素節點 v-model 和文字節點 {{}}
    compile(fragment){
        //需要遞迴
        let childNodes = fragment.childNodes;
        //
        Array.from(childNodes).forEach(node => {
            if(this.isElementNode(node)){
                //是元素節點,還需要深入的檢查
                //這裡需要編譯元素
                this.compileElement(node);//編譯 帶 v-model 的元素
                this.compile(node);
            }else{
                //文字節點
                //這裡需要編譯文字
                this.compileText(node);
            }
        });
    }
    compileElement(node){
        //帶v-model
        let attrs = node.attributes;//取出當前節點的屬性
        Array.from(attrs).forEach(attr => {
            //判斷屬性名字是不是包含v-
            let attrName = attr.name;
            if(this.isDirective(attrName)){
                //取到對應的值放到節點中
                let expr = attr.value;
                //解構負值,將v-model中的model擷取處理
                let [,type] = attrName.split('-');
                //node this.vm.$data expr v-model v-text v-html
                //todo ...
                CompileUtil[type](node,this.vm,expr);
            }
        })
    }
    compileText(node){
        //帶{{}}
        let expr = node.textContent;//取文字中的內容
        let reg = /\{\{([^}]+)\}\}/g; //{{a}}、{{b}}、{{c}}
        if(reg.test(expr)){
            // node this.vm.$data text
            //todo ...
            CompileUtil['text'](node,this.vm,expr);
        }
    }
}

CompileUtil = {
    //獲取示例上對應的示例
    getVal(vm,expr){ 
        expr = expr.split('.');
        return expr.reduce((prev,next) => {
            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,123獲取編譯文字後的結果
        let value = this.getTextVal(vm,expr);
        expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{ // arguments ["{{message.a}}", "message.a", 9, "↵        {{message.a}}↵    "]
            new Watcher(vm,arguments[1],(newValue)=>{
                //如果資料變化了,文字節點需要重新依賴的屬性更新文字中的內容
                updateFn && updateFn(node,this.getTextVal(vm,expr));
            })
            return arguments[1];
        });
        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;
        }
    }
};
複製程式碼

總結

先放入程式碼片段裡面,在用 compile方法遍歷元素節點,解析文字節點, 而且在遍歷節點的同時,會 new watcher 新增回撥來接受資料變化的通知。

3. Watcher

Watcher訂閱者作為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('.')
        return expr.reduce((prev,next) => {
            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
         }
    }
}
複製程式碼

總結

  1. 在自身例項化的時候, 往Dep 裡面 push 自身
  2. 自身有個 update() 方法 以供呼叫 更新檢視回撥
  3. 在 dep.notice() 通知的時候,能呼叫自身的update()方法,並且觸發Compile中繫結的回撥。

4. mvvm

//因為 MVVM 可以 new,所以 MVVM 肯定是一個類
//用 es6寫法定義
class MVVM{
    //在類裡面接受引數,例如,el,和data
    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){
        Object.keys(data).forEach(key=>{
            Object.defineProperty(this,key,{
                get(){
                    return data[key]
                },
                set(newValue){
                    data[key] = newValue;
                }
            })
        })
    }
}
複製程式碼

主要是還是用Object.defineProperty 方法來劫持資料,這邊使用代理,實現 this.xxx 代替 this.data.xxx 的效果。

總結

本文主要是參考 vue原始碼 ,來寫的一個mvvm 小demo, 相信文中肯定有一些不嚴謹的思考和錯誤, 希望大家指出來,和大家共同進步。

相關文章