MVVM 框架解析之雙向綁定

MVVM 框架

近年來前端一個明顯的開發趨勢就是架構從傳統的 MVC 模式向 MVVM 模式遷移。在傳統的 MVC 下,當前前端和後端發生數據交互後會刷新整個頁面,從而致使比較差的用戶體驗。所以咱們經過 Ajax 的方式和網關 REST API 做通信,異步的刷新頁面的某個區塊,來優化和提高體驗。css

MVVM 框架基本概念

在 MVVM 框架中,View(視圖) 和 Model(數據) 是不能夠直接通信的,在它們之間存在着 ViewModel 這個中間介充當着觀察者的角色。當用戶操做 View(視圖),ViewModel 感知到變化,而後通知 Model 發生相應改變;反之當 Model(數據) 發生改變,ViewModel 也能感知到變化,使 View 做出相應更新。這個一來一回的過程就是咱們所熟知的雙向綁定。html

MVVM 框架的應用場景

MVVM 框架的好處顯而易見:當前端對數據進行操做的時候,能夠經過 Ajax 請求對數據持久化,只需改變 dom 裏須要改變的那部分數據內容,而沒必要刷新整個頁面。特別是在移動端,刷新頁面的代價太昂貴。雖然有些資源會被緩存,可是頁面的 dom、css、js 都會被瀏覽器從新解析一遍,所以移動端頁面一般會被作成 SPA 單頁應用。由此在這基礎上誕生了不少 MVVM 框架,好比 React.js、Vue.js、Angular.js 等等。前端

MVVM 框架的簡單實現

模擬 Vue 的雙向綁定流,實現了一個簡單的 MVVM 框架,從上圖中能夠看出虛線方形中就是以前提到的 ViewModel 中間介層,它充當着觀察者的角色。另外能夠發現雙向綁定流中的 View 到 Model 實際上是經過 input 的事件監聽函數實現的,若是換成 React(單向綁定流) 的話,它在這一步交給狀態管理工具(好比 Redux)來實現。另外雙向綁定流中的 Model 到 View 其實各個 MVVM 框架實現的都是大同小異的,都用到的核心方法是 Object.defineProperty(),經過這個方法能夠進行數據劫持,當數據發生變化時能夠捕捉到相應變化,從而進行後續的處理。node

Mvvm(入口文件) 的實現

通常會這樣調用 Mvvm 框架git

const vm = new Mvvm({
            el: '#app',
            data: {
              title: 'mvvm title',
              name: 'mvvm name'
            },
          })

可是這樣子的話,若是要獲得 title 屬性就要形如 vm.data.title 這樣取得,爲了讓 vm.title 就能得到 title 屬性,從而在 Mvvm 的 prototype 上加上一個代理方法,代碼以下:github

function Mvvm (options) {
  this.data = options.data

  const self = this
  Object.keys(this.data).forEach(key =>
    self.proxyKeys(key)
  )
}

Mvvm.prototype = {
  proxyKeys: function(key) {
    const self = this
    Object.defineProperty(this, key, {
      get: function () { // 這裏的 get 和 set 實現了 vm.data.title 和 vm.title 的值同步
        return self.data[key]
      },
      set: function (newValue) {
        self.data[key] = newValue
      }
    })
  }
}

實現了代理方法後,就步入主流程的實現後端

function Mvvm (options) {
  this.data = options.data
  // ...
  observe(this.data)
  new Compile(options.el, this)
}

observer(觀察者) 的實現

observer 的職責是監聽 Model(JS 對象) 的變化,最核心的部分就是用到了 Object.defineProperty() 的 get 和 set 方法,當要獲取 Model(JS 對象) 的值時,會自動調用 get 方法;當改動了 Model(JS 對象) 的值時,會自動調用 set 方法;從而實現了對數據的劫持,代碼以下所示。數組

let data = {
  number: 0
}

observe(data)

data.number = 1 // 值發生變化

function observe(data) {
  if (!data || typeof(data) !== 'object') {
    return
  }
  const self = this
  Object.keys(data).forEach(key =>
    self.defineReactive(data, key, data[key])
  )
}

function defineReactive(data, key, value) {
  observe(value) // 遍歷嵌套對象
  Object.defineProperty(data, key, {
    get: function() {
      return value
    },
    set: function(newValue) {
      if (value !== newValue) {
        console.log('值發生變化', 'newValue:' + newValue + ' ' + 'oldValue:' + value)
        value = newValue
      }
    }
  })
}

運行代碼,能夠看到控制檯輸出 值發生變化 newValue:1 oldValue:0,至此就完成了 observer 的邏輯。瀏覽器

Dep(訂閱者數組) 和 watcher(訂閱者) 的關係

觀測到變化後,咱們總要通知給特定的人羣,讓他們作出相應的處理吧。爲了更方便地理解,咱們能夠把訂閱當成是訂閱了一個微信公衆號,當微信公衆號的內容有更新時,那麼它會把內容推送(update) 到訂閱了它的人。緩存

那麼訂閱了同個微信公衆號的人有成千上萬個,那麼首先想到的就是要 new Array() 去存放這些人(html 節點)吧。因而就有了以下代碼:

// observer.js
function Dep() {
  this.subs = [] // 存放訂閱者
}

Dep.prototype = {
  addSub: function(sub) { // 添加訂閱者
    this.subs.push(sub)
  },
  notify: function() { // 通知訂閱者更新
    this.subs.forEach(function(sub) {
      sub.update()
    })
  }
}

function observe(data) {...}

function defineReactive(data, key, value) {
  var dep = new Dep()
  observe(value) // 遍歷嵌套對象
  Object.defineProperty(data, key, {
    get: function() {
      if (Dep.target) { // 往訂閱器添加訂閱者
        dep.addSub(Dep.target)
      }
      return value
    },
    set: function(newValue) {
      if (value !== newValue) {
        console.log('值發生變化', 'newValue:' + newValue + ' ' + 'oldValue:' + value)
        value = newValue
        dep.notify()
      }
    }
  })
}

初看代碼也比較順暢了,但可能會卡在 Dep.targetsub.update,由此天然而然地將目光移向 watcher,

// watcher.js
function Watcher(vm, exp, cb) {
  this.vm = vm
  this.exp = exp
  this.cb = cb
  this.value = this.get()
}

Watcher.prototype = {
  update: function() {
    this.run()
  },

  run: function() {
    // ...
    if (value !== oldVal) {
      this.cb.call(this.vm, value) // 觸發 compile 中的回調
    }
  },

  get: function() {
    Dep.target = this // 緩存本身
    const value = this.vm.data[this.exp] // 強制執行監聽器裏的 get 函數
    Dep.target = null // 釋放本身
    return value
  }
}

從代碼中能夠看到當構造 Watcher 實例時,會調用 get() 方法,接着重點關注 const value = this.vm.data[this.exp] 這句,前面說了當要獲取 Model(JS 對象) 的值時,會自動調用 Object.defineProperty 的 get 方法,也就是當執行完這句的時候,Dep.target 的值傳進了 observer.js 中的 Object.defineProperty 的 get 方法中。同時也一目瞭然地在 Watcher.prototype 中發現了 update 方法,其做用即觸發 compile 中綁定的回調來更新界面。至此解釋了 Observer 中 Dep.target 和 sub.update 的由來。

來概括下 Watcher 的做用,其充當了 observer 和 compile 的橋樑。

1 在自身實例化的過程當中,往訂閱器(dep) 中添加本身

2 當 model 發生變更,dep.notify() 通知時,其能調用自身的 update 函數,並觸發 compile 綁定的回調函數實現視圖更新

最後再來看下生成 Watcher 實例的 compile.js 文件。

compile(編譯) 的實現

首先遍歷解析的過程有屢次操做 dom 節點,爲提升性能和效率,會先將跟節點 el 轉換成 fragment(文檔碎片) 進行解析編譯,解析完成,再將 fragment 添加回原來的真實 dom 節點中。代碼以下:

function Compile(el, vm) {
  this.vm = vm
  this.el = document.querySelector(el)
  this.fragment = null
  this.init()
}

Compile.prototype = {
  init: function() {
    if (this.el) {
      this.fragment = this.nodeToFragment(this.el) // 將節點轉爲 fragment 文檔碎片
      this.compileElement(this.fragment) // 對 fragment 進行編譯解析
      this.el.appendChild(this.fragment)
    }
  },
  nodeToFragment: function(el) {
    const fragment = document.createDocumentFragment()
    let child = el.firstChild // △ 第一個 firstChild 是 text
    while(child) {
      fragment.appendChild(child)
      child = el.firstChild
    }
    return fragment
  },
  compileElement: function(el) {...},
}

這個簡單的 mvvm 框架在對 fragment 編譯解析的過程當中對 {{}} 文本元素v-on:click 事件指令v-model 指令三種類型進行了相應的處理。

Compile.prototype = {
  init: function() {
    if (this.el) {
      this.fragment = this.nodeToFragment(this.el) // 將節點轉爲 fragment 文檔碎片
      this.compileElement(this.fragment) // 對 fragment 進行編譯解析
      this.el.appendChild(this.fragment)
    }
  },
  nodeToFragment: function(el) {...},
  compileElement: function(el) {...},
  compileText: function (node, exp) { // 對文本類型進行處理,將 {{abc}} 替換掉
    const self = this
    const initText = this.vm[exp]
    this.updateText(node, initText) // 初始化
    new Watcher(this.vm, exp, function(value) { // 實例化訂閱者
      self.updateText(node, value)
    })
  },

  compileEvent: function (node, vm, exp, dir) { // 對事件指令進行處理
    const eventType = dir.split(':')[1]
    const cb = vm.methods && vm.methods[exp]

    if (eventType && cb) {
      node.addEventListener(eventType, cb.bind(vm), false)
    }
  },

  compileModel: function (node, vm, exp) { // 對 v-model 進行處理
    let val = vm[exp]
    const self = this
    this.modelUpdater(node, val)
    node.addEventListener('input', function (e) {
      const newValue = e.target.value
      self.vm[exp] = newValue // 實現 view 到 model 的綁定
    })
  },
}

在上述代碼的 compileTest 函數中看到了期盼已久的 Watcher 實例化,對 Watcher 做用模糊的朋友能夠往上回顧下 Watcher 的做用。另外在 compileModel 函數中看到了本文最開始提到的雙向綁定流中的 View 到 Model 是藉助 input 監聽事件變化實現的。

項目地址

本文記錄了些閱讀 mvvm 框架源碼關於雙向綁定的心得,並動手實踐了一個簡版的 mvvm 框架,不足之處在所不免,歡迎指正。

項目演示

項目地址

相關文章
相關標籤/搜索