Skip to main content

MVVM 前端之禪 - 以 React 為例

前言

感謝前輩 Duncan Du 傳遞這麼簡明易懂的前端架構模式,也感謝 Shun 和其他前端夥伴一起把團隊的 style guide 變得清晰完整。

在套用某些框架或是設計模式時,我們總是要思考專案的生命週期和規模,對於一個簡單的 todo list 或是沒有複雜狀態的網頁,要動用 Redux 過於厚重。但如果是以 5 年以上開發的週期以及超過 3 人的團隊進行思考,Redux 是個穩當而合理的選擇。

確實,如果只是短週期或是小規模的專案,你也可以把 Fetch API、商業邏輯、UI 介面全部寫在同一個元件,但隨著時間以及規模的擴張,會發現這是一個可怕的災難

套用這些設計模式會讓開發速度比較快嗎?不一定,簡言之我認爲此設計模式有三個好處

  • 好閱讀 : 開發過程中我們有一半的時間都是在檢視閱讀先前的程式碼,而一個結構明確而可讀性高的程式庫,會讓後人的開發思路更加絲滑
  • 好寫測試 : 測試是安全網,避免改 A 壞 B
  • 好擴充 : 提高複用性,盡量「對擴充開放,對修改封閉」

核心思想 : MVVM

有一天驚覺,這套模式就是 MVVM 啊!

或許會看到有幾篇文章說 Vue 是 MVVM 而 React 不是,但這些東西都是圍繞這些「框架本身」的實現而討論的。

我們可以把 MVVM 的思想往上拉到 Application 層級 —— 如何將 API fetch、資料邏輯轉換、UI 元件呈現、狀態互動,打包成清楚的結構

M for MODEL

掌管應用的數據以及處理邏輯

使用 Redux 作為資料與狀態的 Single Source of Truth (但還是得視情況使用 local state)。

  • state 是狀態數據

  • 把處理數據的邏輯封裝成 reducer,變成可以執行的 action

    • createSlice 初始 state、reducer、action 可以一次幫你做好
    • reducer : 收到 action 的 payload 後,如何對 state 進行資料處理
    • action : 夾帶需要的 payload 資料,請 reducer 對 state 進行數據操作 (我們通常會說 dispatch 一個 action 到 reducer )
  • 使用 createAsyncThunk 來 fetch API 數據,存放到 state

    • 我們的原則 : thunk 不做複雜的複合邏輯處理(例如單一 thunk 先打 POST 再打 GET,再 dispatch 其他 action)
    • 每一隻 thunk 的功用就只是單純 fetch 一隻 API,並把結果各別存放到 state
    • 例如有 postAddTodoResponsegetTodoListResponse 這些 property object 記錄著 fetch 的「原始結果」
    • 為什麼要這樣做? thunk 並不好測試,如果把邏輯全部丟在裡面會過於臃腫,所以我們選擇 thunk 就只是單純 fetch 一隻 API 並儲存原始結果,並把資料轉換邏輯交給好測試的 selector

如果每個 thunk 都只打一隻 API,那碰到需要先 POST 再 GET 這種順序性的議題該怎麼辦?

其實有多種處理方法,你可以選擇另闢蹊徑使用其他模式,或是使用 createListnerMiddleware (題外話,Redux 官方說這可以取代 saga 或 observable)。

我們會把 isFetch 紀錄到 POST 的 response state,再由 useEffect 處理 : 當 POST 完成之後,就進行 GET。這種方式是有一點迂迴了,但算是 thunk 邏輯極簡化的代價。

結論

  • Redux store 存放的 state 是我們的主要數據存放庫
  • 把「操作數據」的邏輯封裝 reducer 和 action
    • 在 redux 中,reducer 是 pure function,這很重要,代表測試好
  • 使用 thunk fetch API,thunk 內部的盡量簡單化,盡可能只單獨 fetch 一隻 API

VM for View-Model

View-Model 是 View (UI 介面) 和 Model 之間的橋樑

View-Model 的職責是把 Model 的數據轉換成 UI 好渲染的格式(而且 Model 數據更新時也會同步讓 UI 更新)。

再來是把操作 Model 數據的方法封裝成簡單即用的 handler 給 UI ,UI 觸發事件時(例如點擊)ViewModel 就會通知 Model 進行數據操作。

  • 使用 createSelector 轉換 state,變成 UI 方便渲染的格式
    • 我們時常需要把 API 的原始資料進行資料轉換,才方便 UI 介面進行渲染
    • createSelector 不會更動原始 state,不過可以自動轉換 state 變成想要的形狀,在元件內直接取用
    • 通常在 selector 對 API 原始 response 進行轉換
    • 要橫跨不同 reducer 之間的狀態來進行綜合判定時,也很好用
  • 將元件拆出 container 層和 UI 層,進行關注點分離
    • container 層專注於數據整合而不直接參與介面
    • container 調用 selector 向 Redux store 拉取資料,引入 actions 並把 dispatch actions (數據操作) 封裝成 event handler (例如 onClick、onChange)
    • container 把拉取的資料以及封裝好的 handler,以 props 的形式傳遞給 UI 層

V for View

呈現介面,單純接收 props,從 props 中渲染內容,並觸發 props 中的 handler

就是上述的 UI 層,後續會細分為中型的「widget」和更小「unit」(前輩傳承的區分法 XD)

結論

  • 將「數據操作」交給 reducer (Model 層)
  • 將「數據轉換」交給 selector (View-Model 層)
  • 將存取 redux 的權限集中在 container 層

reducer 和 selector 都是 pure function,mock data 並不會很複雜,單元測試好寫!

而 UI 層只負責接收 props 和 handler,渲染內容以及觸發事件,撰寫元件測試也算是方便

這樣就可以呼應「測試金字塔」,以單元測試作為大宗,確保狀態的操作與格式轉換正確,再來是元件測試次之,最後整合測試與 E2E 則是重心放在重要的操作流程。

題外話是 Redux 官方對於測試的論述,我並不是那麼同意。他們引用大佬 Kent C. Dodds (Testing Library 作者),大意是說「測試的流程越接近實際操作,我們對測試的信心會越高,偏好寫整合測試,而不過度在意 Redux 實現的細節」。

「測試的流程越接近實際操作,我們對測試的信心會越高」這句話本身沒有問題,但沒有考慮到 單元測試和整合測試的開發時間是截然不同的

整合測試可能需要花很多時間來架設 mock 初始化,以及設計各種測試情境。反之,單元測試只要確保 state 從 A 轉換到 B 是正確就好(而且 GPT prompt 設計得好的話,單元測試的生成又快又準)。

我們只要花些時間開發,就可以基本確保複雜的 state 操作是安全無虞的,在我看起來單元測試像是壽險,整合測試像是癌症險,都很重要。但我們要先把最關鍵的人身保障做足,再來考慮癌症險。