基於Vue的簡易MVVM實現

子奕發表於2019-05-12

本文可以幫助你瞭解什麼?

  • 瞭解MV*架構設計模式的演變歷史
  • 瞭解觀察者設計模式
  • 瞭解Vue的執行機制
  • 瞭解基於Vue的簡易MVVM實現過程

MV*設計模式的演變歷史

我們先來花點時間想想,如果你是一個前端三賤客(Vue、React或者Angular)的開發者,你是有多麼頻繁的聽到“MVVM”這個詞,但你真正明白它的含義嗎?

Web前端的演變歷史

從單純的HTML靜態頁面到MVVM模式的成熟應用,自我能感受的Web前端模式粗略的發展如下所示(可能順序不是很嚴謹):

  • HTML
  • CGI(Common Gateway Interface)、SSI(Server Side Includes)
  • JavaScript
  • ASP(Active Serve Pages)、JSP(Java Serve Pages)
  • JQuery
  • Node.js、EJS、JADE
  • MVC
  • MVP
  • MVVM - 包括服務端渲染

CGI和SSI分別是早期服務端的HTML渲染器和模板引擎,學生時期在嵌入式STM32晶片上利用lwIP(小型開源的TCP/IP協議棧)和FreeRTOS(輕量級作業系統)搭建了一個嵌入式Web伺服器,如果您感興趣,可以在中國知網檢視我寫的小論文基於嵌入式Web伺服器的停車場管理系統,那也是我從嵌入式轉向Web前端的轉折點,啊哈哈哈,有點扯遠了...

MV*設計模式的起源

起初電腦科學家(現在的我們是小菜雞)在設計GUI(圖形使用者介面)應用程式的時候,程式碼是雜亂無章的,通常難以管理和維護。GUI的設計結構一般包括檢視(View)、模型(Model)、邏輯(Application Logic、Business Logic以及Sync Logic),例如:

  • 使用者在檢視(View)上的鍵盤、滑鼠等行為執行應用邏輯(Application Logic),應用邏輯會觸發業務邏輯(Business Logic),從而變更模型(Model)
  • 模型(Model)變更後需要同步邏輯(Sync Logic)將變化反饋到檢視(View)上供使用者感知

可以發現在GUI中檢視模型是天然可以進行分層的,雜亂無章的部分主要是邏輯。於是我們的程式設計師們不斷的絞盡腦汁在想辦法優化GUI設計的邏輯,然後就出現了MVC、MVP以及MVVM等設計模式。

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

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

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

MVC(Model-View-Controller)

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

MVC

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

  • View:檢測使用者的鍵盤、滑鼠等行為,傳遞呼叫Controller執行應用邏輯。View更新需要重新獲取Model的資料。
  • ControllerViewModel之間協作的應用邏輯或業務邏輯處理。
  • ModelModel變更後,通過觀察者模式通知View更新檢視。

Model的更新通過觀察者模式,可以實現多檢視共享同一個Model

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

優點:

  • 職責分離:模組化程度高、Controller可替換、可複用性、可擴充套件性強。
  • 多檢視更新:使用觀察者模式可以做到單Model通知多檢視實現資料更新。

缺點:

  • 測試困難:View需要UI環境,因此依賴ViewController測試相對比較困難(現在Web前端的很多測試框架都已經解決了該問題)。
  • 依賴強烈:View強依賴Model(特定業務場景),因此View無法元件化設計。

服務端MVC

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

JSP Model1和JSP Model2的演變過程

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

JSP Model1

在Model1中,JSP同時包含了ControllerView,而JavaBean包含了ControllerModel,模組的職責相對混亂。在JSP Model1的基礎上,Govind Seshadri借鑑了MVC設計模式提出了JSP Model2模式(具體可檢視文章Understanding JavaServer Pages Model 2 architecture),它的結構如下所示:

JSP Model2

在JSP Model2中,ControllerViewModel分工明確,Model的資料變更,通常通過JavaBean修改View然後進行前端實時渲染,這樣從Web前端發起請求到資料回顯路線非常明確。不過這裡專門詢問了相應的後端開發人員,也可能通過JavaBeanControllerController主要識別當前資料對應的JSP)再到JSP,因此在服務端MVC中,也可能產生這樣的流程View -> Controller -> Model -> Controller -> View

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

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

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

  • Passive ViewView不再處理同步邏輯,對Presenter提供介面呼叫。由於不再依賴Model,可以讓View從特定的業務場景中抽離,完全可以做到元件化。
  • PresenterSupervising Controller):和經典MVC的Controller相比,任務更加繁重,不僅要處理應用業務邏輯,還要處理同步邏輯(高層次複雜的UI操作)。
  • ModelModel變更後,通過觀察者模式通知Presenter,如果有檢視更新,Presenter又可能呼叫View的介面更新檢視。

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

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

MVVM(Model-View-ViewModel)

MVVM

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

  • ViewModel:內部整合了Binder(Data-binding Engine,資料繫結引擎),在MVP中派發器ViewModel的更新都需要通過Presenter手動設定,而Binder則會實現ViewModel的雙向繫結,從而實現ViewModel的自動更新。
  • View:可元件化,例如目前各種流行的UI元件框架,View的變化會通過Binder自動更新相應的Model
  • ModelModel的變化會被Binder監聽(仍然是通過觀察者模式),一旦監聽到變化,Binder就會自動實現檢視的更新。

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

  • 提升了可維護性,解決了MVP大量的手動同步的問題,提供雙向繫結機制。
  • 簡化了測試,同步邏輯是交由Binder處理,View跟著Model同時變更,所以只需要保證Model的正確性,View就正確。

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

  • 產生效能問題,對於簡單的應用會造成額外的效能消耗。
  • 對於複雜的應用,檢視狀態較多,檢視狀態的維護成本增加,ViewModel構建和維護成本高。

對前端開發而言MVVM是非常好的一種設計模式。在瀏覽器中,路由層可以將控制權交由適當的ViewModel,後者又可以更新並響應持續的View,並且通過一些小修改MVVM模式可以很好的執行在伺服器端,其中的原因就在於ModelView已經完全沒有了依賴關係(通過View與Model的去耦合,可以允許短暫View與持續View的並存),這允許View經由給定的ViewModel進行渲染。

目前流行的框架Vue、React以及Angular都是MVVM設計模式的一種實現,並且都可以實現服務端渲染。需要注意目前的Web前端開發和傳統Model2需要模板引擎渲染的方式不同,通過Node啟動服務進行頁面渲染,並且通過代理的方式轉發請求後端資料,完全可以從後端的苦海中脫離,這樣一來也可以大大的解放Web前端的生產力。

觀察者模式和釋出/訂閱模式

觀察者模式

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

Observer

如上圖所示:一個或多個觀察者對目標物件的狀態感興趣時,可以將自己依附在目標物件上以便註冊感興趣的目標物件的狀態變化,目標物件的狀態發生改變就會傳送一個通知訊息,呼叫每個觀察者的更新方法。如果觀察者對目標物件的狀態不感興趣,也可以將自己從中分離。

釋出/訂閱模式

釋出/訂閱模式使用一個事件通道,這個通道介於訂閱者和釋出者之間,該設計模式允許程式碼定義應用程式的特定事件,這些事件可以傳遞自定義引數,自定義引數包含訂閱者需要的資訊,採用事件通道可以避免釋出者和訂閱者之間產生依賴關係。

Pub/Sub

學生時期很長一段時間內用過Redis的釋出/訂閱機制,具體可檢視zigbee-door/zigbee-tcp,但是慚愧的是沒有好好閱讀過這一塊的原始碼。

兩者的區別

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

釋出/訂閱模式:單一目標通常有很多觀察者,有時一個目標的觀察者是另一個觀察者的目標。通訊可以實現雙向。該模式存在不穩定性,釋出者無法感知訂閱者的狀態。

Vue的執行機制簡述

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方法來更新檢視。

可以發現只要檢視中新增監聽事件,自動變更對應的資料變化時,就可以實現資料和檢視的雙向繫結了。

基於Vue機制的簡易MVVM實現

瞭解了MV*設計模式、觀察者模式以及Vue執行機制之後,可能對於整個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 Demo

MVVM的流程設計

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({
      context: this,
      uid: this.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, {
      enumerable: true,
      configurable: false,
      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', age: 1 }
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

資料雙向繫結的實現

data-binding

如上圖所示,資料雙向繫結主要包括資料的變化引起檢視的變化(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, {
      enumerable: true,
      configurable: false,
      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)的作用不僅僅是實現了ModelView的自動同步(Sync Logic)邏輯(以上檢視繫結指令的解析的實現只是實現了一個檢視的繫結指令初始化,一旦Model變化,檢視要更新的功能並沒有實現),還實現了ViewModel的自動同步邏輯,從而最終實現了資料的雙向繫結。

MVVM

因此只要在檢視繫結指令的解析的基礎上增加Model的資料監聽功能(資料變化更新檢視)和View檢視的input事件監聽功能(監聽檢視從而更新相應的Model資料,注意Model的變化又會因為資料監聽從而更新和Model相關的檢視)就可以實現ViewModel的雙向繫結。同時需要注意的是,資料變化更新檢視的過程需要使用釋出/訂閱模式,如果對流程不清晰,可以繼續回看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, {
          enumerable: true,
          configurable: false,
          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等

由於篇幅太長了,這裡就不過多做說明了,感興趣的童鞋可以直接檢視ziyi2/mvvm,需要注意該示例中還存在一定的缺陷,例如Model的屬性是一個物件,且該物件被重寫時,釋出和訂閱維護的channels中未將舊的屬性監聽的channel移除處理。

設計模式

在以上MVVM示例的實現中,我也是抱著學習的心態用到了以下設計模式,如果對這些設計模式不瞭解,則可以前往檢視示例程式碼。

參考資源

相關文章