【學習筆記】剖析MVVM框架,簡單實現Vue數據雙向綁定


前言:

學習前端也有半年多了,個人的學習欲望還比較強烈,很喜歡那種新知識在自己的演練下一點點實現的過程。最近一直在學vue框架,像網上大佬說的,入門容易深究難。不管是跟着開發文檔學還是視頻教程,按步驟操作總是最膚淺,想要把這門功課做好畢竟得下足功夫。因此,特意花了好幾天時間閱讀相關技術博客和源碼,簡單實現了一個數據雙向綁定的vue框架,希望能讓各位有點啟發...

1.什么是MVVM

MVVM即modle-view-viewmole,MVVM最早由微軟提出來,在前端頁面中,把Model用純JavaScript對象表示,View負責顯示,兩者做到了最大限度的分離。把Model和View關聯起來的就是ViewModel。ViewModel負責把Model的數據同步到View顯示出來,還負責把View的修改同步回Model。

2.數據的雙向綁定

在vue框架中,通過控制台或者Vue Devtools修改data里的一些屬性時會看到頁面也會更新,而在頁面修改數據時,data里的屬性值也會發生改變。我們就把這種model和view同步顯示稱為是雙向綁定。其實單向綁定原理也差不多,視圖改變data更新通過事件監聽就能輕松實現了,重點都在希望data改變視圖也發生改變,而這就是我們下面要講的原理。

 

3.vue數據雙向綁定原理

 3.1 Object.defineProperty()方法

首先要知道的是vue的數據綁定通過數據劫持配合發布訂閱者模式實現的,那么什么是數據劫持呢?我們可以在控制台看一下它的初始化對象是什么樣的:

 let vm = new Vue({
        el:"#app",
        data:{
            obj:{
                a:1
            }
        },
        created() {
            console.log(this.obj)
        },
    })

 

 

 

 

可以看到屬性a分別對應着一個get 和set方法,這里引申出Object.defineProperty()方法,傳遞三個參數,obj(要在其上定義屬性的對象)、prop(要定義或修改的屬性的名稱)、descriptor(將被定義或修改的屬性描述符)。該方法更多信息參考:參考更多用法,着重強調一下get和set這兩個屬性描述鍵值。

  • get 存取描述符的可選鍵值,一個給屬性提供 getter 的方法,如果沒有 getter 則為 undefined。當訪問該屬性時,該方法會被執行,方法執行時沒有參數傳入,但是會傳入this對象(由於繼承關系,這里的this並不一定是定義該屬性的對象)。
  • set 存取描述符的可選鍵值,一個給屬性提供 setter 的方法,如果沒有 setter 則為 undefined。當屬性值修改時,觸發執行該方法。該方法將接受唯一參數,即該屬性新的參數值。

平常我們在打印一個對象屬性時會這樣做: 

var obj = {
   name:"tnagj"
}
console.log(obj.name)  //tangj

 如果我們想要在輸出的同時監聽obj的屬性值,並且輸出的是tangjSir呢?這時候我們的set和get屬性就起到了很好的作用

 var obj ={};
 var name = '';
 Object.defineProperty(obj,'name',{
     set:function(value){
         name = value
         console.log('我叫:' + name)
     },
     get:function(){
        console.log(name + 'Sir')
     }
 })
 obj.name = 'tangj';  //我叫tangj
 obj.name;         //tangjSir

首先我們定義了一個obj空對象以及name空屬性,再用一個Object.defineProperty()方法來判斷obj.name的訪問情況,如果是讀值則調用get函數,如果是賦值則調用set函數。在這兩個函數里面我們分別對輸出的內容作了更改,因此在get方法調用時打印tangjSir,在set方法調用時打印我叫tangj。

其實這就是vue數據綁定的監聽原理,我們能通過這個簡單實現MVVM雙向綁定。

3.2 MVVM雙向綁定分析 

view的變化,比如input值改變我們很容易就能知道通過input事件反應到data中,數據綁定的關鍵在於怎樣讓data更新view。首先我們要知道數據什么時候變的,上文提過可以用Object.defineProperty()的set屬性描述鍵值來監聽這個變化,當數據改變時就調用set方法。

那么我們可以設置一個監聽器Observe,用來監聽所有的屬性,當屬性變化的時候就需要告訴訂閱者Watcher看是否需要更新。因為訂閱者是有很多個,所以我們需要有一個消息訂閱器Dep來專門收集這些訂閱者,然后在監聽器Observer和訂閱者Watcher之間進行統一管理的。當然我們還需要有一個指令解析器Compile,對每個節點元素進行掃描和解析,將相關指令對應初始化成一個訂閱者Watcher,並替換模板數據或者綁定相應的函數,此時當訂閱者Watcher接收到相應屬性的變化,就會執行對應的更新函數,從而更新視圖。所以,我們大致可以把整個過程拆分成五個部分,MVVM.html,MVVM.js,compile.js,observer.js,watcher.js,我們在MVVM.js中創建所需要的實例,在.html文件中引入這些js文件,這樣拆分更容易理解也更好維護。

4.分步拆分

4.1 MVVM.JS

為了和Vue保持一致,我們向MVVM.js傳入一個空對象options,並讓vm.$el = options.el,vm.$data = options.data,如果能取到vm.$el再進行編譯和監聽

class MVVM {
       constructor(options){    
       this.$el = options.el, //把東西掛載在實例上
       this.$data = options.data      
        if(this.$el){    // 如果有要編譯的就開始編譯            
            new Observer(this.$data); //數據劫持,就是把對象的所有屬性改成get和set方法
            new Compile(this.$el,this);//用數據和元素進行編譯
        }      
   }
}

4.2  Compile.js

編譯的時候有一個問題需要注意,如果直接操作DOM元素會特別消耗性能,所以我們希望先把DOM元素都放在內存中即文檔碎片,待編譯完成再把文檔碎片放進真實的元素中

class Complie{
   constructor(el,vm){
       this.el = this.isElementNode(el)?el:document.querySelector(el);
       this.vm = vm;
       if(this.el){//如果這個元素能獲取到,我們才開始編譯                   
           let fragment = this.nodeToFragment(this.el); //1.先把真實的DOM移入到內存中,fragment            
           this.compile(fragment); //2.編譯=>提取想要的元素節點v-modle 和文本節點{{}}          
           this.el.appendChild(fragment) //3.把編譯好的fragment塞回頁面
       }
       nodeToFragment(el){   //需要el元素放到內存中
       let fragment = document.createDocumentFragment();
       let Child;
       while(Child = el.firstChild){
           fragment.appendChild(Child);
       }
       return fragment;
    }
  }
}

接下來我們要判斷需要編譯的是元素節點還是文檔節點,還記得Vue中有很多很有用的指令嗎?比如"v-modle"、"v-for"等,所以我們還要判斷元素節點內是否包含指令,如果是指令,它應該包含一些特殊的方法

/* 省略.... */
    isElementNode(node){  //是不是元素節點
        return node.nodeType === 1;
    }   
    isDirective(name){   //是不是指令
        return name.includes('v-')
    }
 compileElement(node){
          //帶v-modle 
          let attrs = node.attributes;
          Array.from(attrs).forEach(
              attr =>{
                 let attrName = attr.name;
                 if(this.isDirective(attrName)){
                   // 取到對應的值放到節點中
                   let expr = attr.value;                       
                   // node vm.$data expr
                   let [,type] = attrName.split('-')  //解構賦值
                   CompileUtil[type](node,this.vm,expr)
                 }
              }
          )
    }
    compileText(node){
         // 帶{{}}
         let expr = node.textContent; //取文本的內容
         let reg = /\{\{([^}]+)\}\}/g  //全局匹配
         if(reg.test(expr)){
             // node this.vm.$data expr
             CompileUtil['text'](node,this.vm,expr)
         }
    }
    compile(fragment){  //需要遞歸,拿到的childNodes只是第一層
        let childNodes = fragment.childNodes;       
        Array.from(childNodes).forEach(
            node=>{
                if(this.isElementNode(node)){  //是元素節點,還需要遞歸檢查
                    this.compileElement(node)  //編譯元素
                    this.compile(node)  //箭頭函數this指向上一層的實例
                }else{             //文本節點
                    this.compileText(node)  //編譯文本
                }
            }
        )
    }

根據獲取的節點類型不同,執行不同的方法,我們可把這些方法統一都放到一個對象里面去

CompileUtil = {
    getVal(vm,expr){  //獲取實例上的數據   
        expr = expr.split('.'); //如果遇到vm.$data[a.a],希望先拿到vm.$data[a]             
        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'];     
       let value = this.getTextVal(vm,expr);          
       updateFn && updateFn(node,value);      
    },  
    modle(node,vm,expr){   // 輸入框處理
        let updateFn = this.updater['modleUpdater']        
        updateFn && updateFn(node,this.getVal(vm,expr))
    },
    updater:{
        textUpdater(ndoe,value){
             ndoe.textContent = value //文本更新
        },        
        modleUpdater(node,value){
             node.value = value  
        }
    }
}

4.3 Oberver.js

編譯的時候我們還需要一個監聽者,當數據變化調用get和set方法

class Observer{
    constructor(data){
        this.observer(data)
    }
    observer(data){       
        if(!data || typeof data !== 'object') return;
         Object.keys(data).forEach(key =>{
             this.defineReactive(data,key,data[key]);
             this.observer(data[key])
         })         
    }
    defineReactive(obj,key,value){
        let that = this;      
        Object.defineProperty(obj,key,{
            enumerable:true,         
            configurable:true,
            get(){                           
               Dep.target && dep.addSub(Dep.target);            
                return value;
            },
            set(newvalue){
                if(value === newvalue) return;              
                that.observer(newvalue);  //如果新值是對象,繼續劫持
                value = newvalue;
            },
        })
    }
}

4.4 Watcher訂閱者和Dep監聽器

前面已經實現了監聽和編譯,但是怎么樣才能讓它們之間進行通信呢,也就是當監聽到變化了怎么通知呢?這里就用到了發布訂閱模式。默認觀察者watcher有一個update方法,它會更新數據。Dep里面創建一個數組,把觀察者都放在這個數組里面,當監聽到變化,一個個調用監聽者update方法。

// Watcher.js
//觀察者的目的就是給需要變化的那個元素增加一個觀察者,當數據變化后執行對應的方法
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('.'); //如果遇到vm.$data[a.a],希望先拿到vm.$data[a]        
        // console.log(expr)
        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);
        }
    }
}
//Dep.js
class Dep{
    constructor(){
        //訂閱的數組
        this.subs = []
    }
    addSub(watcher){
        this.subs.push(watcher)
    }
    notify(){
        this.subs.forEach(watcher =>{
            watcher.update()
        })
    }
}

watcher邏輯: 當創建watcher實例的時候,先拿到這個值,數據變化又拿到一個新值,如果新值和老值不一樣,那么調用callback,實現更新;

dep邏輯:創建數組把觀察者放在這個數組里,當監聽到變化,執行watcher.update()

我們再它們分別添加到Observer和compile中

// complie.js
// 省略....
text(node,vm,expr){       // 文本處理        
       let updateFn = this.updater['textUpdater'];       
       //{{message.a}} => tangj
       let value = this.getTextVal(vm,expr);
       expr.replace(/\{\{([^}]+)\}\}/g,(...arguments)=>{
           new Watcher(vm,arguments[1],(newVaule)=>{
               // 如果數據變化,文本節點需要重新獲取依賴的屬性更新文本的的內容
               updateFn && updateFn(node,this.getTextVal(vm,expr));
           })
    })              
       updateFn && updateFn(node,value);      
    },  
    modle(node,vm,expr){   // 輸入框處理
        let updateFn = this.updater['modleUpdater']
         // 'message.a' => [message.a] vm.$data['message'].a
         // 這里應該加一個監控,數據變化,調用這個watch的cb
         new Watcher(vm,expr,(newVaule)=>{
             //當值變化后將調用cb,將新的值傳遞過來
            updateFn && updateFn(node,this.getVal(vm,expr))
         });
         node.addEventListener('input',(e)=>{
             let newVaule = e.target.value;
             this.setVal(vm,expr,newVaule)
         })
        updateFn && updateFn(node,this.getVal(vm,expr))
    }
// 省略...
// observer.js
class Observer{ constructor(data){
this.observer(data) } observer(data){ //要對這個data數據原有屬性改成set和get的形式 if(!data || typeof data !== 'object') return; 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); return value; }, set(newvalue){ if(value === newvalue) return; 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() }) } }

到這里我們就實現了數據的雙向綁定,MVVM作為數據綁定的入口,整合Observer、Compile和Watcher三者,通過Observer來監聽自己的model數據變化,通過Compile來解析編譯模板指令,最終利用Watcher搭起Observer和Compile之間的通信橋梁,達到數據變化 -> 視圖更新;視圖交互變化(input) -> 數據model變更的雙向綁定效果。

當然我們還需要數據代理,用vm代理vm.$data,也是通過Object.defineProperty()實現

 
         
 this.proxyData(this.$data);
proxyData(data){
       Object.keys(data).forEach(key =>{
           let val = data[key]
           Object.defineProperty(this,key,{
               enumerable:true,
               configurable:true,
               get(){                   
                   return val
               },
               set(newval){
                   if(val == newval){
                    return;
                   }
                   val = newval
               }
           })
       })
   }

5.最終效果

 

本次學習源碼已上傳github:https://github.com/Tangjj1996/MVVM,喜歡的朋友可以stars

參考博客:基於vue實現一個簡單的MVVM框架(源碼分析)

PS:MVVM是學習框架非常重要的一步,掌握了這些原理才能更好地運用,知其然更要知其所以然,水平有限有錯誤的地方煩請多多指教

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM