淺探VUE的MVVM模式實現

騰訊DeepOcean原創文章:dopro.io/vue-mvvm-re…javascript

timg

一、MVVM模式

MVVM的設計思想:關注Model(數據)的變化,讓MVVM框架去自動更新DOM的狀態,比較主流的實現有:angular的(髒值檢測)vue的(數據劫持->發佈訂閱模式)咱們重點了解vue(數據劫持->發佈訂閱模式)的實現方式,讓咱們從操做DOM的繁瑣操做中解脫出來

二、核心方法Object.defineProperty的理解

咱們先簡單看一下這個方法它也是用來實現咱們數據劫持(數據監聽)的關鍵方法,咱們知道vue框架是不兼容IE6~8低版本的,主要是由於它的用到了ES5中的這個Object.defineProperty的方法,並且這個方法暫時沒有很好的降級方案。
var a = {};
Object.defineProperty(a, 'b', {    value: 123,         // 設置的屬性值    writable: false,    // 是否只讀    enumerable: false,  // 是否可枚舉    configurable: false //
});
console.log(a.b); //123複製代碼

方法使用很簡單,它接受三個參數,並且都是必填html

  • 第一個參數:目標對象
  • 第二個參數:須要定義的屬性或方法的名字。
  • 第三個參數:目標屬性所擁有的特性
前兩個參數比較好理解,主要看第三個參數它是一個對象,看看有哪些屬性定義
  • value:屬性的值。
  • writable:若是爲 false ,屬性的值就不能被重寫,只能爲只讀了。
  • enumerable:是否可枚舉,默認是false不可枚舉的(一般設置爲true)
  • configurable:總開關,一旦爲false,就不能再設置其餘的( value , writable , enumerable)
  • get():函數,獲取屬性值時執行的方法(不能夠和writable、value屬性共存)
  • set():函數,設置屬性值時執行的方法(不能夠和writable、value屬性共存)
// 經常使用定義
var obj = {};
Object.defineProperty(obj, 'school', {    enumerable: true,    get: function() {        
       // 獲取屬性值時會調用get方法    },    set: function(newVal) {        
       // 設置屬性值時會調用set方法        return newVal    } });複製代碼

咱們經過這個Object.defineProperty這個方法,能夠實現對定義的引用數據類型的實現監聽,被方法監聽後的對象,裏面定義的值發生被獲取和設置操做的時候,會分別觸發Object.defineProperty裏面參數三的get和set方法。前端

三、數據劫持

在瞭解完了Object.defineProperty方法後,咱們如今要經過它來實現咱們的數據劫持功能 Obaerve的方法,看以下代碼

function
MyVue(options = {})
{    
   // 將全部的屬性掛載到$options身上    this.$options = options;    
   // 獲取到data數據(Model)    var data = this._data = this.$options.data;    
   // 劫持數據    observe(data) }

// 給須要觀察的對象都添加 Object.defineProperty 的監聽
function Observe(data) {    
   for (let key in data) {        
       let val = data[key];        
       // 遞歸 =》來實現深層的數據監聽        observe(val)        
       Object.defineProperty(data, key, {            enumerable: true,            get() {                
               return val            },            set(newval) {                if (val === newval) { //設置的值是否和之前是同樣的,若是是就什麼都不作                    return                }                val = newval // 這裏要把新設置的值也在添加一次數據劫持來實現深度響應,                observe(newval);            }        })    } }
function observe(data) {
   // 這裏作一下數據類型的判斷,只有引用數據類型纔去作數據劫持    if (typeof data != 'object') return    return new Observe(data) }複製代碼

1)以上代碼作了這些事情,先定義了初始換構造函數MyVue咱們經過它來獲取到咱們傳進來的數據data和咱們定義的DOM節點範圍,而後把data傳進定好的數據劫持方法observevue

2)Observe實現了對數據的監聽總體邏輯,這裏有個細節點,沒有直接用構造函數Observe去劫持咱們的數據,而是寫多了一個observe的小方法用來new Observe,而且在裏面作了引用數據類型的判斷。這樣作的目的是爲了方便遞歸來實現數據結構的深層監聽,由於咱們的data結構確定是複雜多樣的,例以下面代碼java

// 這裏數據結構嵌套不少,要實現深層的數據監聽採用了遞歸的實現方式
data: { a: {b:2} , c:{q:12,k:{name:'binhemori'}} , f:'mvvvm',o:[12,5,9,8]}複製代碼

3)這裏還須要注意的是咱們在set方法裏面有再一次把設置的新值,執行了一遍observe方法,是爲了實現深度響應,由於在賦值的時候可能會賦值一個引用數據類型的值,咱們知道vue有個特色,是不能新增不存在的屬性和不能存在屬性沒有get和set方法的,若是賦值進來的新屬性值是引用數據類型,就會把咱們原先執行過數據劫持方法的對象地址給替換掉,而新對象是沒有通過數據劫持的就是沒有get和set的方法,因此咱們在設置新值的時候須要在從新給他執行一遍observe數據劫持,確保開發者無論怎樣去設置值的時候都能被劫持到node


說了這麼多,咱們來使用一下看看有沒有實現對數據的劫持(數據監聽)吧設計模式

數組

<div id="app">    
   <div>         <div>這裏的數據1======<span style="color: red;">{{a.b}}</span></div>         <div>這裏是數據2======<span style="color: green;">{{c}}</span></div>     </div>     <input type="text" v-model="a.b" value=""> </div> <!-- 引入本身定義的mvvm模塊 --> <script src="./mvvm.js"></script> 複製代碼<script type="text/javascript">     var myvue = new MyVue({         el: '#app',         data: { a: { b: '你好' }, c: 12, o: [12, 5, 9, 8] }     })
</script>複製代碼
3

能夠看到對咱們所定義的data中的數據都已經有了get和set方法了,到這裏咱們對data中數據的變化都是能夠監聽的到了微信

四、數據代理

數據代理,咱們用過vue的都知道,在實際使用中是能直接經過實例+屬性(vm.a)直接獲取到數據的,而咱們上面的代碼要獲取到數據還須要這樣myvue._data.a這樣來獲取到數據,中間多了一個 _data 的環節,這樣使用起來不是很方便的,下面咱們來實現讓咱們的實例this來代理( _data)數據,從而實現 myvue.a 這樣的操做能夠直接獲取到數據數據結構

function MyVue(options = {}) {    
   // 將全部的屬性掛載到$options身上    this.$options = options;    
   // 獲取到data數據(Model)    var data = this._data = this.$options.data;    observe(data);    
   
   // this 就代理數據 this._data    for (const key in data) {        
   Object.defineProperty(this, key, {            enumerable: true,            get() {                
               // this.a 這裏取值的時候 其實是去_data中的值                return this._data[key]            },            set(newVal) {                // 設置值的時候其實也是去改this._data.a的值                this._data[key] = newVal            }        })    } }複製代碼

以上代碼實現了咱們的數據代理,就是在構建實例的時候,把data中的數據遍歷一次出來,依次加到咱們this上,加的過程當中也不要忘記添加Object.defineProperty,只要是數據咱們都須要添加監聽。以下圖咱們已經實現了對數據的代理

2

五、編譯模板(Compile)

咱們已經完成對數據劫持也實現了this對數據的代理,那麼接下來要作的就是怎樣把數據編譯到咱們的DOM節點上面,也就是讓視圖層(view)要展現咱們的數據了

// 將數據和節點掛載在一塊兒
function Compile(el, vm) {    
   // el表示替換的範圍    vm.$el = document.querySelector(el);    
   // 這裏注意咱們沒有去直接操做DOM,而是把這個步驟移到內存中來操做,這裏的操做是不會引起DOM節點的迴流    let fragment = document.createDocumentFragment(); // 文檔碎片    let child;  
   
   while (child = vm.$el.firstChild) {
       // 將app的內容移入內存中        fragment.appendChild(child);    }
           replace(fragment)    
   function replace(fragment) {        
       Array.from(fragment.childNodes).forEach(function (node) { //循環每一層            let text = node.textContent;            
           let reg = /\{\{(.*)\}\}/g;
                       
           // 這裏作了判斷只有文本節點纔去匹配,並且還要帶{{***}}的字符串            if (node.nodeType === 3 && reg.test(text)) {  
               // 把匹配到的內容拆分紅數組              
               let arr = RegExp.$1.split('.');                let val = vm;                
               
               // 這裏對咱們匹配到的定義數組,會依次去遍歷它,來實現對實例的深度賦值                arr.forEach(function (k) { // this.a.b  this.c                    val = val[k]                })      
                         
               // 用字符串的replace方法替換掉咱們獲取到的數據val                node.textContent = text.replace(/\{\{(.*)\}\}/, val)            }        
                  
           // 這裏作了判斷,若是有子節點的話 使用遞歸            if (node.childNodes) {                replace(node)            }        })    }    
   // 最後把編譯完的DOM加入到app元素中    vm.$el.appendChild(fragment) }複製代碼

以上代碼實現咱們對數據的編譯Compile以下圖,能夠看到咱們把獲取到el下面全部的子節點都存儲到了文檔碎片 fragment 中暫時存儲了起來(放到內存中),由於這裏要去頻繁的操做DOM和查找DOM,因此移到內存中操做

4
  • 1)先用while循環,先把 el 中全部的子節點都添加到文檔碎片中fragment.appendChild(child);
  • 2)而後咱們經過replace方法去遍歷文檔中全部的子節點,將他們文本節點中(node.nodeType = 3)帶有{{}} 語法中的內容都獲取到,把匹配到的值拆分紅數組,而後遍歷依次去data中查找獲取,遍歷的節點若是有子節點的話繼續使用replace方法直到反回undefined
  • 3)獲取到數據後,用replace方法替換掉文本中{{}}的整塊內容,而後在放回el元素中vm.$el.appendChild(fragment),

六、關聯視圖(view)與數據(model)

在成功的將咱們的數據綁定到了DOM節點以後,要實現咱們的視圖層(view)跟數據層(model)的關聯,如今實際上尚未關聯起來,由於沒法經過改數據值來引起視圖的變化,實現這步以前先聊一下JS中比較經常使用的設計模式發佈訂閱模式也是vue實現雙向數據綁定的很關鍵的一步

發佈訂閱模式(又稱觀察者模式)

咱們先簡單手動實現一個(就是一個數組關係)

// 發佈訂閱
function Dep() {    
   this.subs = [] }
// 訂閱
Dep.prototype.addSub = function (sub) {    
   this.subs.push(sub) }
// 通知
Dep.prototype.notify = function (sub) {    
   this.subs.forEach(item => item.update()) }
   
// watcher是一個類,經過這個類建立的函數都會有update的方法
function Watcher(fn) {    
   this.fn = fn; } Watcher.prototype.update = function () {    
   this.fn() }複製代碼

這裏用Dep方法來實現訂閱和通知,在這個類中有addSub(添加)和notify(通知)兩個方法,咱們把將要作的事情(函數)經過addSub添加進數組裏,等時機一到就notify通知裏面全部的方法執行

你們會發現爲何要另外定義一個建立函數的方法watcher,而不是直接把方法扔到addSub中好,這樣不是畫蛇添足嘛?其實這樣作的有它的目的,其中一個好處就是咱們經過watcher建立的函數都會有一個update執行的方法能夠方便咱們調用。而另一個用處我下面會講到,先把它運用起來吧

function replace(fragment) {    
   Array.from(fragment.childNodes).forEach(function (node) {         let text = node.textContent;        
        let reg = /\{\{(.*)\}\}/g;  
             
        if (node.nodeType === 3 && reg.test(text)) {            
            let arr = RegExp.$1.split('.');            
            let val = vm;             arr.forEach(function (k) {                 val = val[k]             })            
            // 在這裏運用了Watcher函數來新增要操做的事情             new Watcher(vm, RegExp.$1, function (newVal) {                 node.textContent = text.replace(/\{\{(.*)\}\}/, newVal)             }) 複製代碼            node.textContent = text.replace(/{{(.*)}}/, val)         }        
           
        if (node.childNodes) {             replace(node)         }     }) }複製代碼

能夠看到咱們把定義函數的方法watcher加到了replace方法裏面,可是這裏的watcher更剛寫編寫的多了兩個形參vm、RegExp.$1,並且寫法也新增了一些內容,由於當new Watcher的時候會引起發生幾個操做,來看代碼:

// vm作數據代理的地方
function MyVue(options = {}) {    
   this.$options = options;    
   var data = this._data = this.$options.data;    observe(data);    
   for (const key in data) {        
       Object.defineProperty(this, key, {            enumerable: true,            get() {                
               return this._data[key]            },            set(newVal) {                this._data[key] = newVal            }        })    } }
// 數據劫持函數
function Observe(data) {          let dep = new Dep();    
   for (let key in data) {        
       let val = data[key];        observe(val)        
       Object.defineProperty(data, key, {            enumerable: true,            get() {                
               /* 獲取值的時候 Dep.target
                  對於着 watcher的實例,把他建立的實例加到訂閱隊列中
               */
               Dep.target && dep.addSub(Dep.target);                return val            },            set(newval) {                if (val === newval) {                    
                   return                }                val = newval;                observe(newval);                
               // 設置值的時候讓全部的watcher.update方法執行便可觸發全部數據更新                dep.notify()            }        })    } }

function
Watcher(vm, exp, fn)
{    
   this.fn = fn;    
   // 這裏咱們新增了一些內容,用來能夠獲取對於的數據    this.vm = vm;    
   this.exp = exp;    Dep.target = this    let val = vm;    
   let arr = exp.split('.');    
   /* 執行這一步的時候操做的是vm.a,
   而這一步操做其實就是操做的vm._data.a的操做,
   會觸發this代理的數據和_data上面的數據
   */
   arr.forEach(function (k) {        val = val[k]    })    Dep.target = null; }
// 這裏是設置值操做
Watcher.prototype.update = function () {    
   let val = this.vm;    
   let arr = this.exp.split('.');    arr.forEach(function (k) {        val = val[k]    })    
   this.fn(val) //這裏面要傳一個新值
}複製代碼

這裏開始會有點繞,必定要理解好操做數據的時候會觸發的那個實例上面數據的get和set,操做的是那個數據這個思惟

1)首先看在Watcher構造函數中新增了一些私有屬性分別表明:

  • Dep.target = this(在構造函數Dep.target臨時存儲着watcher的當前實例)
  • this.vm = vm(vm = myvue實例)
  • this.exp = exp(exp = 匹配的查找的對象」a.b」是字符串類型的值)
咱們存儲這些屬性後,接下來就要去獲取用exp匹配的字符串裏面對於數據也就是 vm.a.b,可是此時的exp是個字符串,你不能直接這樣取值vm[a.b]這是錯誤的語法,因此要循環去取到對於的值
arr.forEach(function (k) {        
       // arr = [a,b]        val = val[k] })複製代碼
  • 第一次循環的時候是vm[a] = {b:12},取到了a這個對象,而後在賦值回去就是把當前的val變成了a這個對象
  • 第二次循環的時候val已經變成了 a對象,此時的k變成了b,val就變成了:a[b] = 12
通過兩次遍歷後咱們獲取到值是vm代理數據上面的a對象, 也就是會觸發代理數據上面對於數據的get的方法(vm.a.b)而這個操做返回的是 this._data[k] 它又會觸發 vm._data.a.b 數據上的get方法,也就是走到了Observe 函數裏面的get,此時的Dep.target存儲的是當前watcher方法的實例(這個實例裏面已經有要操做數據的信息),而且把取到最新的值傳到了方法中

get() {    
   // 走到這裏的時候 Dep.target 已經存儲了 watcher的當前實例實例,把他建立的實例加到訂閱隊列中    Dep.target && dep.addSub(Dep.target);    return val },
   
// 把要作的更新視圖層的操做方法用Watcher定義好,裏面已經定義好了要操做的對象
new Watcher(vm, RegExp.$1, function (newVal) {    node.textContent = text.replace(/\{\{(.*)\}\}/, newVal) }) 複製代碼Watcher.prototype.update = function () {    
   let val = this.vm;    
   let arr = this.exp.split('.');    arr.forEach(function (k) {        val = val[k]    })    this.fn(val) // 把對於的新值傳遞到方法裏面
}複製代碼

這裏由於加多了一層 vm.a 這樣的數據代理,因此邏輯有點繞,記住這句話就好理解操做 vm.a 代理數據上面值的時候,其實就是操做的vm._data中的數據因此會觸發兩個地方的get和set方法,好說這麼多,咱們來看是否實現數據變更觸發視圖層的變化吧

text2

這裏就實現了數據的變動觸發視圖層的更新操做了

七、input雙向數據綁定的實現

最後一步就來實現視圖層的變動觸發數據結構的變動操做,上面咱們已經把視圖與數據關聯最核心的代碼講解了,剩下視圖變動觸發數據變動就比較好實現了

<div id="app">    
   <div>        <div>這裏的數據1======<span style="color: red;">{{a.b}}</span></div>        <div>這裏是數據2======<span style="color: green;">{{c}}</span></div>    </div>    <input type="text" v-model="a.b" value="">
</div>

<!-- 引入本身定義的mvvm模塊 -->
<script src="./mvvm.js"></script>
<script type="text/javascript">    var myvue = new MyVue({        el: '#app',        data: { a: { b: '你好' }, c: 12, o: [12, 5, 9, 8] }    })
</script>
複製代碼

// 獲取全部元素節點
if (node.nodeType === 1) {    
   let nodeAttr = node.attributes    
   Array.from(nodeAttr).forEach(function (attr) {        
       let name = attr.name; // v-model="a.b"        let exp = attr.value; // a.b 複製代碼       if (name.indexOf('v-') >= 0) {            
           let val = vm;            
           let arr = exp.split('.');            arr.forEach(function (n) {                val = val[n]            })            
           // 這個還好處理,取到對應的值設置給input.value就好            node.value = val;        }        
       // 這裏也要定義一個Watcher,由於數據變動的時候也要變動帶有v-model屬性名的值        new Watcher(vm, exp, function (newVal) {            node.value = newVal        })        
       // 這裏是視圖變化的時候,變動數據結構上面的值        node.addEventListener('input', function (e) {            
           let newVal = e.target.value            
           if (name.indexOf('v-') >= 0) {                
               let val = vm;                
               let arr = exp.split('.');                arr.forEach(function (k,index) {                        if (typeof val[k] === 'object') {                        val = val[k]                    } else{                        
                   if (index === arr.length-1) {                            val[k] = newVal                        }                    }                })            }        })    }) }複製代碼

上面代碼對數據變動觸發視圖層變動的邏輯更上一節同樣便可,主要是node.addEventListener('input')這裏設置數據的問題,其實原理跟第六節關聯視圖(view)與數據(model)的邏輯同樣,有必定須要注意的是這邊加了一個引用數據類型的判斷,否則他的循環會到最底層的數據類型值(也就是基礎數據類型) 1)這裏判斷到取到的不是對象數據類型,不作替換操做 (val = val[k]) 2)判斷是否是已經最後一個層級了index === arr.length-1,若是是的話直接把input中的值賦值進當前數據中便可

arr.forEach(function (k,index) {     
    if (typeof val[k] === 'object') {        
       // 若是有嵌套的話就繼續查找        val = val[k]    } else{        
       if (index === arr.length-1) {            
       // 查找到最後一個後直接賦值            val[k] = newVal        }    } })複製代碼

以上是整個mvvm雙向數據綁定的簡單實現原理,內容有些哪裏解釋不通順的地方或有更好的意見歡迎留言:)

歡迎關注"騰訊DeepOcean"微信公衆號,每週爲你推送前端、人工智能、SEO/ASO等領域相關的原創優質技術文章:

看小編搬運這麼辛苦,關注一個唄:)

相關文章
相關標籤/搜索