基於 Vue 實現一個簡易 MVVM

關注 前端瓶子君,回覆「交流javascript

加入咱們一塊兒學習,每天進步html

做者:子奕,原文連接:https://juejin.im/post/5cd8a7c1f265da037a3d0992前端

若是你對於MVVM的造成不是特別清晰,則能夠先閱讀如下部分。vue

  • 瞭解MV*的演變歷史以及特性(可跳過)
  • 瞭解觀察者模式(可跳過)

本文能夠幫助你瞭解什麼?java

  • 瞭解Vue的運行機制
  • 參考Vue的運行機制,將觀察者模式改用中介者模式實現一個簡易的MVVM
    • MVVM的實現演示
    • MVVM的流程設計
    • 中介者模式的實現
    • 數據劫持的實現
    • 數據雙向綁定的實現
    • 簡易視圖指令的編譯過程的實現
    • ViewModel的實現
    • MVVM的實現

MV*設計模式的演變歷史

咱們先來花點時間想一想,若是你是一個前端框架(Vue、React或者Angular)的開發者,你是有多麼頻繁的聽到「MVVM」這個詞,但你真正明白它的含義嗎?node

MV*設計模式的起源

起初「計算機科學家(如今的咱們是小菜雞)「在設計GUI(圖形用戶界面)應用程序的時候,代碼是雜亂無章的,一般難以管理和維護。GUI的設計結構通常包括」視圖」(View)、「模型」(Model)、「邏輯」(Application Logic、Business Logic以及Sync Logic),例如:git

  • 用戶在 「視圖」(View)上的鍵盤、鼠標等行爲執行 「應用邏輯」(Application Logic), 「應用邏輯」會觸發 「業務邏輯」(Business Logic),從而變動 「模型」(Model)
  • 「模型」(Model)變動後須要 「同步邏輯」(Sync Logic)將變化反饋到 「視圖」(View)上供用戶感知

能夠發如今GUI中「視圖」「模型」是自然能夠進行分層的,雜亂無章的部分主要是「邏輯」。因而咱們的程序員們不斷的絞盡腦汁在想辦法優化GUI設計的「邏輯」,而後就出現了MVC、MVP以及MVVM等設計模式。程序員

MV*設計模式在B/S架構中的思考

在B/S架構的應用開發中,MV*設計模式概述並封裝了應用程序及其環境中須要關注的地方,儘管JavaScript已經變成一門同構語言,可是在瀏覽器和服務器之間這些關注點可能不同:github

  • 視圖可否跨案例或場景使用?
  • 業務邏輯應該放在哪裏處理?(在 「Model」中仍是 「Controller」中)
  • 應用的狀態應該如何持久化和訪問?

MVC(Model-View-Controller)

早在上個世紀70年代,美國的施樂公司(Xerox)的工程師研發了Smalltalk編程語言,而且開始用它編寫GUI。而在Smalltalk-80版本的時候,一位叫Trygve Reenskaug的工程師設計了MVC的架構模式,極大地下降了GUI的管理難度。web

如圖所示,MVC把GUI分紅「View」(視圖)、「Model」(模型)、「Controller」(控制 器)(可熱插拔,主要進行「Model」「View」之間的協做,包括路由、輸入預處理等業務邏輯)三個模塊:

  • 「View」:檢測用戶的鍵盤、鼠標等行爲,傳遞調用 「Controller」執行應用邏輯。 「View」更新須要從新獲取 「Model」的數據。
  • 「Controller」「View」「Model」之間協做的應用邏輯或業務邏輯處理。
  • 「Model」「Model」變動後,經過觀察者模式通知 「View」更新視圖。

「Model」的更新經過觀察者模式,能夠實現多視圖共享同一個「Model」

傳統的MVC設計對於Web前端開發而言是一種十分有利的模式,由於「View」是持續性的,而且「View」能夠對應不一樣的「Model」。Backbone.js就是一種稍微變種的MVC模式實現(和經典MVC較大的區別在於「View」能夠直接操做「Model」,所以這個模式不能同構)。這裏總結一下MVC設計模式可能帶來的好處以及不夠完美的地方:

優勢:

  • 職責分離:模塊化程度高、 「Controller」可替換、可複用性、可擴展性強。
  • 多視圖更新:使用觀察者模式能夠作到單 「Model」通知多視圖實現數據更新。

缺點:

  • 測試困難: 「View」須要UI環境,所以依賴 「View」「Controller」測試相對比較困難(如今Web前端的不少測試框架都已經解決了該問題)。
  • 依賴強烈: 「View」強依賴 「Model」(特定業務場景),所以 「View」沒法組件化設計。

####服務端MVC

經典MVC只用於解決GUI問題,可是隨着B/S架構的不斷髮展,Web服務端也衍生出了MVC設計模式。

JSP Model1和JSP Model2的演變過程

JSP Model1是早期的Java動態Web應用技術,它的結構以下所示:

在Model1中,「JSP」同時包含了「Controller」「View」,而「JavaBean」包含了「Controller」「Model」,模塊的職責相對混亂。在JSP Model1的基礎上,Govind Seshadri借鑑了MVC設計模式提出了JSP Model2模式(具體可查看文章Understanding JavaServer Pages Model 2 architecture),它的結構以下所示:

在JSP Model2中,「Controller」「View」「Model」分工明確,「Model」的數據變動,一般經過「JavaBean」修改「View」而後進行前端實時渲染,這樣從Web前端發起請求到數據回顯路線很是明確。不過這裏專門詢問了相應的後端開發人員,也可能經過「JavaBean」「Controller」「Controller」主要識別當前數據對應的JSP)再到「JSP」,所以在服務端MVC中,也可能產生這樣的流程「View」 -> 「Controller」 -> 「Model」 -> 「Controller」 -> 「View」

在JSP Model2模式中,沒有作到先後端分離,前端的開發大大受到了限制。

Model2的衍生

對於Web前端開發而言,最直觀的感覺就是在Node服務中衍生Model2模式(例如結合Express以及EJS模板引擎等)。

服務端MVC和經典MVC的區別

在服務端的MVC模式設計中採用了HTTP協議通訊(HTTP是單工無狀態協議),所以「View」在不一樣的請求中都不保持狀態(狀態的保持須要額外經過Cookie存儲),而且經典MVC中「Model」經過觀察者模式告知「View」的環節被破壞(例如難以實現服務端推送)。固然在經典MVC中,「Controller」須要監聽「View」並對輸入作出反應,邏輯會變得很繁重,而在Model2中, 「Controller」只關注路由處理等,而「Model」則更多的處理業務邏輯。

MVP(Model-View-Presenter)

在上個世紀90年代,IBM旗下的子公司Taligent在用C/C++開發一個叫CommonPoint的圖形界面應用系統的時候提出了MVP的概念。

如上圖所示,MVP是MVC的模式的一種改良,打破了「View」對於「Model」的依賴,其他的依賴關係和MVC保持不變。

  • 「Passive View」「View」再也不處理同步邏輯,對 「Presenter」提供接口調用。因爲再也不依賴 「Model」,可讓 「View」從特定的業務場景中抽離,徹底能夠作到組件化。
  • 「Presenter」「Supervising Controller」):和經典MVC的 「Controller」相比,任務更加繁重,不只要處理應用業務邏輯,還要處理同步邏輯(高層次複雜的UI操做)。
  • 「Model」「Model」變動後,經過觀察者模式通知 「Presenter」,若是有視圖更新, 「Presenter」又可能調用 「View」的接口更新視圖。

MVP模式可能產生的優缺點以下:

  • 「Presenter」便於測試、 「View」可組件化設計
  • 「Presenter」厚、維護困難

MVVM(Model-View-ViewModel)

如上圖所示:MVVM模式是在MVP模式的基礎上進行了改良,將「Presenter」改良成「ViewModel」(抽象視圖):

  • 「ViewModel」:內部集成了 「Binder」(Data-binding Engine,數據綁定引擎),在MVP中派發器 「View」「Model」的更新都須要經過 「Presenter」手動設置,而 「Binder」則會實現 「View」「Model」的雙向綁定,從而實現 「View」「Model」的自動更新。
  • 「View」:可組件化,例如目前各類流行的UI組件框架, 「View」的變化會經過 「Binder」自動更新相應的 「Model」
  • 「Model」「Model」的變化會被 「Binder」監聽(仍然是經過觀察者模式),一旦監聽到變化, 「Binder」就會自動實現視圖的更新。

能夠發現,MVVM在MVP的基礎上帶來了大量的好處,例如:

  • 提高了可維護性,解決了MVP大量的手動同步的問題,提供雙向綁定機制。
  • 簡化了測試,同步邏輯是交由 「Binder」處理, 「View」跟着 「Model」同時變動,因此只須要保證 「Model」的正確性, 「View」就正確。

固然也帶來了一些額外的問題:

  • 產生性能問題,對於簡單的應用會形成額外的性能消耗。
  • 對於複雜的應用,視圖狀態較多,視圖狀態的維護成本增長, 「ViewModel」構建和維護成本高。

對前端開發而言MVVM是很是好的一種設計模式。在瀏覽器中,路由層能夠將控制權交由適當的「ViewModel」,後者又能夠更新並響應持續的View,而且經過一些小修改MVVM模式能夠很好的運行在服務器端,其中的緣由就在於「Model」「View」已經徹底沒有了依賴關係(經過View與Model的去耦合,能夠容許短暫「View」與持續「View」的並存),這容許「View」經由給定的「ViewModel」進行渲染。

目前流行的框架Vue、React以及Angular都是MVVM設計模式的一種實現,而且均可以實現服務端渲染。須要注意目前的Web前端開發和傳統Model2須要模板引擎渲染的方式不一樣,經過Node啓動服務進行頁面渲染,而且經過代理的方式轉發請求後端數據,徹底能夠從後端的苦海中脫離,這樣一來也能夠大大的解放Web前端的生產力。

觀察者模式和發佈/訂閱模式

觀察者模式

觀察者模式是使用一個subject目標對象維持一系列依賴於它的observer觀察者對象,將有關狀態的任何變動自動通知給這一系列觀察者對象。當subject目標對象須要告訴觀察者發生了什麼事情時,它會向觀察者對象們廣播一個通知。

如上圖所示:一個或多個觀察者對目標對象的狀態感興趣時,能夠將本身依附在目標對象上以便註冊感興趣的目標對象的狀態變化,目標對象的狀態發生改變就會發送一個通知消息,調用每一個觀察者的更新方法。若是觀察者對目標對象的狀態不感興趣,也能夠將本身從中分離。

發佈/訂閱模式

發佈/訂閱模式使用一個事件通道,這個通道介於訂閱者和發佈者之間,該設計模式容許代碼定義應用程序的特定事件,這些事件能夠傳遞自定義參數,自定義參數包含訂閱者須要的信息,採用事件通道能夠避免發佈者和訂閱者之間產生依賴關係。

學生時期很長一段時間內用過Redis的發佈/訂閱機制,具體可查看zigbee-door/zigbee-tcp,可是慚愧的是沒有好好閱讀過這一塊的源碼。

二者的區別

觀察者模式:容許觀察者實例對象(訂閱者)執行適當的事件處理程序來註冊和接收目標實例對象(發佈者)發出的通知(即在觀察者實例對象上註冊update方法),使訂閱者和發佈者之間產生了依賴關係,且沒有事件通道。不存在封裝約束的單一對象,目標對象和觀察者對象必須合做才能維持約束。觀察者對象向訂閱它們的對象發佈其感興趣的事件。通訊只能是單向的。

發佈/訂閱模式:單一目標一般有不少觀察者,有時一個目標的觀察者是另外一個觀察者的目標。通訊能夠實現雙向。該模式存在不穩定性,發佈者沒法感知訂閱者的狀態。

Vue的運行機制簡述

這裏簡單的描述一下Vue的運行機制(須要注意分析的是 Runtime + Compiler 的 Vue.js)。

初始化流程

  • 建立Vue實例對象
  • init過程會初始化生命週期,初始化事件中心,初始化渲染、執行 beforeCreate周期函數、初始化 datapropscomputedwatcher、執行 created周期函數等。
  • 初始化後,調用 $mount方法對Vue實例進行掛載(掛載的核心過程包括 「模板編譯」「渲染」以及 「更新」三個過程)。
  • 若是沒有在Vue實例上定義 render方法而是定義了 template,那麼須要經歷編譯階段。須要先將 template 字符串編譯成 render functiontemplate 字符串編譯步驟以下 :
    • parse正則解析 template字符串造成AST(抽象語法樹,是源代碼的抽象語法結構的樹狀表現形式)
    • optimize標記靜態節點跳過diff算法(diff算法是逐層進行比對,只有同層級的節點進行比對,所以時間的複雜度只有O(n)。若是對於時間複雜度不是很清晰的,能夠查看我寫的文章ziyi2/algorithms-javascript/漸進記號)
    • generate將AST轉化成 render function字符串
  • 編譯成 render function 後,調用 $mountmountComponent方法,先執行 beforeMount鉤子函數,而後核心是實例化一個渲染 Watcher,在它的回調函數(初始化的時候執行,以及組件實例中監測到數據發生變化時執行)中調用 updateComponent方法(此方法調用 render方法生成虛擬Node,最終調用 update方法更新DOM)。
  • 調用 render方法將 render function渲染成虛擬的Node(真正的 DOM 元素是很是龐大的,由於瀏覽器的標準就把 DOM 設計的很是複雜。若是頻繁的去作 DOM 更新,會產生必定的性能問題,而 Virtual DOM 就是用一個原生的 JavaScript 對象去描述一個 DOM 節點,因此它比建立一個 DOM 的代價要小不少,並且修改屬性也很輕鬆,還能夠作到跨平臺兼容), render方法的第一個參數是 createElement(或者說是 h函數),這個在官方文檔也有說明。
  • 生成虛擬DOM樹後,須要將虛擬DOM樹轉化成真實的DOM節點,此時須要調用 update方法, update方法又會調用 pacth方法把虛擬DOM轉換成真正的DOM節點。須要注意在圖中忽略了新建真實DOM的狀況(若是沒有舊的虛擬Node,那麼能夠直接經過 createElm建立真實DOM節點),這裏重點分析在已有虛擬Node的狀況下,會經過 sameVnode判斷當前須要更新的Node節點是否和舊的Node節點相同(例如咱們設置的 key屬性發生了變化,那麼節點顯然不一樣),若是節點不一樣那麼將舊節點採用新節點替換便可,若是相同且存在子節點,須要調用 patchVNode方法執行diff算法更新DOM,從而提高DOM操做的性能。

須要注意在初始化階段,沒有詳細描述數據的響應式過程,這個在響應式流程裏作說明。

響應式流程

  • init的時候會利用 Object.defineProperty方法(不兼容IE8)監聽Vue實例的響應式數據的變化從而實現數據劫持能力(利用了JavaScript對象的訪問器屬性 getset,在將來的Vue3中會使用ES6的 Proxy來優化響應式原理)。在初始化流程中的編譯階段,當 render function被渲染的時候,會讀取Vue實例中和視圖相關的響應式數據,此時會觸發 getter函數進行 「依賴收集」(將觀察者 Watcher對象存放到當前閉包的訂閱者 Depsubs中),此時的數據劫持功能和觀察者模式就實現了一個MVVM模式中的 「Binder」,以後就是正常的渲染和更新流程。
  • 當數據發生變化或者視圖致使的數據發生了變化時,會觸發數據劫持的 setter函數, setter會通知初始化 「依賴收集」中的 Dep中的和視圖相應的 Watcher,告知須要從新渲染視圖, Wather就會再次經過 update方法來更新視圖。

能夠發現只要視圖中添加監聽事件,自動變動對應的數據變化時,就能夠實現數據和視圖的雙向綁定了。



瞭解了MV*設計模式、觀察者模式以及Vue運行機制以後,可能對於整個MVVM模式有了一個感性的認知,所以能夠來手動實現一下,這裏實現過程包括以下幾個步驟:

  • MVVM的實現演示
  • MVVM的流程設計
  • 中介者模式的實現
  • 數據劫持的實現
  • 數據雙向綁定的實現
  • 簡易視圖指令的編譯過程的實現
  • ViewModel的實現
  • MVVM的實現

MVVM的實現演示

MVVM示例的使用以下所示,包括browser.js(View視圖的更新)、mediator.js(中介者)、binder.js(MVVM的數據綁定引擎)、view.js(視圖)、hijack.js(數據劫持)以及mvvm.js(MVVM實例)。本示例相關的代碼可查看github的ziyi2/mvvm:

<div id="app">
 <input type="text" b-value="input.message" b-on-input="handlerInput">
 <div>{{ input.message }}</div>
 <div b-text="text"></div>
 <div>{{ text }}</div>
 <div b-html="htmlMessage"></div>
</div>


<script src="./browser.js"></script>
<script src="./mediator.js"></script>
<script src="./binder.js"></script>
<script src="./view.js"></script>
<script src="./hijack.js"></script>
<script src="./mvvm.js"></script>


<script>
 let vm = new Mvvm({
    el'#app',
    data: {
      input: {
        message'Hello Input!'
      },
      text'ziyi2',
      htmlMessage`<button>提交</button>`
    },
    methods: {
      handlerInput(e) {
        this.text = e.target.value
      }
    }
  })
</script>

MVVM的流程設計

這裏簡單的描述一下MVVM實現的運行機制。

初始化流程

  • 建立MVVM實例對象,初始化實例對象的 options參數
  • proxyData將MVVM實例對象的 data數據代理到MVVM實例對象上
  • Hijack類實現數據劫持功能(對MVVM實例跟視圖對應的響應式數據進行監聽,這裏和Vue運行機制不一樣,幹掉了 getter依賴蒐集功能)
  • 解析視圖指令,對MVVM實例與視圖關聯的DOM元素轉化成文檔碎片並進行綁定指令解析( b-valueb-on-inputb-html等,實際上是Vue編譯的超級簡化版),
  • 添加數據訂閱和用戶監聽事件,將視圖指令對應的數據掛載到 「Binder」數據綁定引擎上(數據變化時經過Pub/Sub模式通知 「Binder」綁定器更新視圖)
  • 使用Pub/Sub模式代替Vue中的Observer模式
  • 「Binder」採用了命令模式解析視圖指令,調用 update方法對View解析綁定指令後的文檔碎片進行更新視圖處理
  • Browser採用了外觀模式對瀏覽器進行了簡單的兼容性處理

響應式流程

  • 監聽用戶輸入事件,對用戶的輸入事件進行監聽
  • 調用MVVM實例對象的數據設置方法更新數據
  • 數據劫持觸發 setter方法
  • 經過發佈機制發佈數據變化
  • 訂閱器接收數據變動通知,更新數據對應的視圖

中介者模式的實現

最簡單的中介者模式只須要實現發佈、訂閱和取消訂閱的功能。發佈和訂閱之間經過事件通道(channels)進行信息傳遞,能夠避免觀察者模式中產生依賴的狀況。中介者模式的代碼以下:

class Mediator {
  constructor() {
    this.channels = {}
    this.uid = 0
  }

  /** 
   * @Desc:   訂閱頻道
   * @Parm:   {String} channel 頻道
   *          {Function} cb 回調函數 
   */
  
  sub(channel, cb) {
    let { channels } = this
    if(!channels[channel]) channels[channel] = []
    this.uid ++ 
    channels[channel].push({
      contextthis,
      uidthis.uid,
      cb
    })
    console.info('[mediator][sub] -> this.channels: 'this.channels)
    return this.uid
  }

  /** 
   * @Desc:   發佈頻道 
   * @Parm:   {String} channel 頻道
   *          {Any} data 數據 
   */
  
  pub(channel, data) {
    console.info('[mediator][pub] -> chanel: ', channel)
    let ch = this.channels[channel]
    if(!ch) return false
    let len = ch.length
    // 後訂閱先觸發
    while(len --) {
      ch[len].cb.call(ch[len].context, data)
    }
    return this
  }

  /** 
   * @Desc:   取消訂閱  
   * @Parm:   {String} uid 訂閱標識 
   */
  
  cancel(uid) {
    let { channels } = this
    for(let channel of Object.keys(channels)) {
      let ch = channels[channel]
      if(ch.length === 1 && ch[0].uid === uid) {
        delete channels[channel]
        console.info('[mediator][cancel][delete] -> chanel: ', channel)
        console.info('[mediator][cancel] -> chanels: ', channels)
        return
      }
      for(let i=0,len=ch.length; i<len; i++) {
          if(ch[i].uid === uid) {
            ch.splice(i,1)
            console.info('[mediator][cancel][splice] -> chanel: ', channel)
            console.info('[mediator][cancel] -> chanels: ', channels)
            return
          }
      }
    }
  }
}

在每個MVVM實例中,都須要實例化一箇中介者實例對象,中介者實例對象的使用方法以下:

let mediator = new Mediator()
// 訂閱channel1
let channel1First = mediator.sub('channel1', (data) => {
  console.info('[mediator][channel1First][callback] -> data', data)
})
// 再次訂閱channel1
let channel1Second = mediator.sub('channel1', (data) => {
  console.info('[mediator][channel1Second][callback] -> data', data)
})
// 訂閱channel2
let channel2 = mediator.sub('channel2', (data) => {
  console.info('[mediator][channel2][callback] -> data', data)
})
// 發佈(廣播)channel1,此時訂閱channel1的兩個回調函數會連續執行
mediator.pub('channel1', { name'ziyi1' })
// 發佈(廣播)channel2,此時訂閱channel2的回調函數執行
mediator.pub('channel2', { name'ziyi2' })
// 取消channel1標識爲channel1Second的訂閱
mediator.cancel(channel1Second)
// 此時只會執行channel1中標識爲channel1First的回調函數
mediator.pub('channel1', { name'ziyi1' })

數據劫持的實現

對象的屬性

對象的屬性可分爲數據屬性(特性包括[[Value]][[Writable]][[Enumerable]][[Configurable]])和存儲器/訪問器屬性(特性包括[[ Get ]][[ Set ]][[Enumerable]][[Configurable]]),對象的屬性只能是數據屬性或訪問器屬性的其中一種,這些屬性的含義:

  • [[Configurable]]: 表示可否經過 delete 刪除屬性從而從新定義屬性,可否修改屬性的特性,或者可否把屬性修改成訪問器屬性。
  • [[Enumerable]]:  對象屬性的可枚舉性。
  • [[Value]]: 屬性的值,讀取屬性值的時候,從這個位置讀;寫入屬性值的時候,把新值保存在這個位置。這個特性的默認值爲 undefined
  • [[Writable]]: 表示可否修改屬性的值。
  • [[ Get ]]: 在讀取屬性時調用的函數。默認值爲 undefined
  • [[ Set ]]: 在寫入屬性時調用的函數。默認值爲 undefined

數據劫持就是使用了[[ Get ]][[ Set ]]的特性,在訪問對象的屬性和寫入對象的屬性時可以自動觸發屬性特性的調用函數,從而作到監聽數據變化的目的。

對象的屬性能夠經過ES5的設置特性方法Object.defineProperty(data, key, descriptor)改變屬性的特性,其中descriptor傳入的就是以上所描述的特性集合。

數據劫持

let hijack = (data) => {
  if(typeof data !== 'object'return
  for(let key of Object.keys(data)) {
    let val = data[key]
    Object.defineProperty(data, key, {
      enumerabletrue,
      configurablefalse,
      get() {
        console.info('[hijack][get] -> val: ', val)
        // 和執行 return data[key] 有什麼區別 ?
        return val
      },
      set(newVal) {
        if(newVal === val) return
        console.info('[hijack][set] -> newVal: ', newVal)
        val = newVal
        // 若是新值是object, 則對其屬性劫持
        hijack(newVal)
      }
    })
  }
}

let person = { name'ziyi2'age1 }
hijack(person)
// [hijack][get] -> val:  ziyi2
person.name
// [hijack][get] -> val:  1
person.age
// [hijack][set] -> newVal:  ziyi
person.name = 'ziyi'

// 屬性類型變化劫持
// [hijack][get] -> val:  { familyName:"ziyi2", givenName:"xiankang" }
person.name = { familyName'zhu',  givenName'xiankang' }
// [hijack][get] -> val:  ziyi2
person.name.familyName = 'ziyi2'

// 數據屬性
let job = { type'javascript' }
console.info(Object.getOwnPropertyDescriptor(job, "type"))
// 訪問器屬性
console.info(Object.getOwnPropertyDescriptor(person, "name"))

注意Vue3.0將不產用Object.defineProperty方式進行數據監聽,緣由在於

  • 沒法監聽數組的變化(目前的數組監聽都基於對原生數組的一些方法進行 hack,因此若是要使數組響應化,須要注意使用Vue官方推薦的一些數組方法)
  • 沒法深層次監聽對象屬性

在Vue3.0中將產用Proxy解決以上痛點問題,固然會產生瀏覽器兼容性問題(例如萬惡的IE,具體可查看Can I use proxy)。

須要注意是的在hijack中只進行了一層屬性的遍歷,若是要作到對象深層次屬性的監聽,須要繼續對data[key]進行hijack操做,從而能夠達到屬性的深層次遍歷監聽,具體可查看mvvm/mvvm/hijack.js,

數據雙向綁定的實現

如上圖所示,數據雙向綁定主要包括數據的變化引發視圖的變化(「Model」 -> 監聽數據變化 -> 「View」)、視圖的變化又改變數據(「View」 -> 用戶輸入監聽事件 -> 「Model」),從而實現數據和視圖之間的強聯繫。

在實現了數據監聽的基礎上,加上用戶輸入事件以及視圖更新,就能夠簡單實現數據的雙向綁定(其實就是一個最簡單的「Binder」,只是這裏的代碼耦合嚴重):

<input id="input" type="text">
<div id="div"></div>
// 監聽數據變化
function hijack(data{
  if(typeof data !== 'object'return
  for(let key of Object.keys(data)) {
    let val = data[key]
    Object.defineProperty(data, key, {
      enumerabletrue,
      configurablefalse,
      get() {
        console.log('[hijack][get] -> val: ', val)
        // 和執行 return data[key] 有什麼區別 ?
        return val
      },
      set(newVal) {
        if(newVal === val) return
        console.log('[hijack][set] -> newVal: ', newVal)
        val = newVal
        
        // 更新全部和data.input數據相關聯的視圖
        input.value = newVal
        div.innerHTML = newVal

        // 若是新值是object, 則對其屬性劫持
        hijack(newVal)
      }
    })
  }
}

let input = document.getElementById('input')
let div = document.getElementById('div')

// model
let data = { input'' }

// 數據劫持
hijack(data)

// model -> view
data.input = '11111112221'

// view -> model
input.oninput = function(e{
  // model -> view
  data.input = e.target.value
}

數據雙向綁定的demo源碼。

簡易視圖指令的編譯過程實現

在MVVM的實現演示中,能夠發現使用了b-valueb-textb-on-inputb-html等綁定屬性(這些屬性在該MVVM示例中自行定義的,並非html標籤原生的屬性,相似於vue的v-htmlv-modelv-text指令等),這些指令只是方便用戶進行Model和View的同步綁定操做而建立的,須要MVVM實例對象去識別這些指令並從新渲染出最終須要的DOM元素,例如

<div id="app">
  <input type="text" b-value="message">
</div>

最終須要轉化成真實的DOM

<div id="app">
  <input type="text" value='Hello World' />
</div>

那麼實現以上指令解析的步驟主要以下:

  • 獲取對應的 #app元素
  • 轉換成文檔碎片(從DOM中移出 #app下的全部子元素)
  • 識別出文檔碎片中的綁定指令並從新修改該指令對應的DOM元素
  • 處理完文檔碎片後從新渲染 #app元素

HTML代碼以下:

<div id="app">
<input type="text" b-value="message" />
<input type="text" b-value="message" />
<input type="text" b-value="message" />
</div>

<script src="./browser.js"></script>
<script src="./binder.js"></script>
<script src="./view.js"></script>

首先來看示例的使用

// 模型
let model = {
  message'Hello World',
  
  getData(key) {
    let val = this
    let keys = key.split('.')
    for(let i=0, len=keys.length; i<len; i++) {
      val = val[keys[i]]
      if(!val && i !== len - 1) { throw new Error(`Cannot read property ${keys[i]} of undefined'`) }
    }
    return val
  }
}

// 抽象視圖(實現功能將b-value中對應的model.message轉換成最終的value="Hello World")
new View('#app', model)

view.js中實現了#app下的元素轉化成文檔碎片以及對全部子元素進行屬性遍歷操做(用於binder.js的綁定屬性解析)

class View {
  constructor(el, model) {
    this.model = model
    // 獲取須要處理的node節點
    this.el = el.nodeType === Node.ELEMENT_NODE ? el : document.querySelector(el)
    if(!this.el) return
    // 將已有的el元素的全部子元素轉成文檔碎片
    this.fragment = this.node2Fragment(this.el)
    // 解析和處理綁定指令並修改文檔碎片
    this.parseFragment(this.fragment)
    // 將文檔碎片從新添加到dom樹
    this.el.appendChild(this.fragment)
  }

  /** 
   * @Desc:   將node節點轉爲文檔碎片 
   * @Parm:   {Object} node Node節點 
   */
  
  node2Fragment(node) {
    let fragment = document.createDocumentFragment(),
        child;
    while(child = node.firstChild) {
      // 給文檔碎片添加節點時,該節點會自動從dom中刪除
      fragment.appendChild(child)
    }    
    return fragment
  }

  /** 
   * @Desc:   解析文檔碎片(在parseFragment中遍歷的屬性,須要在binder.parse中處理綁定指令的解析處理) 
   * @Parm:   {Object} fragment 文檔碎片 
   */
  
  parseFragment(fragment) {
    // 類數組轉化成數組進行遍歷
    for(let node of [].slice.call(fragment.childNodes)) {
      if(node.nodeType !== Node.ELEMENT_NODE) continue
      // 綁定視圖指令解析
      for(let attr of [].slice.call(node.attributes)) {
        binder.parse(node, attr, this.model)
        // 移除綁定屬性
        node.removeAttribute(attr.name)
      }
      // 遍歷node節點樹
      if(node.childNodes && node.childNodes.length) this.parseFragment(node)
    }
  }
}

接下來查看binder.js如何處理綁定指令,這裏以b-value的解析爲示例

(function(window, browser){
  window.binder = {
    /** 
     * @Desc:   判斷是不是綁定屬性 
     * @Parm:   {String} attr Node節點的屬性 
     */
  
    is(attr) {
      return attr.includes('b-')
    },
    /** 
     * @Desc:   解析綁定指令
     * @Parm:   {Object} attr html屬性對象
     *          {Object} node Node節點
     *          {Object} model 數據
     */
  
    parse(node, attr, model) {
   // 判斷是不是綁定指令,不是則不對該屬性進行處理
      if(!this.is(attr.name)) return
      // 獲取model數據
      this.model = model 
      // b-value = 'message', 所以attr.value = 'message'
      let bindValue = attr.value,
       // 'b-value'.substring(2) = value
          bindType = attr.name.substring(2)
      // 綁定視圖指令b-value處理
      // 這裏採用了命令模式
      this[bindType](node, bindValue.trim())
    },
    /** 
     * @Desc:   值綁定處理(b-value)
     * @Parm:   {Object} node Node節點
     *          {String} key model的屬性
     */
  
    value(node, key) {
      this.update(node, key)
    },
    /** 
     * @Desc:   值綁定更新(b-value)
     * @Parm:   {Object} node Node節點
     *          {String} key model的屬性
     */
  
    update(node, key) {
   // this.model.getData是用於獲取model對象的屬性值
   // 例如 model = { a : { b : 111 } }
   // <input type="text" b-value="a.b" />
   // this.model.getData('a.b') = 111
   // 從而能夠將input元素更新爲<input type="text" value="111" />
   browser.val(node, this.model.getData(key))
    }
  }
})(window, browser)

browser.js中使用外觀模式對瀏覽器原生的事件以及DOM操做進行了再封裝,從而能夠作到瀏覽器的兼容處理等,這裏只對b-value須要的DOM操做進行了封裝處理,方便閱讀

let browser = {
  /** 
   * @Desc:   Node節點的value處理 
   * @Parm:   {Object} node Node節點   
   *          {String} val 節點的值
   */
  
  val(node, val) {
 // 將b-value轉化成value,須要注意的是解析完後在view.js中會將b-value屬性移除
    node.value = val || ''
    console.info(`[browser][val] -> node: `, node)
    console.info(`[browser][val] -> val: `, val)
  }
}

至此MVVM示例中簡化的「Model」 -> 「ViewModel」 (未實現數據監聽功能)-> 「View」路走通,能夠查看視圖綁定指令的解析的demo。

ViewModel的實現

「ViewModel」(內部綁定器「Binder」)的做用不只僅是實現了「Model」「View」的自動同步(Sync Logic)邏輯(以上視圖綁定指令的解析的實現只是實現了一個視圖的綁定指令初始化,一旦「Model」變化,視圖要更新的功能並無實現),還實現了「View」「Model」的自動同步邏輯,從而最終實現了數據的雙向綁定。

所以只要在視圖綁定指令的解析的基礎上增長「Model」的數據監聽功能(數據變化更新視圖)和「View」視圖的input事件監聽功能(監聽視圖從而更新相應的「Model」數據,注意「Model」的變化又會由於數據監遵從而更新和「Model」相關的視圖)就能夠實現「View」「Model」的雙向綁定。同時須要注意的是,數據變化更新視圖的過程須要使用發佈/訂閱模式,若是對流程不清晰,能夠繼續回看MVVM的結構設計。

「簡易視圖指令的編譯過程實現」的基礎上進行修改,首先是HTML代碼

<div id="app">
<input type="text" id="input1" b-value="message">
<input type="text" id="input2" b-value="message">
<input type="text" id="input3" b-value="message">
</div>

<!-- 新增中介者 -->
<script src="./mediator.js"></script>
<!-- 新增數據劫持 -->
<script src="./hijack.js"></script>
<script src="./view.js"></script>
<script src="./browser.js"></script>
<script src="./binder.js"></script>

mediator.js再也不敘述,具體回看「中介者模式的實現」view.jsbrowser.js也再也不敘述,具體回看「簡易視圖指令的編譯過程實現」

示例的使用:

// 模型
let model = {
  message'Hello World',
  setData(key, newVal) {
    let val = this
    let keys = key.split('.')
    for(let i=0, len=keys.length; i<len; i++) {
      if(i < len - 1) {
        val = val[keys[i]]
      } else {
        val[keys[i]] = newVal
      }
    }
    // console.log('[mvvm][setData] -> val: ', val)
  },
  getData(key) {
    let val = this
    let keys = key.split('.')
    for(let i=0, len=keys.length; i<len; i++) {
      val = val[keys[i]]
      if(!val && i !== len - 1) { throw new Error(`Cannot read property ${keys[i]} of undefined'`) }
    }
    return val
  }
}
// 發佈/訂閱對象
let mediator = new Mediator()
// 數據劫持(監聽model的變化,併發布model數據變化消息)
hijack(model, mediator)
// 抽象視圖(實現綁定指令的解析,並訂閱model數據的變化從而更新視圖)
new View('#app', model, mediator)
// model -> view (會觸發數據劫持的set函數,從而發佈model變化,在binder中訂閱model數據變化後會更新視圖)
model.message = 'Hello Ziyi233333222'

首先看下數據劫持,在** 數據劫持的實現「的基礎上,增長了中介者對象的發佈數據變化功能(在抽象視圖的」Binder**中會訂閱這個數據變化)

var hijack = (function({

  class Hijack {
    /** 
     * @Desc:   數據劫持構造函數
     * @Parm:   {Object} model 數據 
     *          {Object} mediator 發佈訂閱對象 
     */
  
    constructor(model, mediator) {
      this.model = model
      this.mediator = mediator
    }
  
    /** 
     * @Desc:   model數據劫持
     * @Parm:   
     *          
     */
  
    hijackData() {
      let { model, mediator } = this
      for(let key of Object.keys(model)) {
        let val = model[key]
        Object.defineProperty(model, key, {
          enumerabletrue,
          configurablefalse,
          get() {
            return val
          },
          set(newVal) {
            if(newVal === val) return
            val = newVal
            // 發佈數據劫持的數據變化信息
            console.log('[mediator][pub] -> key: ', key)
            // 重點注意這裏的通道,在最後的MVVM示例中和這裏的實現不同
            mediator.pub(key)
          }
        })
      }
    }
  }

  return (model, mediator) => {
    if(!model || typeof model !== 'object'return
    new Hijack(model, mediator).hijackData()
  }
})()

接着重點來看binder.js中的實現

(function(window, browser){
  window.binder = {
    /** 
     * @Desc:   判斷是不是綁定屬性 
     * @Parm:   {String} attr Node節點的屬性 
     */
  
    is(attr) {
      return attr.includes('b-')
    },

    /** 
     * @Desc:   解析綁定指令
     * @Parm:   {Object} attr html屬性對象
     *          {Object} node Node節點
     *          {Object} model 數據
     *          {Object} mediator 中介者
     */
  
    parse(node, attr, model, mediator) {
      if(!this.is(attr.name)) return
      this.model = model 
      this.mediator = mediator
      let bindValue = attr.value,
          bindType = attr.name.substring(2)
      // 綁定視圖指令處理
      this[bindType](node, bindValue.trim())
    },
    
    /** 
     * @Desc:   值綁定處理(b-value)
     * @Parm:   {Object} node Node節點
     *          {String} key model的屬性
     */
  
    value(node, key) {
      this.update(node, key)
      // View -> ViewModel -> Model
      // 監聽用戶的輸入事件
      browser.event.add(node, 'input', (e) => {
        // 更新model
        let newVal = browser.event.target(e).value
        // 設置對應的model數據(由於進行了hijack(model))
        // 由於進行了hijack(model),對model進行了變化監聽,所以會觸發hijack中的set,從而觸發set中的mediator.pub
        this.model.setData(key, newVal)
      })

   // 一旦model變化,數據劫持會mediator.pub變化的數據  
      // 訂閱數據變化更新視圖(閉包)
      this.mediator.sub(key, () => {
        console.log('[mediator][sub] -> key: ', key)
        console.log('[mediator][sub] -> node: ', node)
        this.update(node, key)
      })
    },
    
    /** 
     * @Desc:   值綁定更新(b-value)
     * @Parm:   {Object} node Node節點
     *          {String} key model的屬性
     */
  
    update(node, key) {
      browser.val(node, this.model.getData(key))
    }
  }
})(window, browser)

最終實現了具備「viewModel」的MVVM簡單實例,具體查看ViewModel的實現的demo。

MVVM的實現

「ViewModel的實現」的基礎上:

  • 新增了 b-textb-htmlb-on-*(事件監聽)指令的解析
  • 代碼封裝更優雅,新增了MVVM類用於約束管理以前示例中零散的實例對象(建造者模式)
  • hijack.js實現了對 「Model」數據的深層次監聽
  • hijack.js中的發佈和訂閱的 channel採用HTML屬性中綁定的指令對應的值進行處理(例如 b-value="a.b.c.d",那麼 channel就是 'a.b.c.d',這裏是將Vue的觀察者模式改爲中介者模式後的一種嘗試,只是一種實現方式,固然採用觀察者模式關聯性更強,而採用中介者模式會更解耦)。
  • browser.js中新增了事件監聽的兼容處理、 b-htmlb-text等指令的DOM操做api等

因爲篇幅太長了,這裏就不過多作說明了,感興趣的童鞋能夠直接查看 https://github.com/ziyi2/mvvm/tree/master/mvvm,須要注意該示例中還存在必定的缺陷,例如「Model」的屬性是一個對象,且該對象被重寫時,發佈和訂閱維護的channels中未將舊的屬性監聽的channel移除處理。

最後

歡迎關注【前端瓶子君】✿✿ヽ(°▽°)ノ✿
歡迎關注「前端瓶子君」,回覆「 算法 」,加入前端算法源碼編程羣,每日一刷(工做日),每題瓶子君都會很認真的解答喲
回覆「交流」,吹吹水、聊聊技術、吐吐槽!
回覆「 閱讀 」,每日刷刷高質量好文!
若是這篇文章對你有幫助,在看」是最大的支持
》》面試官也在看的算法資料《《
「在看和轉發」 就是最大的支持

本文分享自微信公衆號 - 前端瓶子君(pinzi_com)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索