前端架構101(三):MVC啟示錄:模塊的職責,作用域和通信

NO IMAGE

在上一篇中,我提出了一個應用中常見的問題:如何在多個視圖中共享同一份數據,並且保證它的改動能夠同步到不同的視圖中去?

建議從這個系列的第一篇開始閱讀

針對這個問題我給出了兩類解決方案:一類是用戶行為驅動的意識流編碼,比如當我選擇將素材回滾到某個歷史版本時,我想當然的手動去更新每一個視圖:

historyView.rollbackTo((historyInfo) => {
const {size: { width, height }, color, history, layer } = historyInfo;
infoView.updateHeight(height);
infoView.updateWidth(width);
colorView.updateColor(color);
historyView.updateHistory(history);
layerView.updateHistory(layer)
})

另一類是 MVC 解決方案,以 Backbone 為例,它通過廣播事件來通知各個視圖的更新發生了

const HISTORY_ROOLBACK_EVENT = 'rollback';
// InfoView.js:
initialize: function () {
this.listenTo(historyModel, HISTORY_ROOLBACK_EVENT, this.updateSizeInfo);
},
// ColorView.js:
initialize: function () {
this.listenTo(historyModel, HISTORY_ROOLBACK_EVENT, this.updateColorInfo);
}

很明顯第二種解決方案會更好,從維護代碼的體驗上說,我們不用去主動的維護視圖間的調用關係。每當有視圖添加或者被刪除時,不需要找到它的每一個消費方把這層調用關係做對應的修改。

但不知道你們有沒有考慮過為什麼第二類方案會給我們帶來這樣的便利,或者說第一類會讓人覺得糟糕;我們是否能夠從中得到一些啟示來幫助我們今後的代碼也變得同樣的利於維護?在這一篇的內容裡我們詳解這些編程中的原則和模式。這將為我們之後的內容奠定基礎

分離關注點

“分離關注點”,即 Separate Of Concern,以下簡稱 SOC。

我們的前後端代碼庫是分離的,前後端團隊是分離的,component 和 store 是分離的,component 之間也是分離的。分離是可維護性的出發點,有助於我們理解系統,有助於我們專注局部。 有的是框架使然,有的實踐使然。再強調一遍,SOC 解決的不是幫助我們解決功能性問題,而是非功能性需求,“意大利麵”和“大泥球”代碼當然能工作,但是維護的成本極高

但是在約束之下呢,如果脫離了框架,迴歸到依靠人工編寫的代碼時,我們的代碼依然是良好分離的嗎?思考下面的這些問題有助於你理解“分離”這件事:

  • 在早期的前端項目中提倡樣式與腳本的分離,現在則是提倡以腳本和樣式為整體單位的組件分離?為什麼會出現這樣的變化?
  • “狀態”和“組件”是什麼樣的關係?它們應該被分離嗎?比如分開存放在 store 和 component 中
  • 重複的代碼務必需要被移除嗎?

不知道你有沒有讀過原版 Uncle Bob MartinClean Architecture,我個人在閱讀過程中印象最深的一個詞組是 “axis of change”。也就是說分離關注點並不是無腦的一刀切,需要從變化的頻率(rate)和變化的原因(reason)去考慮。在實際過程中你可以能還需要考慮更多的 “axis”,也就是更多的維度,比如 scope,團隊。

但無論如何,我個人的經驗是,“分離”問題大體上需要回答這幾個問題

  • 我們應該按照什麼樣的規則分離關注點?——職責
  • 關注點的“可見度”(在系統內能做的,能訪問的數據)有多少——作用域
  • 不同關注點之間如何協作?——通信

其它的問題同樣需要解決,但是在我看來這三個問題是最重要的

單一職責(Single Responsibility Principle)

我們再來重新審視一下最初的“意識流”代碼:

historyView.rollbackTo((historyInfo) => {
const {size: { width, height }, color, history, layer } = historyInfo;
colorView.updateColor(color);
historyView.updateHistory(history);
layerView.updateHistory(layer)
})

這是一份存在於視圖層的代碼,但它同時再做好幾件事情:

  • 響應用戶的請求
  • 更新其它視圖(的業務數據)
  • 重新獲取業務數據

喔,它同時把 MVC 的事情都做了,但這一定都不酷。如果一段代碼職責越多,改變任何其中一個功能對其它功能產生影響的可能性也就越大。這種影響可能不僅僅限於這個模塊,還會牽扯到整個應用。這樣的代碼是脆弱的。

在 Clean Architecture 中,單一職責意味著 ”A CLASS SHOULD HAVE ONLY ONE REASON TO CHANGE“。如果你發現當你修改任何功能都要修改同一份代碼的時候,就要小心了。

如果你有 React 經驗的話,相信你一定知道 Smart Component 和 Dumb Component 的概念(或者又稱為 Container Component 和 Presentational Component),前者負責存儲狀態,加載數據;後者只負責渲染。這種模式就是很好的職責劃分的例子。

前端架構101(三):MVC啟示錄:模塊的職責,作用域和通信

值得注意的是,職責和實現並不是絕對統一的。我們通常使用專業的狀態管理框架比如 Redux 或者 Mobx 進行狀體管理,但在 Container Component 的場景裡一個本用於可視化的組件的成了臨時的狀態管理工具並且它也做的非常不錯。事實上目前 React 自身提供的功能已經能實現絕大部分的狀態管理功能,比如通過 useReducer,通過 ContenxtAPI 等等。

我想強調的是:正確的設計程序、對模塊進行職責劃分比強行去套入框架重要。上面的段落裡我列舉了一個狀態從框架 “降級”到組件中的正面例子。類似的,你可以想象一個把組件狀態“升級”到全局 store 的反面例子。之所以稱之為反面,是因為有些類型的組件狀態,比如表現層狀態,比如是否高亮,是否摺疊,並不適合加入到全局狀態中。它們通常是局部的,帶有生命週期的,更適合伴隨著組件產生和消亡。

如果你現在已經感到疑惑的話,我再提出一個之後我們會談到的模式:組件的狀態既不存在於組件內部中,也不存在於全局 store 中,而是存在於獨立第三類狀態文件中,這種模式對組件內的狀態再一次進行了分離((下圖箭頭的反向代表數據的傳遞順序,並非模塊的依賴順序))

前端架構101(三):MVC啟示錄:模塊的職責,作用域和通信

”作用域“

之所以加上引號是因為可能用英文描述更加達意,比如 scope、boundary、knowledge。總之我們需要回答的問題是,當不同的關注點被分離成獨立的模塊後,它的知識邊界在哪裡?它們能夠知道些什麼,不應該知道些什麼

我猜你的 React 項目一定有類似於 util.js 或者是 helper.js 一類的工具類庫,它們通常負責做一些類似於轉化日期格式,生成 uuid 一類的邊角料工作。我們都同意這類代碼完全可以和項目無關的,甚至把文件複製到另一個 Angular 項目中它們依然可以生效,它們對項目裡的 store 是用什麼解決方案管理的,甚至有沒有 store 都完全不知情。

但如果我們把邊界再縮小再模糊一些呢,store 應不應該知道 component 的存在?component 應不應該知道 store 的存在?如果繼續把邊界縮小一些呢,父組件是否應該知道子組件的存在?子組件是否應該知道父組件的存在?你也許會說現有的機制裡面 store 不必知道 component 的存在,React 框架父組件也必須知道子組件的存在。但假設給你一個機會,能夠讓你有無上的權力重寫所有的這些框架,重置它們的機制,不用擔心有任何的副作用,你是會依然遵循現有的方案還是有不一樣的想法?

或許換一個場景能夠幫助你理解這個問題,假設你需要編寫了一段在空調遙控器液晶屏顯示實時時間的程序,將來還需要把它移植到電視遙控器甚至風扇遙控器上,為了避免移植過程中反覆的重寫代碼,你肯定會最大程度的減少直接與硬件接口的調用,或者說至少把它們隔離出來獨立統一管理。因為硬件是千奇百怪的,是會更新換代的,然而你的程序代碼在完成之後基本不會在發生變化了。如果你的核心代碼對外界的信息瞭如指掌,那麼外部的絲毫變化都會影響到代碼的重寫和重新編譯。相反我們需要把變更的成本降到最小

這和我們上面說的 axis of change 依然是保持一致的——把不同變化原因的代碼隔離開。

你也可以把這個當作最大程度的一種解耦

我們使用的外觀模式、jQuery 殊途同歸,只不過它們是站在消費者角度屏蔽系統的複雜性來看這個問題。以 jQuery 為例,瀏覽器和核心業務代碼的關係就類似與我上面提到的硬件與代碼的關係。我們不關心瀏覽器是否真的支持 getElementByClassName,我們仰仗的是$()函數支持我們傳遞 classname 來選擇元素。即使瀏覽器從版本6升級到了版本60,關於這部分代碼也不用做任何改變。甚至放到 Electron 應用中也沒有問題

前端架構101(三):MVC啟示錄:模塊的職責,作用域和通信

所以 store 和組件沒有任何關係,它們都不應該知道對方的存在。換句話說,一個應用的 store 可以僅僅用一個 windows 全局變量就實現是不是;redux 也可以服務於 Node.js CLI 應用是不是。它們天生並沒有必然的邏輯。

說白了這種理想的“不相往來”關係的最高境界是,如果有一天 React 不再開源了而是需要收費了,又或者有更好的視圖層框架出現了,你可以在不改動任何一行 store 代碼的前提下把 React 替換掉。更加理想的狀態的是,因為應用是需要繼續工作的,所以它的業務邏輯依然是有效的,而這些業務邏輯恰恰存在於 store 中,不用發生任何的修改。反之你也可以在不改變 component 的情況下換掉 store 的實現。

最後 store 和 component 是通過各種的 connect 或者 selector 連接起來的。

前端架構101(三):MVC啟示錄:模塊的職責,作用域和通信

假設你使用的 React 渲染視圖層,只需要保證傳遞給 props 的屬性值不變即可。如果其它的框架不支持 props,facade 或者是 proxy

store 和 component 只是關於作用域其中的一個例子。在設計架構中的其它模塊時,你都需要考慮模塊的知識邊界在哪裡。例如當一個模塊被其它模塊引用的越多,它就越難以被替換;當一個模塊引用的模塊越多,他揹負的職責也就越多,就更容易變得難以維護。

最後我照例提出一個承上啟下的問題: 如果你有注意前一篇中舉例的 Backbone.js 和 AngularJS 的話,你會發現兩者的 model 層對 view 是一無所知的。但 view 是清楚 model 的存在的(在 AngularJS 中通過 controller 將數據轉發給了模板,而 Backbone.js 中則是對 model 的直接引用)。於是我們看到了一種模塊間依賴的方向性,即 A 依賴於 B,但 B 不會依賴於 A。這樣的方向有什麼講究?會給我們的應用帶來什麼樣的影響?在後幾篇中會繼續談到

通信

有模塊存在的地方它們間就需要通信。

通信要解決的問題總結起來很簡單:我把數據傳遞給別人,又或者別人把數據傳遞給我;要使用的手段也非常的簡單:

  • 主動 pull:調用模塊的獲取數據方法,例如 store.getData()
  • 主動 push
    • Broadcast: 也就是 pub / sub 模式,向外廣播事件/主題,可是它並不知道誰訂閱了當前廣播的主題
    • Command:命令模式,command 是顯式的調用,指示去即將完成一件事,結果是可以預見的。而 event 則是通知而已。Redux 中的 action / dispatch 就是 command 機制
  • 被 push:監聽事件的發生,在前一篇的 Backbone.js 例子中的 this.listenTo(app.todos, 'add', this.addOne);就是在監聽 todos 集合裡“添加”事件的發生。

但是有一些場景中,這些模式可能是組合混用使用的,比如:

  1. todos發生了修改,廣播update事件,但事件信息中並不包含最新數據

  2. view 監聽 update 事件的發生,但是需要手動調用接口獲取最新的數據

上面只是最簡單的一種混合場景,但是你可以想象在 view 可以繼續發生其他的變化,它產生的變化又以事件的形式廣播出去。所以你會看到一種級聯反應。但恰恰是這樣一種模式,讓 MVC 陷入了一種難以維護的境地,於是 Flux 誕生了,這也是我們下一篇的內容。我們將繼續我們實際的例子,聊聊 MVC 沒有解決的和 Flux 為幫助它解決的問題。

本文也同時發佈在我的知乎專欄,歡迎關注

相關文章

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

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

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

前端架構101(四):MVC的不足與Flux的崛起