前端架構101(五):從Flux進化到ModelViewPresenter

NO IMAGE

在 Flux 架構中,有兩個問題依然沒有被提到,一個是表現層模型,另一個是測試

我們從表現層邏輯說起

表現層模型即 Presenter Model 或者稱之為 View Model。這是一些與業務無關緊要,但是與可視化展示息息相關的數據。簡單的例如某個可摺疊的控件是否處於摺疊狀態,複雜的可以是某個字段的校驗規則,校驗的出錯信息,或者是圖表的展現類型(餅圖還是柱狀圖)等等。

想象一下在 Flux + React 的框架下這些數據應該存放在哪裡?我想包括曾經的我在內的大多數人都會把它放在組件中,這是想當然的事情:既然它們屬於表現層狀態,那麼就應該放在表現層的組件中;而不放在 Redux 中的另一個原因是,Redux 並非是所有功能的標配,把所有數據都往 Redux 中集成會讓整個 store 顯得臃腫,維護起來反而不利。

但在實際應用中這些數據並沒有那麼純粹,甚至可以說大多數時候表現層模型和業務模型是息息相關,比如用戶允許在下面的表格中選中某些商品,然後選擇將它們的價格清零:

前端架構101(五):從Flux進化到ModelViewPresenter

簡易的偽代碼可能是這樣的:

// 每行的選中函數
function onRowSeleted(rowId) {
selectedRows.push(rowId)
}
// 左上角提交按鈕的回調函數
function onSubmit() {
// Step 1: Clear selected data's price:
selectedRows.forEach(rowId => data[rowId].price = 0);
// Step 2: Sync to local store:
syncToLocalStoreAction(data);
// Step 3: Sync to remote backend:
syncToBackendRequest(data);
// Step 4: Clear view model:
selectedRows = [];
}

有幾個問題我要需要考慮:

  • 如果上面的這段代碼書寫在某個 React 組件中,如果某天我們需要切換為另一個 UI 框架時,這部分代碼我們可能需要原封不動的照抄一遍,但你可以看到,上述代碼並沒有使用到 React 技術特定的接口或者語法。理論上來說時可以無縫移植的
  • 即使需求不是遷移框架,而是需要上述邏輯在同一個應用的不同組件中重用,例如上面截圖是清除水果的價格,另一個頁面需要清除 3C 產品的商品價格, 抄一遍似乎也有一些多餘。這樣就可能產生“散彈式修改”的代碼壞味道
  • 最後一個問題是測試,對於相同的邏輯,我們可不希望當邏輯複用時需要編寫的測試也要加倍。

再次提醒以上考慮的出發點是我們在第一章討論的非功能需求,即可維護性和測試。如果你不在乎非功能需求,那麼接下來的內容對你的意義並不大。

服務層

從上面的三點敘述中,我們不難得出我們需要進一步解決的問題:

  1. 即表現層邏輯、業務邏輯、與視圖三者其實並非強相關的,尤其是表現層邏輯可以與視圖使用的具體技術棧無關。
  2. 表現層邏輯需要和視圖進行分離,以便於複用。

同時注意上面 onSubmit 回調函數中的內容,它其實描述的是一些列流程,在這個提交操作中,我首先需要做什麼、其次需要做什麼以及最後需要做什麼。這樣的流程是用戶使用的其中一個場景,也算是其中一個用例。這些用戶用例本質上是和表現層的技術無關的,無論是使用 React 還是 Vue 都需要將它們實現。所以我們可以把它們作為獨立的模塊與視圖隔離並且封裝起來。借用後端的概念,我們可以把這類模塊、這種規則的分層稱之為Service Layer,後文中使用服務層稱呼它。

前端架構101(五):從Flux進化到ModelViewPresenter

封裝用戶用例只是服務層的實現,再往上抽象點看,它定義的其實是應用的邊界。因為無論操作指令來自於用戶界面,或者想象它終有一天被移植到命令行界面,操作指令來自於命令行輸入,所有可行的操作以及需要對這些操作做出的響應都不過封裝於該層。該層決定了應用能做什麼不能做什麼。直接搬用 Martin Fowler 對於服務層的完整定義如下:

Defines an application’s boundary with a layer of services that establishes a set of available operations and coordinates the application’s response in each operation.

前端架構101(五):從Flux進化到ModelViewPresenter

但不難看出服務層其實也只是“中介”而已,在服務層的實現裡它依然需要調用其它的模塊來實現功能,最需要互動的就是各種領域模型。這個方面的相關的問題我們待會再談

在後端開發中,服務層不會關心視圖,視圖的操作通常以 API 的形式到達的這裡,所以並不存在表現層邏輯的問題。但是涉及在界面的開發中我們必須要解決這個問題,我們都同意表現層邏輯需要和視圖的實現分離,那麼分離之後放哪呢?目前看來服務層是一個不錯的選擇,因為 1) 表現層邏輯確實和用例相關;2) 服務層也確實是和視圖分離的。服務層和視圖的合作方式也非常簡單,通常是把事件委託給服務層處理而已。React 示例代碼如下:

function TodoComponent() {
const serviceLayer = new ServiceLayer();
function onComplete(todo) {
serviceLayer.completeTodo(todo);
} 
function onDelete(todo) {
serviceLayer.deleteTodo(todo);
}
return (/.../)
}

就像上面說的,服務層其實是一個舶來品。服務層在後端上下文中需要解決的問題與前端並不重疊。但主動權掌握在我們手中,我們可以豐富服務層的職責,讓它為我們提供更好的服務——比如 selector。

selector 的作用並不是僅僅把組件所需要的屬性選擇出來而已。它是組件與領域模型之間的緩。因為組件並不知道自己會被用在何處,所以它不需要也不應該關心在它所屬的應用內 store 包含的是什麼樣的業務模型。如果讓組件直接擁有關於 store 的知識反會產生耦合。這個問題可能在 React 中會有所緩解,因為有 mapStateToPropsreselector 作為天然的屏障。但是在 Angular 中,因為依賴注入的關係很容易產生這一的問題。比如直接和 store 打交道的例子:

@Component({
selector: 'List',
templateUrl: './list.component.html',
styleUrls: ['./list.component.scss']
})
export class ListComponent implements OnInit {
constructor(private todoStore: TodoStore) {
}
ngOnInit() {
this.data = this.todoStore.todos.filter(t => !!t.active);
}
}

在上面這段代碼中,ListComponent 是通用的表現層組件,但是確直接對具體的 TodoStore 進行引用,造成了和具體業務的強耦合,降低了組件複用性。可以修改為對 ServiceLayer的引用

@Component({
selector: 'List',
templateUrl: './list.component.html',
styleUrls: ['./list.component.scss']
})
export class ListComponent implements OnInit {
constructor(private serviceLayer: ServiceLayer) {
}
ngOnInit() {
this.data = this.serviceLayer.getData();
}    
}

這樣一來ListComponet 的職責會更加明確更加通用,當開發人員需要僅僅對視覺功能進行修改時可以降低業務邏輯造成的干擾。又或者當開發人員需要修改獲取數據的邏輯時僅僅修改 serviceLayer.getData方法即可,這也呼應了我們之前所說的單一職責。

前端架構101(五):從Flux進化到ModelViewPresenter

同時補全 UI 與服務層的獲取數據的流程,我們便得到了最終上圖的結果。注意,上圖中數據流依然是單向的。也就是說上圖中的架構設計在 React 或者是 Redux 中是適用的。

截止到現在“服務層”似乎已經有些偏離它原始的涵義,我更願意親切的稱之為 Presenter,MVP(Model View Presenter) 中的 Presenter

MVP

MVP 的實現有兩類,一類稱為 Passive View,另一類稱為 Supervising Controller

  • Passive View: 顧名思義如 passive(被動)所示,在這個模式中 View 是不包含任何邏輯的,它是被動的被調用方。View 和 Model 完全被Presenter 隔開,Presenter 充當中介的角色分別與兩者溝通。Presenter 可以監聽的 Model 層上的一些事件。當數據發生修改時,事件就會被觸發,接著 Presenter 再通過 View 上暴露的方法對 View 進行數據更新。
前端架構101(五):從Flux進化到ModelViewPresenter

  • Supervising Controller: Presenter 會負責響應用戶的 UI 操作,但與 Passive View 最大的不同在於 View 會直接與 Model 打交道,並且與 Model 進行數據綁定。在有的實現中 Presenter 的職責還包括就是將 Model 數據傳遞給 View
前端架構101(五):從Flux進化到ModelViewPresenter

相對於 MVC,MVP 在桌面端和 web 端的概念更統一一些。

所以很顯然,Supervising Controller 模式與我們上面描述的服務層模式,乃至 Redux 都更加契合。總結下來,前端領域 View、Presenter、Model 的分別職責如下:

ResponsibilityData
View1. Render; 2. Delegate;n/a
Presenter1. flow control; 2. user gesture response; 3. state selector/converterPresenter Data
ModelBusiness LogicDomain Data

至於如何實現,我認為目前的所有框架都支持這一套架構的實現,只不過 Redux 類型的框架可能相對 Object 類型的框架實現起來會彆扭一些。

這樣的分配會影響到我們下一個談論的話題,測試。

測試

前端架構101(五):從Flux進化到ModelViewPresenter

不知道大家是否熟悉上圖中的測試金字塔,簡單來說我們可以根據測試所涉及的範圍來將測試類型劃分為這些等級,最底層的是粒度最小的單元測試,最頂層的端到端的應用級測試。我在Google搜索測試金字塔的時候不同圖片會有少許差異,但總的來說和我上面的描述大致相似。

就我個人的經驗而言,在編寫測試時不可能覆蓋所有這些類型的測試,這當中有交付壓力與人力成本的考慮。

我們再次回到最終版本的這個圖:

前端架構101(五):從Flux進化到ModelViewPresenter

在經過重新對代碼進行組織之後,現在我們需要回答這個問題,應該對哪些代碼進行測試?

  • UI:我最不建議對純 UI 代碼進行測試,這裡所說的純 UI 指的是類似於 React 中的 Dump Component. 因為UI 測試的效率是非常底下的,相對於純粹代碼性的測試,不僅 UI 測試的啟動和運行都略遜一籌,編寫起來也費勁,通常你需要查找出不同的元素,然後模擬的用戶的操作,最後再對頁面元素做驗證。

    建議 UI 測試只在非用不可的情況下編寫,比如你設計了一個極其複雜的組件,例如 handsontable, 它純粹是表現層的,組件對用戶操作的反饋是其中非常重要的功能,那麼此時對 UI 的測試才是有價值的。

  • Service Layer / Presenter:這裡是我最推薦編寫測試的地方。首先這裡的測試對象通常面向的是代碼,因為服務層通常由 store 或者是類進行封裝;其次這部分的邏輯非常重要,它包含的是所有的用戶用例,用戶用例即“用戶能幹什麼”的終極體現。如果這部分都沒法保證的話,那麼我們的應該基本上沒有任何用處。

    在對用戶用例進行測試的同時,其實也間接的在對業務模型進行測試。因為你最終需要驗證用戶的一頓操作之後業務的數據是否如期望所示,例如是否按期望進行了刪除、是否發生了修改

當然凡事沒有絕對,如果你的應用內有非常重要的功能,例如工具類中的一個非常重要的算法,嚴格的業務模型,那麼也可以單獨對這些功能做單元測試。

關於測試,我推薦閱讀 Kent 的關於前端測試的一系列的文章:The Testing Garden of Kent C. Dodds,我個人是是非常贊同他主張的一些列測試策略,例如:

  • Test use cases, not code.
  • Write tests. Not too many. Mostly integration.

相關文章

Vue2.x源碼解析系列四:數據響應之Observer

vue2.x源碼解析系列二:Vue組件初始化過程概要

關於Vue和React區別的一些筆記

三分鐘從零單排js靜態檢查