MVVM 最佳解讀和實踐

MiseryLee發表於2018-06-22

本文不討論MVVM的歷史、優劣和與其他架構模式的對比,有興趣的可以閱讀一下Wikipedia對其描述Model-view-viewmodel - Wikipedia,和微軟對MVVM的解釋The MVVM Pattern

網路上對於MVVM架構模式的科普文章已經層出不窮了,這篇文章可能會顯得有一些多餘。不過本文可能會給你一些不同的思路,說不定會改變你對MVVM的理解和看法。

對MVVM架構的擴充解讀

  1. MVVM應該改成M-VM-V會更容易直觀地理解。View-Model作為膠水層,把檢視View和資料模型Model粘合在一起。
  2. MVVM不是一個純前端的架構模式。它適用於所有的包含GUI(Graphical User Interface 圖形使用者介面)的應用程式中(包含後端部分)。
  3. MVVM其實可以細分為M-C-VM-V的四層架構。
  4. 對於以上M-C-VM-V層的理解:
    1. M(odel)層:定義資料結構,建立應用的抽象模型。
    2. C(ontroller)層:實現業務邏輯,向上暴露資料更新的介面,呼叫Model層來進行模型資料的增刪改查,以達到應用資料更新的目的。
    3. V(iew)-M(odel)層:將Model層的抽象模型轉換為檢視模型用於展示,同時將檢視互動事件繫結到Controller層的資料更新介面上。
    4. V(iew)層:將檢視模型通過特定的GUI展示出來,並在GUI控制元件上繫結檢視互動事件。
  5. 說白了,對於一款擁有GUI的應用程式來說,使用者與計算機進行交流的過程,不過是IO(輸入輸出)的過程。計算機通過輸出裝置(顯示器、揚聲器、機械馬達等。不過這裡我們針對於圖形介面來講的話,一般就是顯示器)將檢視資料進行展示,使用者通過輸入裝置(鍵盤、滑鼠、觸控板等)來觸發特定的事件達到模型的更新。
  6. 我們之所以要發明這種分層架構,最主要的原因是為了讓Model層和Controller層能夠複用。甚至於對於同一款應用程式在不同的GUI上進行展示時,View-Model層也是複用的,僅僅只是把View層進行了替換而已。
  7. 再擴充一下,假如我們的應用程式需要在非GUI介面進行實現,而是通過其他UI方式來實現呢?只需要將View-Model層替換成新的UI-Model,再與新的UI進行橋接,同樣的功能便可以跨UI進行實現了。
  8. 對於上述這點,舉個例子:針對於殘障人士(比如盲人),我們的應用程式應該更加方便易用。或許我們需要考慮使用揚聲器來代替顯示器進行輸出,同時使用麥克風來進行輸入。這時,我們可以將上述的View-Model替換為Audio-Model作為語音模型,UI層即Audio層用於播放語音和接收語音輸入。
  9. 綜上所述,對於UI應用程式(給使用者提供了使用者介面的應用程式),都可以抽象成M-I-IM(其中I指Interface)架構模式來達到模型、邏輯、表徵之間的分離解耦,並提高開發效率。

MVVM的最終體現

根據前面對MVVM的解讀,我們可以用下面這張圖來描述這種關係:

這裡,我們就瀏覽器中的Web開發來講。View層體現為瀏覽器中的DOM樹,View-Model則體現為近幾年特別流行的虛擬DOM樹,Model則體現為業務邏輯和ORM(Controller被歸為Model層)。

以上這張圖更加傾向於描述一個基於瀏覽器的本地應用的架構。我們現在的應用程式更多的是基於網路伺服器的,所以把伺服器加入到這套架構模式中來,用下圖描述一下:

在上面一張圖的基礎上,我們新增了Local Logic和Local Store層,分別代表了本地邏輯和資料儲存。你會發現,我們通過本地邏輯層和遠端邏輯層進行了互動,達到使本地資料儲存與遠端資料之間的同步。通過這種方式,達到前面一張圖描述出來的結構。

既然這樣,我們何不對此做一些封裝來讓圖形看起來更加簡潔:

最終,MVVM的架構模式就很明瞭了。對於純Web前端專案來講,Model指的是上圖中的Local Model層,而對於包含了伺服器的應用程式來講,Model指的是上圖中的Local Model和Remote Model的組合。

可能是MVVM的最佳實踐

對於Local Model和Remote Model之間的同步,有很多種方案,比如HTTP、WebSocket等。那麼如何去實現View到View-Model,View-Model到Local Model之間的互動呢?

首先明確一點,DOM是由瀏覽器進行實現並在顯示器上進行展示輸出的,這一部分的工作我們一般情況下是不需要處理的。它的地位相當於View物件之於Android應用、Storyboard之於iOS應用。不過呢,我們還是需要編寫一些程式碼,比如XML來對其進行描述(HTML是XML的一個子集,一般圖形介面都是由XML語言來進行編寫的)。

接著Virtual DOM,不得不提現在比較火的React和Vue框架了。這兩個框架實質上可以理解為一種,內部實現核心是虛擬DOM的實現和虛擬DOM樹之間的比較演算法。這裡我們就React來做詳細描述。

早期的React是包含了View、View-Model、Model於一身的集大成框架,我們通過React.createClass來建立一個類來作為一個元件類,然後在元件的生命週期函式中實現業務邏輯,在物件成員變數中儲存本地資料,並將本地資料轉換為檢視狀態state,然後由state渲染出UI介面。

在後來的發展中,React對自身進行了拆分:

  • JSX,是Virtual DOM的語法糖,我們編寫的JSX程式碼實質上是Virtual DOM,並不是DOM。
  • React-dom,主要職責是將Virtual DOM渲染成瀏覽器中的DOM。
  • React,主要職責是進行Virtual DOM的維護和diff運算。

在新的版本中,React弱化了State和生命週期,建議用Stateless元件(即函式式元件)來進行元件的開發。至此React將檢視狀態、本地資料和邏輯完全剝離了出去。

我們可以使用其他的方式來進行檢視狀態、本地資料的維護和邏輯的實現。React的生態達到完全解耦的地步,完美地詮釋了MVVM架構的精髓。

誇完了React,我們回到正題上。前述的React和其相關生態實現了View、View-Model和它們之間的互動,那麼View-Model到Model如何更好地進行實現呢?

當然,我們仍然可以用React的類元件來進行實現,畢竟它還沒有完全棄用Stateful元件。更加優化的一點的是通過諸如Flux、Redux等狀態管理方案。

在Redux中,從React元件中剝離出來的狀態和邏輯,被統一的進行管理,再通過connect方法與根元件進行連線達到將狀態和方法繫結到元件上面的目的。

對於使用React+Redux的方案,我們的架構圖可以這樣描述(為了方便檢視和理解,這裡先不考慮伺服器部分):

令人愉快的是,大部分的東西React生態都已經幫我們實現了,我們要做的只有兩個部分:

  1. 使用JSX描述出期望的DOM結構
  2. 使用Redux來管理維護Store,並利用Reducer和Action來實現業務邏輯。

可能是更佳的實踐

使用過Redux的人可能會發現,編寫Reducer和Action的過程其實是很不友好的。當你的專案越來越龐大的時候,你的DOM樹會越來越深,如果要更新一個葉子節點的狀態,需要逐級地去對Store進行更新。

Redux主要實現了對狀態的剝離,同時因此實現了父子節點之間更簡單的通訊方式。那麼如果我們需要找到一個替代方案,我們至少需要實現這兩點。

所謂狀態,是對檢視在不同情況下的表徵的一個動態描述(不會更新的表徵是不需要狀態的)。所以實質上狀態只是一組變數的集合,這些變數可能是布林值、數字或者資料集。

狀態本身其實可以認為是和DOM樹無關的,正因為這個前提,我們才可以把狀態完全剝離出來。只通過一些協議來進行繫結,比如Redux中的connect方法。

想象一下,如果我們的所有狀態是一個完全離散的網路,狀態之間沒有層級結構的話,是不是就可以更方便地去對狀態進行維護,狀態之間更簡單地去進行通訊了呢?我們來看一下下圖:

DOM樹實際上是一個多叉樹,我們建立一個網路,網路上的節點對應上DOM樹上每個節點,代表著這個節點的狀態。網路上的節點之間可以互相呼叫,無視DOM樹上的節點層級關係。

我們甚至可以做一些更瘋狂的事情,比如下圖:

我們可以通過多個節點對應一個狀態的方式來實現多個節點間的狀態共享,通過一個節點對應多個狀態的方式來實現狀態的修飾組合裝飾器模式 | 菜鳥教程

把狀態抽象成一個物件,它包含了檢視狀態和狀態的更新方法。然後將這個物件和需要使用到這個物件代表的狀態的節點進行連線,就可以實現我們的目的了。同時,因為我們是通過一個純物件來實現狀態管理的,實際上我們也可以將本地資料模型在這個物件中進行管理。資料模型和狀態模型(即檢視模型)之間可以直接在內部進行轉化。我們可以把狀態模型和資料模型統稱為Model。那麼我們再更新一下上面的架構圖:

到這一步,我們發現其實又回到了最初的那張圖的模式了。沒錯,這就是MVVM的最基本的形態也是最終的形態。

那麼問題來了。我們如何實現上述的Store物件和View-Model之間的繫結呢?看下下面這張圖:

我們建立一箇中間元件Connector,在Connector中維護一個state物件,將View-Model對應的元件包裹在Connector中,將Connector的state作為包裹元件的屬性傳入進去。當Store中的變數發生變化時,觸發Connector元件的setState方法對state進行更新,同時觸發render方法達到使包裹元件重新渲染的目的。利用Javascript的Getter/Setter特性可以很容易地實現這一點。

針對於上述的架構模式,connect-store 庫應運而生。

我們來使用connect-store,實現一個計數器。

  • 首先建立一個類,來描述我們的資料模型。這將是計數器的核心。我們需要一個模型資料來儲存計數,同時需要一個increase方法來實現計數更新。
class CounterStore {
  // 計數
  count = 0; // 模型資料,同時也是檢視資料
  // 計數增加1
  increase() {
    this.count += 1; // 對計數進行更新
  }
}
  • 建立一個stateless元件,描述一下期望的展示效果。我們需要展示一個文字,同時需要展示一個按鈕,點選這個按鈕可以令計數加一。connect-store會將increase方法包裝成onIncrease事件傳遞給檢視元件。
const CounterView = ({ count, onIncrease }) => {
  return (
    <div>
      <span>{count}</span>
      <button onClick={onIncrease}> + </button>
    </div>
  );
};
  • 使用connect-store繫結它們並使用react-dom渲染到id為root的DOM節點上。
import React from 'react';
import { render } from 'react-dom';
import Connector from 'connect-store';

render(
  <Connector
    View={CounterView}
    store={new CounterStore()}
  />,
  document.getElementById('root')
);

初始渲染效果

點選增加按鈕2次後的渲染效果

connect-store專案已經在github上開源,不過暫時還沒有文件,使用方法可以在example中檢視示例。

miserylee/connect-store​github.com

還有更佳的實踐嗎?

歡迎留言交(hù)流(duǐ)。

Javascript交流QQ群:348108867 歡迎你的加入!

相關文章