1. 程式人生 > >MVVM 框架解析之雙向綁定

MVVM 框架解析之雙向綁定

dom server 部分 框架 後端 多次 行數據 控制 inf

技術分享圖片

MVVM 框架

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

MVVM 框架基本概念

技術分享圖片

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

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(),通過這個方法可以進行數據劫持,當數據發生變化時可以捕捉到相應變化,從而進行後續的處理。

技術分享圖片

Mvvm(入口文件) 的實現

一般會這樣調用 Mvvm 框架

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

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

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.target 和 sub.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 框架,不足之處在所難免,歡迎指正。

項目演示

項目地址

http://muyunyun.cn/posts/384a97b3/

MVVM 框架解析之雙向綁定