基於Vue的MVVM學習筆記

什麼是MVVM

MVVM——Model-View-ViewModle的縮寫,MVC設計模式的改進版。Model是咱們應用中的數據模型,View是咱們的UI層,經過ViewModle,能夠把咱們Modle中的數據映射到View視圖上,同時,在View層修改了一些數據,也會反應更新咱們的Modle。javascript

上面的話,未免太官方了。簡單理解就是雙向數據綁定,即當數據發生變化的時候,視圖也就發生變化,當視圖發生變化的時候,數據也會跟着同步變化。html

圖片描述

MVVM這種思想的前端框架其實老早就有了,我記得是在13年,本身在公司的主要工做是作後臺管理系統的UI設計和開發,當時就思考,如何讓那些專一後臺的開發,既簡單又方便的使用前端開發的一些組件。當時有三種方案:前端

  • 使用Easy-ui,但easy-ui好像官方要求收費,固然也能夠破解使用
  • 本身開發UI框架,其實當時想作的東西就是後來BootStrap
  • 使用谷歌的Angular,進行二次開發

後來的評估是:vue

  1. 使用easy-ui,工做量太多
  2. 使用Angular和easy-ui不只工做量很大,後臺也要作相應的修改
  3. 本身寫UI框架,比較合適,當時的作法是寫一些jQuery相關的插件,先給後臺一個js插件包,後續的UI修改,慢慢進行。

當時本身仍是比較推崇Angular的,我記得後來還買了一本《基於MVC的Javascript Web富應用開發》專門去了解這種模式在工做中可能用的狀況,以及實現它的一些基本思路。java

當時熱點比較高的MVVM框架有:node

  • Angular:谷歌出品,名氣很大,入門高,使用麻煩,它提供了不少新的概念。
  • Backbone.js,入門要求級別很高,我記得當時淘寶有些項目應用了這個,《基於MVC富應用開發》書裏面也是以這個框架爲主介紹MVC的。
  • Ember:大而全的框架,開始寫代碼以前就已經有不少的工做要作了。

當年的環境和條件都沒有如今好,不管從技術完善的狀況,仍是工做的實際狀況上面看,都是如此——那時候先後端分離都是理想。nginx

固然如今環境好了,各類框架的出現也極大方便了咱們,提升了咱們開發的工做效率。時代老是在進步,大浪淘沙,MVVM的框架如今比較熱門和流行的,我相信你們如今都知道,就是下面三種了:正則表達式

  • Angular
  • Vue
  • React

如今Angular除了一些忠實的擁躉,基本上也就沒落了。Angular不管從入門仍是實際應用方面,都要比其餘兩個框架發費的時間成本更大。
Angular如今有種英雄末路的感受,但不能不認可,以前它確實散發了光芒。segmentfault

Angular的1.x版本,是經過髒值檢測來實現雙向綁定的。後端

而最新的Angular版本和Vue,以及React都是經過數據劫持+發佈訂閱模式來實現的。

髒值檢測

簡單理解就是,把老數據和新數據進行比較,就表示以前存在過,有過痕跡,經過比較新舊數據,來判斷是否要更新。感興趣的能夠看看這篇文章 構建本身的AngularJS,第一部分:做用域和digest

數據劫持 發佈訂閱

數據劫持:在訪問或者修改對象的某個屬性時,經過代碼攔截這個行爲,進行額外的操做或者修改返回結果。在ES5當中新增了Object.defineProperty()能夠幫咱們實現這個功能。

發佈訂閱:如今每一個人應該都用微信吧,一我的能夠關注多個公衆號,多我的能夠同時關注相同的公衆號。關注的動做就至關於訂閱。公衆號每週都會更新內容,並推送給咱們,把寫好的文章在微信管理平臺更新就行了,點擊推送,就至關於發佈。更詳細的能夠深刻閱讀 javascript設計模式——發佈訂閱模式

怎麼實現一個MVVM

咱們靜下心好好思考下,若是才能實現雙向數據綁定的功能。可能須要:

  • 一個初始化實例的類
  • 一個存放數據的對象Object
  • 一個能夠把咱們的數據映射到HTML頁面上的「模板解析」工具
  • 一個更新數據的方法
  • 一個經過監聽數據的變化,更新視圖的方法
  • 一個掛載模板解析的HTML標籤

經過上面這樣的思考,咱們能夠簡單的寫一下大概的方法。

class MVVM {
    constructor(data){
        this.$option = option;
        const data = this._data = this.$option.data;
        
        //數據劫持
        observe(data)
        
        //數據代理
        proxyData(data)
        
        //編譯模板
        const dom = this._el = this.$option.el;
        complie(dom,this);
        
        //發佈訂閱
        
        //鏈接視圖和數據
        
        //實現雙向數據綁定   
    }
}

// Observe類
function Observe(){}


// Observe實例化函數
function observe(data){
    return new Observe(data);
}


// Compile類
function Compile(){}

// Compile實例化函數
function compile(el){
    return new Compile(el)
}

數據劫持

咱們有下面這樣一個對象

let obj = {
    name:"mc",
    age:"29",
    friends:{
        name:"hanghang",
        name:"jiejie"
    }
}

咱們要對這個對象執行某些操做(讀取,修改),一般像下面就能夠

// 取值
const name = obj.name;
console.log(obj.age)
const friends = obj.friends;

// 修改
obj.name = "mmcai";
obj.age =  30;

在VUE中,咱們知道,若是data對象中的某個屬性,在template當中綁定的話,當咱們修改了這個屬性值,咱們的視圖也就更新了。這就是雙向數據綁定,數據變化,視圖更新,同時反過來也同樣。

要實現這個功能,咱們就須要知道data當中的數據是如何變更了,ES5當中提供了Object.defineProperty()函數,咱們能夠經過這個函數對咱們data對象當中的數據進行監聽。當數據變更,就會觸發這個函數裏面的set方法,經過判斷數據是否變化,就能夠執行一些方法,更新咱們的視圖了。因此咱們如今須要實現一個數據監聽器Observe,來對咱們data中的全部屬性進行監聽。

// Observe類的實例化函數
function observe(data){
    // 判斷數據是不是一個對象
    if(typeof data !== 'object'){
        return;
    }
    // 返回一個Observe的實例化對象
    return new Observe(data)
}

// Observer類的實現
class Observe{
    constructor(data){
        this.data = data;
        this.init(data)
    }
    
    init(data){
        for(let k in data){
            let val = data[k];
            
            //若是data是一個對象,咱們遞歸調用自身
            if(typeof val === 'object'){
                observe(val);
            }
            
            Object.defineProperty(data,k,{
                enumerable:true,
                get(){
                    return val;
                },
                set(newVal){
                    //若是值相同,直接返回
                    if(newVal === val){
                        return;
                    };
                    //賦值
                    val = newVal;
                    
                    //若是新設置的值是一個對象,遞歸調用observe方法,給新數據也添加上監聽
                    if(typeof newVal === 'object'){
                        observe(newVal);
                    }
                }
            })
        }
    }
    
}

瞭解了數據劫持,咱們就能夠明白,爲何咱們實例化vue的時候,必須事先在data當中定義好咱們的須要的屬性了,由於咱們新增的屬性,沒有通過observe進行監聽,沒有經過observe監聽,後面complie(模板解析)也就不會執行。

因此,雖然你能夠在data上面設置新的屬性,並讀取,但視圖卻不能更新。

數據代理

咱們常見的代理有nginx,就是咱們不直接去訪問(操做)咱們實際要訪問的數據,而是經過訪問一個代理,而後代理幫咱們去拿咱們真正須要的數據。

通常的特色是:

  • 安全,不把真實內容暴露
  • 方便,能夠把一些複雜的操做,經過代理進行簡化
  • ...

下面是VUE簡單的一個使用實例:

cosnt vm = new Vue({
    el:"#app",
    data:{
        name:"mmcai"
    }
});

咱們的實例化對象vm,想要讀取data裏面的數據的時候,不作任何處理的正常狀況下,使用下面方式讀取:

const name = vm.data.name;

這樣操做起來,顯然麻煩了一些,咱們就能夠經過數據代理,直接把data綁定到咱們的實例上,因此在vue當中,咱們通常獲取數據像下面同樣:

cosnt vm = new Vue({
    el:"#app",
    data:{
        name:"mmcai"
    },
    created(){
        
        // 直接經過實例就能夠訪問到data當中的數據
        const name = this.name;
        
        // 經過this.data.name 也能夠訪問,可是顯然,麻煩了一些
    }
});

一樣,咱們經過Object.defineProperty函數,把data對象中的數據,綁定到咱們的實例上就能夠了,代碼以下:

class MVVM {
    constructor(option){
        //此處代碼省略
        this.$option = option;
        const data = this._data = this.$option.data;
        
        //調用代理
        this._proxyData(data);
    }
    
    _proxyData(data){
        const that = this;
        for(let k in data){
            let val = data[k];
            Object.defineProperty(that,k,{
                enumerable:true,
                get(){
                    return that._data[k];
                },
                set(newVal){
                    that._data[k] = newVal;
                }
            })
        }
    }
}

編譯模板

利用正則表達式識別模板標識符,並利用數據替換其中的標識符。
VUE裏面的標識符是 {{}} 雙大括號,數據就是咱們定義在data上面的內容。

實現原理

  1. 肯定咱們的模板範圍
  2. 遍歷DOM節點,循環找到咱們的標識符
  3. 將標識符的內容用數據進行填充填充

遍歷解析須要替換的根元素el下的HTML標籤,必定會使用遍歷對DOM節點進行操做,對DOM操做就會引起頁面的重排和重繪,爲了提升性能和效率,能夠把el根節點下的全部節點替換爲文檔碎片fragment進行解析編譯操做,解析完成,再將fragment添加到根節點el中

若是想對文檔碎片進行,更多的瞭解,能夠查看文章底部的參考資料

<!--定義模板編譯類-->
class Complie{
    constructor(el,vm){
        this.$vm = vm;
        this.$el = document.querySelector(el);
        
        //第一步,把DOM轉換成文檔碎片
        this.$fragment = this.nodeToFragment(this.$el);
        
        //第二步,匹配標識符,填充數據
        this.compileElement(this.$fragment);
        
        //把文檔碎片,添加到el根節點上面
        this.$el.appendChild(this.$fragment);  
    }
    
    // 把DOM節點轉換成文檔碎片
    nodeToFragment(el){
        let nodeFragment = document.createDocumentFragment();
        // 循環遍歷el下面的節點,填充到文檔碎片nodeFragment中
        while(child = el.firstChild){
            nodeFragment.appendChild(child);
        }
        
        // 把文檔碎片返回
        return nodeFragment;
    }
    
    // 遍歷目標,查找標識符,並替換
    compileElement(node){
        let reg = /\{\{(.*)\}\}/;
        Array.from(node.childNodes).forEach((node)=>{
            let text = node.textContent;
            if(node.nodeType === 3 && reg.test(text)){
                let arr = RegExp.$1.split('.');
                // vm 是實例的整個data對象
                let val = vm;
                arr.forEach((k)=>{
                    val = val[k]
                })
                
                node.textContent = text.replace(/\{\{(.*)\}\}/,val);
            }
            
            // 若是節點包含字節的,遞歸調用自身
            if(node.childNodes){
                this.compileElement(node)
            }
            
        })
    }
    
    
}

<!--實例化的方法-->
const complie = (el,vm)=>{
    return new Compile(el,vm)
}

發佈訂閱

在軟件架構中,發佈訂閱是一種消息範式,消息的發送者(成爲發佈者)不會將消息直接發送給特定的接收者(成爲訂閱者)。二十將發佈的消息分爲不一樣的類別,無需瞭解哪些訂閱者是否存在。一樣的,訂閱者能夠表達對一個或多個類別的興趣,直接受感興趣的消息,無需瞭解哪些發佈者是否存在——維基。

上述的表達中,既然說發佈者不關心訂閱者,訂閱者也不關心發佈者,那麼他們是如何通訊呢?

其實就是經過第三方,一般在函數中咱們,稱他們爲觀察者watcher

在VUE的裏面,咱們要確認幾個概念,誰是發佈者,誰是訂閱者,爲何須要發佈訂閱?

上面咱們說了數據劫持Observe,也說了Compile,其實,Observe和Compile 他們即便發佈者,也是訂閱者,幫助他們之間的通信,就是watcher的工做。
經過下面的代碼,咱們簡單瞭解下,發佈訂閱模式的實現狀況。

// 建立一個類
// 發佈訂閱,本質上是維護一個函數的數組列表,訂閱就是放入函數,發佈就是讓函數執行

class Dep{
    consturctor(){
        this.subs=[];
    }
    
    // 添加訂閱者
    addSub(sub){
        this.subs.push(sub);
    }
    
    // 通知訂閱者
    notify(){
        // 訂閱者,都有
        this.subs.forEach((sub=>sub.update());
    }
}

// 監聽函數,watcher
// 經過Watcher類建立的實例,都有update方法
class Watcher{
    
    // watcher的實例,都須要傳入一個函數
    constructor(fn){
        this.fn = fn;
    }
    
    // watcher的實例,都擁有update方法
    update(){
        this.fn();
    }
}

// 把函數做爲參數傳入,實例化一個watcher
const watcher = new Watcher(()=>{
    consoole.log('1')
});

// 實例化Dep 類
const dep = new Dep();

// 將watcher放到dep維護的數組中,watcher實例自己具備update方法
// 能夠理解成函數的訂閱
dep.addSub(watcher);

// 執行,能夠理解成,函數的發佈,
// 不關心,addSub方法訂閱了誰,只要訂閱了,就經過遍歷循環subs數組,執行數組每一項的update
dep.notify();

經過以上代碼的瞭解,咱們繼續實現咱們MVVM中的代碼,實現數據和視圖的關聯。
這種關聯的結果就是,當咱們修改data中的數據的時候,咱們的視圖更新。或者咱們視圖中修改了相關內容,咱們的data也進行相關的更新,因此這裏主要的邏輯代碼,就是咱們watcher當中的update方法。

咱們根據上面的內容,對咱們的Observe和Compile以及Watcher進行修改,代碼以下:

class MVVM{
    constructor(option){
        this.$option = option;
        const data = this._data = this.$option.data;
        this.$el = this.$option.el;
        
        // 數據劫持
        this._observe(data);
        
        // 數據代理
        this._proxyData(data);
        
        //模板解析
        this._compile(this.$el,this)
    }
    
    // 數據代理
    _proxyData(data){
        for(let k in data){
            let val = data[k];
            Object.defineProperty(this,k,{
                enumerable:true,
                get(){
                    return this._data[k];
                },
                set(newVal){
                    this._data[k] = newVal;
                }
            })
        }
    }
    

    
}




// 數據劫持
class Observe{
    constructor(data){
        this.init(data);
    }
    
    init(data){
        let dep = new Dep();
        for(let k in data){
            let val = data[k];
            
            // val 多是一個對象,遞歸調用
            if(typeof val === 'object'){
                observe(val);
            }
            Object.defineProperty(data,k,{
                enumerable:true,
                get(){
                    // 訂閱,
                    
                    // Dep.target 是Watcher的實例
                    Dep.target && dep.addSub(Dep.target);
                    return val;
                },
                set(newVal){
                    if(newVal === val){
                        return;
                    }
                    
                    val = newVal;
                    observe(newVal);
                    
                    
                    dep.notify();
                }
                
            })
        }
    }
}

// 數據劫持實例
function observe(data){
    if(typeof data !== 'object'){
        return
    };
    return new Observe(data);
}
    


// 模板編譯
class Compile{
    constructor(el,vm){
        vm.$el = document.querySelector(el);
    
        //1.把DOM節點,轉換成文檔碎片
        const Fragment = this.nodeToFragment(vm.$el)
        
        //2.經過正則匹配,填充數據
        this.replace(Fragment,vm);
        
        //3.把填充過數據的文檔碎片,插入模板根節點
        vm.$el.appendChild(Fragment);
        
        
    }
    
    // DOM節點轉換
    nodeToFragment(el){
        // 建立文檔碎片,
        const fragment = document.createDocumentFragment();
        //遍歷DOM節點,把DOM節點,添加到文檔碎片上
        while(child ===el.firstChild){
            fragment.appendChild(child);    
        }
        // 返回文檔碎片
        return fragment;
    }
    
    //匹配標識,填充數據
    replace(fragment,vm){
        // 使用Array.from方法,把DOM節點,轉化成數據,進行循環遍歷
        Array.from(fragment.childNodes).forEach((node)=>{
            // 遍歷節點,拿到每一個內容節點
            let text = node.textContent;
            // 定義標識符的正則
            let reg = /\{\{(.*)\}\}/;
            
            //若是節點是文本,且節點的內容當中匹配到了模板標識符
            
            // 數據渲染視圖
            if(node.nodeType===3 && reg.test(text)){
                // 用數據替換標識符
                let arr = RegExp.$1.split('.');
                let val = vm;
                arr.forEach((item)=>{
                    val = val[item];
                })
                // 添加一個watcher,當咱們的數據發生變化的時候,更新咱們的view
                new Watcher(vm,RegExp.$1,(newVal)=>{
                    node.textContent = text.replace(reg,newVal); 
                })
                
                //把數據填充到節點上
                node.textContent = text.replace(reg,val);
            }
            
            // 視圖更新數據
            if(node.nodeType === 1){
                let nodeAttrs = node.attributes;
                Array.from(nodeAttrs).forEach((attr)=>{
                    let name = attr.name;
                    // 獲取標識符的內容,也就是v-mode="a"的內容
                    let exp = attr.value;
                    if(name.indexOf('v-model')===0){
                        node.value = vm[exp];
                    };
                    new Watcher(vm,exp,(newVal)=>{
                        node.value = newVal;
                    });
                    
                    node.addEventListener('input',function(e){
                        let newVal = e.target.value;
                        vm[exp] = newVal;
                    });
                });
            }
            
            // 若是節點包含子節點,遞歸調用自身
            if(node.childNodes){
                this.replace(node,vm);
            }
        })
    }
}

// 模板編譯實例
function compile(el,vm){
    return new Compile(el,vm)
}

// 發佈訂閱
class Dep{
    constructor(){
        this.subs = [];
    }
    
    // 訂閱函數
    addSub(fn){
        this.subs.push(fn);
    }
    
    // 發佈執行函數
    notify(){
        this.subs.forEach((fn)=>{
            fn();
        })
    }
}

// Dep實例
function dep(){
    return new Dep();
}

// 觀察者
class Watcher{
    // vm,咱們的實例
    // exp,咱們的標識符
    // fn,回調
    constructor(vm,exp,fn){
        this.fn = fn;
        this.vm = vm;
        this.exp = exp;
        Dep.target = this;
        let val = vm;
        let arr = exp.split('.');
        arr.forEach((k)=>{
            val = val[k]
        });
        // 完成以後,咱們把target 刪除;
        Dep.target = null;
    }
    update(){
        let val = this.vm;
        let arr = this.exp.split('.');
        arr.forEach((k)=>{
            val = val[k];
        })
        this.fn();
    }
}


function watcher(){
    return new Watcher()
}

Wathcer幹了那些好事:

  • 在自身實例化的時候,往訂閱器(dep)裏面添加本身
  • 自身有一個update方法
  • 待data屬性發生修改的時候,dep.notify()通知的時候,能夠調用自身的update()方法,在update()方法出發綁定的回調

Watcher鏈接了兩個部分,包括Observe和Compile;

在Observe方法執行的時候,咱們給data的每一個屬性都添加了一個dep,這個dep被閉包在get/set函數內。

當咱們new Watcher,在以後訪問data當中屬性的時候,就會觸發經過Object.defineProperty()函數當中的get方法。
get方法的調用,就會在屬性的訂閱器實例dep中,添加當前Watcher的實例。

當咱們嘗試修改data屬性的時候,就會出發dep.notify()方法,該方法會調用每一個Watcher實例的update方法,從而更新咱們的視圖。

結束語

回顧下整個MVVM實現的整個過程

  • 使用Object.defineProperty()函數,給每一個data屬性添加get/set,併爲每一個屬性建立一個dep實例,監聽數據變化
  • 一樣使用Object.defineProperty()函數,把data對象的屬性,綁定到咱們MVVM實例vm對象上,簡化使用
  • 經過document.createDocumentFragment,把咱們el節點下的dom轉換成文檔碎片
  • 遍歷文檔碎片,找到模板標識符,進行數據的替換,添加Watcher觀察者,當數據發生變化的時候,再次更新咱們的文檔碎片
  • 把文檔碎片插入到咱們的el節點中。
  • 咱們修改data,執行dep.notify()方法,而後調用Watcher實例上的update方法,更新視圖。

我這裏有一個簡短的視頻,是某培訓機構講解MVVM的內容,你們有興趣,能夠自取。

視頻連接

提取碼:1i0r

若是失效,能夠私聊我。

參考

廖雪峯談MVVM

...,讓MVVM原理還給你

觀察者模式與發佈訂閱模式

基於vue實現一個簡單的MVVM框架

文檔碎片

相關文章
相關標籤/搜索