1. 程式人生 > 程式設計 >詳解Vue中的MVVM原理和實現方法

詳解Vue中的MVVM原理和實現方法

下面由我阿巴阿巴的詳細走一遍Vue中MVVM原理的實現,這篇文章大家可以學習到:

1.Vue資料雙向繫結核心程式碼模組以及實現原理

2.訂閱者-釋出者模式是如何做到讓資料驅動檢視、檢視驅動資料再驅動檢視

3.如何對元素節點上的指令進行解析並且關聯訂閱者實現檢視更新

一、思路整理

實現的流程圖:

詳解Vue中的MVVM原理和實現方法

我們要實現一個類MVVM簡單版本的Vue框架,就需要實現一下幾點:

1、實現一個數據監聽Observer,對資料物件的所有屬性進行監聽,資料發生變化可以獲取到最新值通知訂閱者。

2、實現一個解析器Compile解析頁面節點指令,初始化檢視。

3、實現一個觀察者Watcher,訂閱資料變化同時繫結相關更新函式。並且將自己放入觀察者集合Dep中。Dep是Observer和Watcher的橋樑,資料改變通知到Dep,然後Dep通知相應的Watcher去更新檢視。

二、實現

以下采用ES6的寫法,比較簡潔,所以大概在300多行程式碼實現了一個簡單的MVVM框架。

1、實現html頁面

按Vue的寫法在頁面定義好一些資料跟指令,引入了兩個JS檔案。先例項化一個MVue的物件,傳入我們的el,data,methods這些引數。待會再看Mvue.js檔案是什麼?

html

<body>
 <div id="app">
  <h2>{{person.name}} --- {{person.age}}</h2>
  <h3>{{person.fav}}</h3>
  <h3>{{person.a.b}}</h3>
  <ul>
   <li>1</li>
   <li>2</li>
   <li>3</li>
  </ul>
  <h3>{{msg}}</h3>
  <div v-text="msg"></div>
  <div v-text="person.fav"></div>
  <div v-html="htmlStr"></div>
  <input type="text" v-model="msg">
  <button v-on:click="click111">按鈕on</button>
  <button @click="click111">按鈕@</button>
 </div>
 <script src="./MVue.js"></script>
 <script src="./Observer.js"></script>
 <script>
  let vm = new MVue({
   el: '#app',data: {
    person: {
     name: '星哥',age: 18,fav: '姑娘',a: {
      b: '787878'
     }
    },msg: '學習MVVM實現原理',htmlStr: '<h4>大家學的怎麼樣</h4>',},methods: {
    click111() {
     console.log(this)
     this.person.name = '學習MVVM'
     // this.$data.person.name = '學習MVVM'
    }
   }
  })
 </script>

</body>

2、實現解析器和觀察者

MVue.js

// 先建立一個MVue類,它是一個入口
Class MVue {
  construction(options) {
    this.$el = options.el
    this.$data = options.data
    this.$options = options
  }
  if(this.$el) {
    // 1.實現一個數據的觀察者   --先看解析器,再看Obeserver
    new Observer(this.$data)
    // 2.實現一個指令解析器
    new Compile(this.$el,this)
  }
}
​
// 定義一個Compile類解析元素節點和指令
class Compile {
  constructor(el,vm) {
    // 判斷el是否是元素節點物件,不是就通過DOM獲取
    this.el = this.isElementNode(el) ? el : document.querySelector(el)
    this.vm = vm
    // 1.獲取文件碎片物件,放入記憶體中可以減少頁面的迴流和重繪
    const fragment = this.node2Fragment(this.el)

    // 2.編輯模板
    this.compile(fragment)

    // 3.追加子元素到根元素(還原頁面)
    this.el.appendChild(fragment)
  }

  // 將元素插入到文件碎片中
  node2Fragment(el) {
    const f = document.createDocumnetFragment();
    let firstChild
    while(firstChild = el.firstChild) {
      // appendChild
      // 將已經存在的節點再次插入,那麼原來位置的節點自動刪除,並在新的位置重新插入。
      f.appendChild(firstChild)
    }
    // 此處執行完,頁面已經沒有元素節點了
    return f
  }

  // 解析模板
  compile(frafment) {
    // 1.獲取子節點
    conts childNodes = fragment.childNodes;
    [...childNodes].forEach(child => {
      if(this.isElementNode(child)) {
        // 是元素節點
        // 編譯元素節點
        this.compileElement(child)
      } else {
        // 文字節點
        // 編譯文字節點
        this.compileText(child)
      }

      // 巢狀子節點進行遍歷解析
      if(child.childNodes && child.childNodes.length) {
        this.compule(child)
      }
    })
  }

  // 判斷是元素節點還是屬性節點
  isElementNode(node) {
    // nodeType屬性返回 以數字值返回指定節點的節點型別。1-元素節點 2-屬性節點
    return node.nodeType === 1
  }

  // 編譯元素節點
  compileElement(node) {
    // 獲得元素屬性集合
    const attributes = node.attributes
    [...attributes].forEach(attr => {
      const {name,value} = attr
      if(this.isDirective(name)) { // 判斷屬性是不是以v-開頭的指令
        // 解析指令(v-mode v-text v-on:click 等...)
        const [,dirctive] = name.split('-')
        const [dirName,eventName] = dirctive.split(':')
        // 初始化檢視 將資料渲染到檢視上
        compileUtil[dirName](node,value,this.vm,eventName)

        // 刪除有指令的標籤上的屬性
        node.removeAttribute('v-' + dirctive)
      } else if (this.isEventName(name)) { //判斷屬性是不是以@開頭的指令
        // 解析指令
        let [,eventName] = name.split('@')
        compileUtil['on'](node,val,eventName)

        // 刪除有指令的標籤上的屬性
        node.removeAttribute('@' + eventName)
      } else if(this.isBindName(name)) { //判斷屬性是不是以:開頭的指令
        // 解析指令
        let [,attrName] = name.split(':')
        compileUtil['bind'](node,attrName)

        // 刪除有指令的標籤上的屬性
        node.removeAttribute(':' + attrName)
      }
    })
  }

  // 編譯文字節點
  compileText(node) {
    const content = node.textContent
    if(/\{\{(.+?)\}\}/.test(content)) {
      compileUtil['text'](node,content,this.vm)
    }
  }

  // 判斷屬性是不是指令
  isDirective(attrName) {
    return attrName.startsWith('v-')
  }
  // 判斷屬性是不是以@開頭的事件指令
  isEventName(attrName) {
    return attrName.startsWith('@')
  }
  // 判斷屬性是不是以:開頭的事件指令
  isBindName(attrName) {
    return attrName.startsWith(':')
  }
}
​
​
// 定義一個物件,針對不同指令執行不同操作
const compileUtil = {
  // 解析引數(包含巢狀引數解析),獲取其對應的值
  getVal(expre,vm) {
    return expre.split('.').reduce((data,currentVal) => {
      return data[currentVal]
    },vm.$data)
  },// 獲取當前節點內參數對應的值
  getgetContentVal(expre,vm) {
    return expre.replace(/\{\{(.+?)\}\}/g,(...arges) => {
      return this.getVal(arges[1],vm)
    })
  },// 設定新值
  setVal(expre,vm,inputVal) {
    return expre.split('.').reduce((data,currentVal) => {
      return data[currentVal] = inputVal
    },// 指令解析:v-test
  test(node,expre,vm) {
    let value;
    if(expre.indexOf('{{') !== -1) {
      // 正則匹配{{}}裡的內容
      value = expre.replace(/\{\{(.+?)\}\}/g,(...arges) => {

        // new watcher這裡相關的先可以不看,等後面講解寫到觀察者再回頭看。這裡是繫結觀察者實現   的效果是通過改變資料會觸發檢視,即資料=》檢視。
        // 沒有new watcher 不影響檢視初始化(頁面引數的替換渲染)。
        // 訂閱資料變化,繫結更新函式。
        new watcher(vm,arges[1],() => {
          // 確保 {{person.name}}----{{person.fav}} 不會因為一個引數變化都被成新值
          this.updater.textUpdater(node,this.getgetContentVal(expre,vm))
        })

        return this.getVal(arges[1],vm)
      })
    } else {
      // 同上,先不看
      // 資料=》檢視
      new watcher(vm,(newVal) => {
      // 找不到{}說明是test指令,所以當前節點只有一個引數變化,直接用回撥函式傳入的新值
    this.updater.textUpdater(node,newVal)
     })

      value = this.getVal(expre,vm)
    }

    // 將資料替換,更新到檢視上
    this.updater.textUpdater(node,value)
  },//指令解析: v-html
  html(node,vm) {
    const value = this.getVal(expre,vm)

    // 同上,先不看
    // 繫結觀察者 資料=》檢視
    new watcher(vm,expre (newVal) => {
      this.updater.htmlUpdater(node,newVal)
    })

    // 將資料替換,更新到檢視上
    this.updater.htmlUpdater(node,newVal)
  },// 指令解析:v-mode
  model(node,(newVal) => {
      this.updater.modelUpdater(node,newVal)
    })

    // input框 檢視=》資料=》檢視
    node.addEventListener('input',(e) => {
      //設定新值 - 將input值賦值到v-model繫結的引數上
      this.setVal(expre,e.traget.value)
    })
    // 將資料替換,更新到檢視上
    this.updater.modelUpdater(node,// 指令解析: v-on
  on(node,eventName) {
    // 或者指令繫結的事件函式
    let fn = vm.$option.methods && vm.$options.methods[expre]
    // 監聽函式並呼叫
    node.addEventListener(eventName,fn.bind(vm),false)
  },// 指令解析: v-bind
  bind(node,attrName) {
    const value = this.getVal(expre,vm)
    this.updater.bindUpdate(node,attrName,value)
  }

// updater物件,管理不同指令對應的更新方法
updater: {
    // v-text指令對應更新方法
    textUpdater(node,value) {
      node.textContent = value
    },// v-html指令對應更新方法
    htmlUpdater(node,value) {
      node.innerHTML = value
    },// v-model指令對應更新方法
    modelUpdater(node,value) {
      node.value = value
    },// v-bind指令對應更新方法
    bindUpdate(node,value) {
      node[attrName] = value
    }
  },}

3、實現資料劫持監聽

我們有了資料監聽,還需要一個觀察者可以觸發更新檢視。因為需要資料改變才能觸發更新,所有還需要一個橋樑Dep收集所有觀察者(觀察者集合),連線Observer和Watcher。資料改變通知Dep,Dep通知相應的觀察者進行檢視更新。

Observer.js

// 定義一個觀察者
class watcher {
  constructor(vm,cb) {
    this.vm = vm
    this.expre = expre
    this.cb =cb
    // 把舊值儲存起來
    this.oldVal = this.getOldVal()
  }
  // 獲取舊值
  getOldVal() {
    // 將watcher放到targe值中
    Dep.target = this
    // 獲取舊值
    const oldVal = compileUtil.getVal(this.expre,this.vm)
    // 將target值清空
    Dep.target = null
    return oldVal
  }
  // 更新函式
  update() {
    const newVal = compileUtil.getVal(this.expre,this.vm)
    if(newVal !== this.oldVal) {
      this.cb(newVal)
    }
  }
}
​
​
// 定義一個觀察者集合
class Dep {
  constructor() {
    this.subs = []
  }
  // 收集觀察者
  addSub(watcher) {
    this.subs.push(watcher)
  }
  //通知觀察者去更新
  notify() {
    this.subs.forEach(w => w.update())
  }
}
​
​
​
// 定義一個Observer類通過gettr,setter實現資料的監聽繫結
class Observer {
  constructor(data) {
    this.observer(data)
  }

  // 定義函式解析data,實現資料劫持
  observer (data) {
    if(data && typeof data === 'object') {
      // 是物件遍歷物件寫入getter,setter方法
      Reflect.ownKeys(data).forEach(key => {
        this.defineReactive(data,key,data[key]);
      })
    }
  }

  // 資料劫持方法
  defineReactive(obj,value) {
    // 遞迴遍歷
    this.observer(data)
    // 例項化一個dep物件
    const dep = new Dep()
    // 通過ES5的API實現資料劫持
    Object.defineProperty(obj,{
      enumerable: true,configurable: false,get() {
        // 當讀當前值的時候,會觸發。
        // 訂閱資料變化時,往Dep中新增觀察者
        Dep.target && dep.addSub(Dep.target)
        return value
      },set: (newValue) => {
        // 對新資料進行劫持監聽
        this.observer(newValue)
        if(newValue !== value) {
          value = newValue
        }
        // 告訴dep通知變化
        dep.notify()
      }
    })
  }

}

三、總結

其實複雜的地方有三點:

1、指令解析的各種操作有點複雜饒人,其中包含DOM的基本操作和一些ES中的API使用。但是你靜下心去讀去想,肯定是能理順的。

2、資料劫持中Dep的理解,一是收集觀察者的集合,二是連線Observer和watcher的橋樑。

3、觀察者是什麼時候進行繫結的?又是如何工作實現了資料驅動檢視,檢視驅動資料驅動檢視的。

在gitHub上有上述原始碼地址,歡迎clone打樁嘗試,還請不要吝嗇一個小星星喲!

以上就是詳解Vue中的MVVM原理和實現方法的詳細內容,更多關於Vue中的MVVM的資料請關注我們其它相關文章!