vue中MVVM原理及其實現

一. 什麼是mvvm

MVVM是Model-View-ViewModel的簡寫。它本質上就是MVC 的改進版。MVVM 就是將其中的View 的狀態和行爲抽象化,讓咱們將視圖 UI 和業務邏輯分開。html

clipboard.png

要實現一個mvvm的庫,咱們首先要理解清楚其實現的總體思路。先看看下圖的流程:vue

clipboard.png

1.實現compile,進行模板的編譯,包括編譯元素(指令)、編譯文本等,達到初始化視圖的目的,而且還須要綁定好更新函數;
2.實現Observe,監聽全部的數據,並對變化數據發佈通知;
3.實現watcher,做爲一箇中樞,接收到observe發來的通知,並執行compile中相應的更新方法。
4.結合上述方法,向外暴露mvvm方法。node

二. 實現方法

首先編輯一個html文件,以下:數組

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>MVVM原理及其實現</title>
</head>
<body>
<div id="app">
  <input type="text" v-model="message">
  <div>{{message}}</div>
  <ul><li></li></ul>
</div>
<script src="watcher.js"></script>
<script src="observe.js"></script>
<script src="compile.js"></script>
<script src="mvvm.js"></script>
<script>
  let vm = new MVVM({
    el: '#app',
    data: {
      message: 'hello world',
      a: {
        b: 'bbb'
      }
    }
  })
</script>
</body>
</html>

1.實現一個mvvm類(入口)

新建一個mvvm.js,將參數經過options傳入mvvm中,並取出el和data綁定到mvvm的私有變量$el和$data中。瀏覽器

// mvvm.js
class MVVM {
  constructor(options) {
    this.$el = options.el
    this.$data = options.data
  }
}

2.實現compile(編譯模板)

新建一個compile.js文件,在mvvm.js中調用compile。compile.js接收mvvm中傳過來的el和vm實例。app

// mvvm.js
class MVVM {
  constructor(options) {
    this.$el = options.el
    this.$data = options.data
    // 若是有要編譯的模板 =>編譯
    if(this.$el) {
      // 將文本+元素模板進行編譯
      new Compile(this.$el, this)
    }
  }
}

(1)初始化傳值dom

// compile.js
export default class Compile {
  constructor(el, vm) {
    // 判斷是不是元素節點,是=》取該元素 否=》取文本
    this.el = this.isElementNode(el) ? el:document.querySelector(el)
    this.vm = vm
  },
  // 判斷是不是元素節點
  isElementNode(node) {
    return node.nodeType === 1
  }
}

(2)先把真實DOM移入到內存中 fragment,由於fragment在內存中,操做比較快mvvm

// compile.js
class Compile {
  constructor(el, vm) {
    // 判斷是不是元素節點,是=》取該元素 否=》取文本
    this.el = this.isElementNode(el) ? el:document.querySelector(el)
    this.vm = vm
    // 若是這個元素能獲取到 咱們纔開始編譯
    if(this.el) {
      // 1. 先把真實DOM移入到內存中 fragment
      let fragment = this.node2fragment(this.el)
    }
  },
  // 判斷是不是元素節點
  isElementNode(node) {
    return node.nodeType === 1
  }
  // 將el中的內容所有放到內存中
  node2fragment(el) { 
    let fragment = document.createDocumentFragment()
    let firstChild
    // 遍歷取出firstChild,直到firstChild爲空
    while (firstChild = el.firstChild) {
      fragment.appendChild(firstChild)
    }
    return fragment // 內存中的節點
  }
}

(3)編譯 =》 在fragment中提取想要的元素節點 v-model 和文本節點函數

// compile.js
class Compile {
  constructor(el, vm) {
    // 判斷是不是元素節點,是=》取該元素 否=》取文本
    this.el = this.isElementNode(el) ? el:document.querySelector(el)
    this.vm = vm
    // 若是這個元素能獲取到 咱們纔開始編譯
    if(this.el) {
      // 1. 先把真實DOM移入到內存中 fragment
      let fragment = this.node2fragment(this.el)
      // 2. 編譯 =》 在fragment中提取想要的元素節點 v-model 和文本節點
      this.compile(fragment)
      // 3. 把編譯好的fragment在放回到頁面中
      this.el.appendChild(fragment)
    }
  }
  // 判斷是不是元素節點
  isElementNode(node) {
    return node.nodeType === 1
  }
  // 是否是指令
  isDirective(name) {
    return name.includes('v-')
  }
  // 將el中的內容所有放到內存中
  node2fragment(el) {
    let fragment = document.createDocumentFragment()
    let firstChild
    // 遍歷取出firstChild,直到firstChild爲空
    while (firstChild = el.firstChild) {
      fragment.appendChild(firstChild)
    }
    return fragment // 內存中的節點
  }
  //編譯 =》 提取想要的元素節點 v-model 和文本節點
  compile(fragment) {
    // 須要遞歸
    let childNodes = fragment.childNodes
    Array.from(childNodes).forEach(node => {
      // 是元素節點 直接調用文本編譯方法 還須要深刻遞歸檢查
      if(this.isElementNode(node)) {
        this.compileElement(node)
        // 遞歸深刻查找子節點
        this.compile(node)
      // 是文本節點 直接調用文本編譯方法
      } else {
        this.compileText(node)
      }
    })
  }
  // 編譯元素方法
  compileElement(node) {
    let attrs = node.attributes
    Array.from(attrs).forEach(attr => {
      let attrName = attr.name
      // 判斷屬性名是否包含 v-指令
      if(this.isDirective(attrName)) {
        // 取到v-指令屬性中的值(這個就是對應data中的key)
        let expr = attr.value
        // 獲取指令類型
        let [,type] = attrName.split('-')
        // node vm.$data expr
        compileUtil[type](node, this.vm, expr)
      }
    })
  }
  // 這裏須要編譯文本
  compileText(node) {
    //取文本節點中的文本
    let expr = node.textContent
    let reg = /\{\{([^}]+)\}\}/g
    if(reg.test(expr)) {
      // node this.vm.$data text
      compileUtil['text'](node, this.vm, expr)
    }
  }
}
// 解析不一樣指令或者文本編譯集合
const compileUtil = {
  text(node, vm, expr) { // 文本
    let updater = this.updater['textUpdate']
    updater && updater(node, getTextValue(vm, expr))
  },
  model(node, vm, expr){ // 輸入框
    let updater = this.updater['modelUpdate']
    updater && updater(node, getValue(vm, expr))
  },
  // 更新函數
  updater: {
    // 文本賦值
    textUpdate(node, value) {
      node.textContent = value
    },
    // 輸入框value賦值
    modelUpdate(node, value) {
      node.value = value
    }
  }
}
// 輔助工具函數
// 綁定key上對應的值,從vm.$data中取到
const getValue = (vm, expr) => {
  expr = expr.split('.') // [message, a, b, c]
  return expr.reduce((prev, next) => {
    return prev[next]
  }, vm.$data)
}
// 獲取文本編譯後的對應的數據
const getTextValue = (vm, expr) => {
  return expr.replace(/\{\{([^}]+)\}\}/g, (...arguments) => {
    return getValue(vm, arguments[1])
  })
}

(3) 將編譯後的fragment放回到dom中工具

let fragment = this.node2fragment(this.el)
  this.compile(fragment)
  // 3. 把編譯好的fragment在放回到頁面中
  this.el.appendChild(fragment)

進行到這一步,頁面上初始化應該渲染完成了。以下圖:

clipboard.png

3.實現observe(數據監聽/劫持)

不一樣於發佈者-訂閱者模式和髒值檢測,vue採用的observe + sub/pub 實現數據的劫持,經過js原生的方法Object.defineProperty()來劫持各個屬性的setter,getter,在屬性對應數據改變時,發佈消息給訂閱者,而後觸發相應的監聽回調。
主要內容:observe的數據對象進行遞歸遍歷,包括子屬性對象的屬性,都加上 setter和getter。

// observe.js
class Observe {
  constructor(data) {
    this.observe(data)
  }
  // 把data數據原有的屬性改爲 get 和 set方法的形式
  observe(data) {
    if(!data || typeof data!== 'object') {
      return
    }
    console.log(data)
    // 將數據一一劫持
    // 先獲取到data的key和value
    Object.keys(data).forEach((key) => {
      // 數據劫持
      this.defineReactive(data, key, data[key])
      this.observe(data[key]) // 深度遞歸劫持,保證子屬性的值也會被劫持
    })
  }
  // 定義響應式
  defineReactive(obj, key, value) {
    let _this = this
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() { // 當取值時調用
        return value
      },
      set(newValue) { //當data屬性中設置新值得時候 更改獲取的新值
        if(newValue !== value) {
          _this.observe(newValue) // 若是是對象繼續劫持
          console.log('監聽到值變化了,舊值:', value, ' --> 新值:', newValue);
          value = newValue
        }
      }
    })
  }
}

完成observe.js後,修改mvvm.js文件,將屬性傳入observe中

// mvvm.js
class MVVM {
  constructor(options) {
    console.log(options)
    this.$el = options.el
    this.$data = options.data
    // 若是有要編譯的模板 =》編譯
    if(this.$el) {
      // 數據劫持 就是把對象的全部屬性改爲 get 和 set方法
      new Observe(this.$data)
      // 將文本+元素模板進行編譯
      new Compile(this.$el, this)
    }
  }
}

能夠在控制檯查看到如下信息,說明劫持屬性成功。

clipboard.png

實現數據劫持後,接下來的任務怎麼通知訂閱者了,咱們須要在監聽數據時實現一個消息訂閱器,具體的方法是:定義一個數組,用來存放訂閱者,數據變更通知(notify)訂閱者,再調用訂閱者的update方法。
在observe.js添加Dep類:

//observe.js

// ...
    let _this = this
    let dep = new Dep()
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,
      get() { // 當取值時調用
        return value
      },
      set(newValue) { //當data屬性中設置新值得時候 更改獲取的新值
        if(newValue !== value) {
          _this.observe(newValue) // 若是是對象繼續劫持
          console.log('監聽到值變化了,舊值:', value, ' --> 新值:', newValue);
          value = newValue
          dep.notify() //通知全部人 數據更新了
        }
      }
    })
// ...
// 消息訂閱器Dep()
class Dep {
  constructor() {
    // 訂閱的數組
    this.subs = []
  }
  addSub(watcher) {
    // push到訂閱數組
    this.subs.push(watcher)
  }
  notify() {
    // 通知訂閱者,並執行訂閱者的update回調
    this.subs.forEach(watcher => watcher.update())
  }
}

實現了消息訂閱器,而且可以執行訂閱者的回調,那麼訂閱者怎麼獲取,並push到訂閱器數組中呢?這個要和watcher結合。

4.實現watcher(訂閱中心)

Observer和Compile之間通訊的橋樑是Watcher訂閱中心,其主要職責是:
一、在自身實例化時往屬性訂閱器(Dep)裏面添加本身,與Observer創建鏈接;
二、自身必須有一個update()方法,與Compile創建鏈接;
三、當屬性變化時,Observer中dep.notice()通知,而後能調用自身(Watcher)的update()方法,並觸發Compile中綁定的回調,實現更新。

// watcher.js
// 訂閱中心(觀察者): 給須要變化的那個元素 增長一個觀察者, 當數據變化後,執行對應的方法
class Watcher {
  constructor(vm, expr, cb) {
    this.vm = vm
    this.expr = expr
    this.cb = cb
    // 先獲取一下老值
    this.value = this.get()
  }
  getValue(vm, expr) { // 獲取實例上對應的數據
    expr = expr.split('.') // [message, a, b, c]
    return expr.reduce((prev, next) => {
      return prev[next]
    }, vm.$data)
  }
  get() { // 獲取文本編譯後的對應的數據
    // 獲取當前訂閱者
    Dep.target = this
    // 觸發getter,當前訂閱者添加訂閱器中 在 劫持數據時,將訂閱者放到訂閱者數組
    let value = this.getValue(this.vm, this.expr)
    // 重置訂閱者
    Dep.target = null
    return value
  }
  // 對外暴露的方法
  update() {
    let newValue = this.getValue(this.vm, this.expr)
    let oldValue = this.value
    // 更新的值 與 之前的值 進行比對, 若是發生變化就更新方法
    if(newValue !== oldValue) {
      this.cb(newValue)
    }
  }
}

// observe.js 
// ... 省略
Object.defineProperty(data, key, {
    get: function() {
        // 在取值時將訂閱者push入訂閱者數組
        Dep.target && dep.addDep(Dep.target);
        return val;
    }
    // ... 省略
});
// ... 省略

上面步驟搭建了watcher與observe之間的鏈接,還須要搭建watcher與之間的鏈接。
咱們須要在compile中解析不一樣指令或者文本編譯集合的時候綁定watcher.

// compile.js
// ...省略
 model(node, vm, expr){ // 輸入框
    let updater = this.updater['modelUpdate']
    // 這裏加一個監控 數據變化了 應該調用這個watcher的callback
    new Watcher(vm, expr, (newValue) => {
      // 當值變化後 會調用cb ,將新值傳遞過來
      updater && updater(node, this.getValue(vm, expr))
    })
    node.addEventListener('input', (e) => {
      let newValue = e.target.value
      this.setVal(vm, expr, newValue)
    })
    updater && updater(node, this.getValue(vm, expr))
 },
// ...省略

此時,在瀏覽器控制檯執行下圖操做,手動改變 message 屬性的值,發現輸入框的值也隨之變化,v-model 綁定完成。

clipboard.png

相關文章
相關標籤/搜索