如果把「客戶端」想成是樓,把「數據」想成是水——「Model」就是這幢樓的蓄水池,提供充足的水源;「ViewModel」是將蓄水池裏的水進行淨化等加工的地方,然後輸送給挨家挨戶;「View」部分的每個 UI 組件就是「挨家挨戶」,對水進行消費的地方。

一切皆爲模型

模型是人們根據事物特徵將它們分類並抽象後的結果,建模是人們認知世界的一種方式。

模型驅動

數字世界這種虛擬空間,裏面本無一物,是個需要被人開墾的空虛的世界。那麼人該如何打造數字世界呢?

就像《聖經》裏描述的——上帝按照自己的樣子創造了亞當這個世上第一個人類,又從他身上取下一根肋骨創造了夏娃這個世界上第二個人類。在這裏,上帝將自己作爲參照提取特徵抽象出祂所認爲的「人」的模型,並根據這個模型創造出「亞當」和「夏娃」。

人在打造數字世界時必然會參照自己所存在的並且是自己所認知的世界,因爲人不可能想像出自己無法認知的事物。人們所抽象的現實世界的事物的模型,就成了建設數字世界的基礎,而數據則爲構造數字世界的基本單元,數字世界成了現實世界的映射。

模型是數字世界萬物的概念,程序是將概念具像化的工具,打造數字世界需從建模開始。

領域驅動

上面說了在打造數字世界時首先要建立模型,然後以模型爲中心開始建造。那麼要怎樣進行建模呢?

至今爲止,軟件工程發展這麼多年,產生了很多方法論,其中「領域驅動設計」在構建大型軟件時是被廣泛採納的實踐方法。它的核心就是針對問題域分析並建立領域模型,理出模型間的關係及業務邏輯。

領域驅動設計最常用在商業層面的模型上,如:包含名稱、編號、規格、出廠日期等信息的商品模型;同時也可以用在技術層面的模型上,如:包含名稱、編碼、字段、關係、約束等用來描述模型的信息的模型。前者稱之爲「業務模型」,後者則是「元模型」。業務模型可以被元模型描述。

如果把模型映射爲數據庫表,那麼元模型所對應的表中的每條記錄都是元數據,業務模型所對應的表中的每條記錄都是業務數據。

MVVM 架構

標準的 MVVM 架構是 Model-View-ViewModel 三部分:

圖片來自 Wikipedia

而這裏所說的如下圖所示:

「MVAVM」架構

從圖中可以看到,多了個「Action」,所以實際上應該是 Model-View-ViewModel-Action 四部分。它們之間彼此分離,以組合的方式協同工作。

爲了講究對稱美,將這種架構簡稱爲「MVAVM」。

模型

模型的主要職責是前、後端協議處理,以及對數據進行讀寫操作。

前、後端協議的處理包括元數據適配和 HTTP 請求構造。與後端對接的工作都控制在這一層,其他層的運作都基於這層適配後的結果。

在這層中進行讀寫的數據,既有業務數據又有元數據。元數據只加載一次,將適配後的結果進行緩存;業務數據只暫時緩存尚未持久化的處於草稿狀態的記錄,持久化之後會將其刪除。

ViewModel

VM 的職責很單純,就是處理業務數據流轉相關的邏輯,即數據的分發、彙總與聯動。理論上,在這層不直接進行任何與請求服務、執行動作相關的處理。

正如文章開頭所說——在一個應用中,數據是像水一樣不斷流動的,在此過程中,VM 應該起到鋪設輸送管線與在特定節點對數據進行處理的作用。根據這一特點,可以考慮採用 管道和過濾器模式

圖片來自 Microsoft Build

實例與數據的關係

每個 VM 實例都來源於數據,是數據的變形,是具備能力的數據。

根據數據源的形態,VM 實例大致分爲列表、對象和值三種。如果值是布爾、數字、字符串等簡單類型,那就即刻終止;若值爲對象、列表等複雜類型,則要遞歸下去,直到末端爲簡單類型。

需要注意的是,VM 實例與數據一一對應,其實質就是數據本身,而不是數據的容器。也就是說,VM 實例不是裝水的瓶子,不能把已經裝的水倒掉換些水進來,而是一起丟棄。

生命週期

任何對象的生命週期都可粗略地分爲初始化、活動中與銷燬三個階段。

在初始化時根據策略獲取自身數據源,與上級 VM 實例創建的流進行對接形成數據管道,然後創建向外推送自身變化的流。

活動期間就是不斷地與外界進行數據交換:

  1. 視圖輸入變化時,通過對應的 VM 實例提交自身的數據變更
  2. 在處理被提交的輸入數據時會對其進行保留,併發出有數據提交的信號
    1. 自身的數據變化會通過數據管道流向下級 VM 實例
    2. 外部(主要是上級)接收到信號後會做些後續處理

銷燬時做些清理、善後的工作,如:移除子 VM 引用,取消訂閱等。

數據流轉

在活動期間,數據在各層 VM 實例所連通的數據管道中流轉時會發生變化,爲了方便在不同場景下對數據進行處理,需要在初始化 VM 實例時將數據源進行備份,並生成幾個拷貝:初始值(initial value)、默認值(default value)、原始值(data source)和當前值(current value)。

其中,初始值是獲取到數據源那一刻的值,默認值在沒有指定的情況下與初始值相同,它們都是一經初始化就不會改變的;當前值是自身一段時間內的數據變更,是最新的但不確定的值,可以理解爲是一種草稿狀態的值;原始值只有在上級當前值變動,接收到下級提交的數據或強制更新時纔會更新,它是階段性的確定值,可以看作是可靠的數據。

「原始值」中的「原始」也許會容易讓人誤解。在這裏,它的含義是相對於「當前值」來說,它是「原始」的,可以拿來作爲參考的,而不是「最初的值」。表達「最初的值」的含義的是「初始值」。

原始值與當前值的區別與特點是:

  • 原始值是確定的,當前值是不確定的;
  • 原始值是純的,當前值是髒的;
  • 通過「提交(commit)」操作對各級的原始值、當前值進行同步;
  • 當前值的「版本」始終不落後於原始值;
  • 有些場景下原始值與當前值始終相同。

數據在流轉時遵循以下幾個原則:

  • 自身的原始值變動會引起自身的當前值以及子孫級的原始值和當前值變動,子孫可以定義拋棄變動的規則;
  • 自身的當前值變動在沒提交時不會影響自身的原始值,會引起子孫級的原始值和當前值變動,子孫可以定義拋棄變動的規則;
  • 將自身的當前值提交到上級後,不會引起迴流,兄弟 VM 也不會發生變化。

總的來說,只有在上級引發數據變動的情況下,纔會發生上到下的數據流動。

各層級 VM 實例之間數據的傳遞過程大致如下:

數據流轉

過濾器

在數據通過上下級 VM 實例之間所連通的數據管道,即數據的分發與彙總時,會經過一系列相對獨立的邏輯的處理,如:數據的裁剪、變形、校驗等。每一段處理邏輯就是一個「過濾器」,每個過濾器都可以拋出異常終止後續的操作。

與視圖的交互

每個 VM 實例都會提供一些供視圖進行狀態同步、數據聯動等的接口:

interface IViewModel<ValueType> {
  // 獲取原始值
  getDataSource(): ValueType;
  // 設置原始值
  setDataSource(value: ValueType): void;

  // 獲取當前值
  getCurrentValue(): ValueType;
  // 設置當前值
  setCurrentValue(value: ValueType): void;

  // 監聽當前值變化
  watch(handler: Function): Subscription;

  // 監聽提交等事件
  on(handlers: {[key: string]: Function}): void;

  // 在分發數據的過濾器隊列頭部添加一個過濾器
  prependDispatchFilter(filter: Function): void;
  // 在分發數據的過濾器隊列尾部添加一個過濾器
  appendDispatchFilter(filter: Function): void;

  // 在提交數據的過濾器隊列頭部添加一個過濾器
  prependCommitFilter(filter: Function): void;
  // 在提交數據的過濾器隊列尾部添加一個過濾器
  appendCommitFilter(filter: Function): void;

  // 獲取上級 VM 實例
  getParent(): IViewModel;
  // 獲取下級 VM 實例
  getChildren(): IViewModel[];

  // 獲取模型,返回值包含發請求的 API
  getModel(): IModel;

  // 執行動作,不指定 VM 實例的話使用當前 VM 實例
  call(action: IAction, vm?: IViewModel): Promise<void>;
}

動作

關於「動作」是什麼,在之前的文章《 我來聊聊配置驅動的視圖開發 》中已經提及——

「動作」是一段完整邏輯的抽象,與函數相當,用來描述且只描述「做什麼事」,不描述「長什麼樣」。一個可複用的動作應該是原子化的。

根據邏輯的定義、執行所在位置,可以分爲客戶端動作(廣義)與服務端動作:客戶端動作(廣義)是定義並且執行在前端;服務端動作是定義並且執行在後端。

客戶端動作(廣義)根據具體場景的用途及特性,又可分爲以下幾種動作:

  • 路由動作
  • CRUD 動作
  • 客戶端動作(狹義)
  • 組合動作

其中,路由動作的作用是進行頁面跳轉;CRUD 動作是對數據進行操作;客戶端動作(狹義)是單純的一段邏輯,可以簡單理解爲是一個 JS 函數;組合動作用於將其他類型的動作「打包」處理,就像一個調用了其他函數的函數。

服務端動作可以簡單粗暴地理解爲是非常規 CRUD 的後端接口。

除了客戶端動作(狹義)需要自己寫邏輯之外,其他的都是完全根據元數據執行。

路由動作是進行頁面跳轉的動作,這裏的「頁面」是廣義的,根據情景,可以理解爲是瀏覽器窗口中的整個頁面,也可以理解爲是某個視圖所在的宿主。在這個體系裏,將視圖跳轉的動作稱爲「視圖動作」,跳轉到當前應用之外的頁面的叫做「頁面動作」。

既然組合動作是將其他類型的動作「打包」處理的動作,那麼它就得具備調整被「打包」的動作的執行順序及如果某個動作執行失敗要終止後續處理等的控制能力。實現方式可以參考 continuation 在 JS 中的實踐應用。

視圖

解析視圖描述信息,並根據注入的 VM 實例所攜帶的數據進行渲染。

視圖中可以自己發請求,但理論上只能發獲取數據的請求,不能發修改數據的,修改數據需要通過 VM 實例或動作去處理。

視圖這部分又細分爲描述層、包裝層和渲染層:

視圖層架構

「描述層」即「DSL 層」,通過內部定義的 XML 標籤集去描述一個界面中的 UI 元素、數據等信息,是一種相較於 JSON 來說更符合直覺,更容易理解的界面配置。

包裝層的作用是將描述層的標籤轉換爲實際渲染的部件,渲染層則是具體的運行時環境。不像描述層那樣相對獨立,包裝層和描述層可以說是不能分離的,包裝層在將描述層的標籤轉換爲實際渲染的部件時需要渲染層的支撐。

包裝層的包裝器與描述層的標籤集裏的標籤可以說是一一對應的,標籤通過包裝器轉換爲部件集裏的部件,但部件卻不一定與包裝器一一對應,很可能一個包裝器對應多個同類別的部件。

描述層

在 web 前端開發中,HTML 是一種 DSL,CSS 也是一種 DSL。在這個模型驅動的體系裏,內部定義的用來描述一個界面中的 UI 元素、數據等信息的 XML 標籤集就是 DSL。

描述層是運行時無關的,能夠在任何平臺及運行時庫中運行。

日常工作交流中常會說到「模板」,這個詞在不同語境中代表着不同的東西。在這個體系中,當在開發的語境裏時,如果沒帶任何修飾詞,應該就是指「一段描述界面配置的標籤」,如:

<view widget="form">
  <group title="基本信息" widget="fieldset">
    <field name="name" label="姓名" widget="input" />
    <field name="gender" label="性別" widget="radio" />
    <field name="age" label="年齡" widget="number" />
    <field name="birthday" label="生日" widget="date-picker" />
  </group>
  <group title="寵物" widget="fieldset">
    <field name="dogs" label=":dog:" widget="select" />
    <field name="cats" label=":cat:" widget="select" />
  </group>
  <action ref="submit" text="提交" widget="button" />
  <action ref="reset" text="重置" widget="button" />
  <action ref="cancel" text="取消" widget="button" />
</view>

模板如果不去解析,它就只是一段普通的文本,沒有任何作用。

要對模板進行解析,得有一套對應模板上標籤的標籤集,還需有能將純文本的模板藉助標籤集轉換成 JS 對象的解析器。

標籤集中的每個標籤,也可以稱爲「元素」。考慮到擴展性,需要有元素註冊的機制,這有助於元素屬性等的規範和管理。

在註冊元素時,需要指定一些關鍵信息,如:元素名、標籤名、屬性描述符、行爲。「屬性描述符」主要是用來聲明該元素所支持的屬性及其值的類型;「行爲」則用來告知該元素在解析後是作爲父節點的子節點還是屬性存在。

所有作爲子節點存在的元素,基本都對應一個具體的部件。從表意上來說,這些元素分爲兩類:一類是較爲抽象的,另一類是較爲具象的。較爲抽象的元素只有一個,它僅單純地表達是「部件」這個含義,並沒有更具體地體現出是幹嘛的;其他的元素都是具象的,像 <view><field> 等,從命名就知道是用於哪方面的。

所謂「節點」,就是將模板中的元素編譯解析後所轉換成的 JS 對象。整個模板會解析成一個樹狀結構的 JS 對象,也就是「節點樹」。每個節點可以有一些方法,用來新增子節點、刪除自身、獲取或修改自身信息等。

包裝層

包裝層的作用是將描述層的產物,即節點,轉換爲部件。在 DSL 節點與部件之間起到橋樑作用的,就是「包裝器」。

包裝器裏面彙集了描述層所產出的一些信息,如:要生成到界面中的節點的屬性及其對應部件的配置等。會根據節點所對應的元素所引用的部件的標識符去查找相應的部件,如果沒指定引用則使用默認的,並將其他屬性及相關聯部件的配置作爲部件的屬性進行傳遞。

渲染層

簡單來說,渲染層就是像 Vue、React、iOS、Android、微信小程序之類的庫/框架、平臺的運行時環境。進行實際渲染的組件、部件及作爲橋樑的包裝器都對其依賴,這就需要在每個運行環境下都得有一套包裝器、組件和部件的封裝。

思想總結

模型驅動架構正符合我在《前端有架構嗎?》所提到的架構設計的首要核心原則——以不變爲中心。

在這個體系中,根據不同層、不同角色的設計目標,需要採用適合的編程範式,而不侷限於一種。如:模型主要用 OOP,VM 使用 OOP 和 FRP,動作用到 FP。

合理且完善的模型驅動架構的設計與實現,能夠很好地支撐企業業務的變化,快速搭建新的應用。

數據處理相關的架構設計就到這裏。

以上。

相關文章