淺探VUE的MVVM模式實現

騰訊DeepOcean發表於2018-11-16

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

timg

1、MVVM模式

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

2、核心方法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複製程式碼

方法使用很簡單,它接受三個引數,而且都是必填

  • 第一個引數:目標物件
  • 第二個引數:需要定義的屬性或方法的名字。
  • 第三個引數:目標屬性所擁有的特性
前兩個引數比較好理解,主要看第三個引數它是一個物件,看看有哪些屬性定義
  • 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方法。

3、資料劫持

在瞭解完了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傳進定好的資料劫持方法observe

2)Observe實現了對資料的監聽整體邏輯,這裡有個細節點,沒有直接用建構函式Observe去劫持我們的資料,而是寫多了一個observe的小方法用來new Observe,並且在裡面做了引用資料型別的判斷。這樣做的目的是為了方便遞迴來實現資料結構的深層監聽,因為我們的data結構肯定是複雜多樣的,例如下面程式碼

// 這裡資料結構巢狀很多,要實現深層的資料監聽採用了遞迴的實現方式
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資料劫持,確保開發者不管怎樣去設定值的時候都能被劫持到


說了這麼多,我們來使用一下看看有沒有實現對資料的劫持(資料監聽)吧

<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中資料的變化都是可以監聽的到了

4、資料代理

資料代理,我們用過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

5、編譯模板(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),

6、關聯檢視(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

這裡就實現了資料的變更觸發檢視層的更新操作了

7、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等領域相關的原創優質技術文章:

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

淺探VUE的MVVM模式實現

相關文章