iT邦幫忙

2024 iThome 鐵人賽

DAY 16
0

https://ithelp.ithome.com.tw/upload/images/20240930/20168201iXSzyBVoEP.png

今天要介紹的是 MVC 模式,這和之前介紹的模式有點不同,在 GoF 書中,他們認為沒有將 MVC 稱為設計模式,而是視為一組用來建構 UI 的類別,他們認為這是 3 種設計模式的結合/變體:Observer、Strategy、Composite 模式,而根據 MVC 實作方式,還可再應用 Factory 和 Template 模式,所以與其說是設計模式,也許可稱呼 MVC 是一種方便開發者組織、規劃應用程式架構的實作方式。因此這篇我就先不用情境、問題、解決方案的方式來介紹它(雖然要套這個架構應該也是可以)。

在介紹 MVC 是什麼之前,先提一個生活情境,假設小美的房間是這樣擺放的:她在衣櫃的一疊長褲裡放了一件短袖上衣又放了一對耳環,在桌上的筆筒裡放了好幾隻原子筆,但中間又摻雜了一枚戒指,在書櫃中的一排書籍中放了一盒粉餅又放了另一對耳環,那麼現在問題來了,如果今天小美趕著出們,她要去哪裡找到她要的上衣、褲子、化妝品和飾品? 如果小美已經習慣某些特定物品會放在特定位子,房間內的物品擺放對她來說是亂中有序(?),那也許她還是可以從這些看似混亂的擺放中找到她要的東西,但今天如果是小美的室友小華想和小美借某樣物品,那小華在小美的房間可能會迷失方向...不知道該從哪裡找到物品🤯,因此小華會和小美說,妳同樣類型的物品可以擺一起嗎?文具區統一放文具、飾品區統一放飾品、化妝品區統一放化妝品,不要全部混雜在一起! 小美聽一聽覺得有道理,所以開始了房間整理計畫,將自己的同類型物品統一擺放在同個區域,而這個「同類型物品統一擺放在同一區」的房間整理小技巧,和 MVC 的概念其實十分相似。

在沒有 MVC 之前,如果我們要實作一個簡單計數器,我們可能會這樣寫:

<!-- html  -->
<button id="increment">increment counter</button>
<p id="counter">0</p>
// JavaScript
const incrementButton = document.getElementById('increment');
const counterDisplay = document.getElementById('counter');
let counter = 0; 

incrementButton.addEventListener('click', function() {
    counter++; 
    counterDisplay.textContent = counter; 
});

一切看似非常正常,運作也沒任何問題,但如果今天要繼續擴展這個計數器功能,例如加上 reset、加上減少 counter 計數功能,那程式碼就會逐漸變得複雜,因為我們需要一邊修改邏輯,一邊還要顧及畫面顯示的樣子,隨著東西越來越多就會越來越像小美的房間(?),也許寫這段程式的開發者本人(小美本人)還可以知道自己在寫什麼、知道畫面的程式碼在哪、功能邏輯的程式碼在哪,但如果是其他開發者(室友小華)要一起協作,就會迷失在這個雜亂的程式碼當中...,而這種非結構化且難以維護的程式碼就稱為「義大利麵程式」(spaghetti code),這類程式碼的問題是:

  • 很難理解與維護:UI 與功能邏輯混雜在一起寫,會讓程式碼很難維護與追蹤,也很難除錯,因為混雜在一起很難去指出現在出錯的是誰,而如果要調整也需要多方考量以避免出錯
  • 很難協作分工:承上,因為很難理解,其他人在看到這段程式碼時需要花時間追蹤理解整段程式碼邏輯與流程,很難分工一起完成功能
  • 很難測試:因為都混雜在一起寫,無法一次只測一個重點(例如只測 UI 或是只測功能邏輯)

那我們該如何解決?就是依照小華的建議,將「同類型物品統一擺放在同一區」,對應程式碼,就是讓程式的「關注點分離」(Separation of concerns,SoC),讓每個部分都有各自的關注焦點,通常會區分為三個關注焦點:介面、資料與邏輯,而這也是 MVC 模式的核心概念,MVC 鼓勵以關注點分離的方式來改善應用程式的組織。

MVC 是什麼?

MVC 是指程式由三個部分組成:

  • Model:用來存放和管理應用程式的資料,通常會包含資料驗證的功能(如:要改 Model 資料時先驗證傳入的資料格式是否正確)
    • 運作:當 Model 改變時,會通知其 View
  • View:以視覺方式呈現 Model 目前的資料/狀態
    • 運作:會觀察 Model 並在 Model 更改時收到通知,做出對應回應、更新;使用者可和 View 互動以更改 Model 資料,當使用者和 View 互動時,會觸發 Controller 更新 Model
  • Controller:Model 和 View 間的中間人,管理應用程式邏輯
    • 運作:在使用者操作 View 時更新 Model

示意圖如下:
https://ithelp.ithome.com.tw/upload/images/20240930/201682019Aonrwo2Rs.jpg
圖 1 MVC 示意圖(資料來源:自行繪製)

以 MVC 來改寫上面的計數器範例,可以改成這樣(demo code):

<!-- html 維持不變 -->
<button id="increment">increment counter</button>
<p id="counter">0</p>
// Model
class CounterModel {
    constructor() {
        this.counter = 0; // 儲存資料
    }

    // 存取資料的方法
    getCounter() {
        return this.counter;
    }

    setCounter(value) {
        this.counter = value;
    }
}

// View
class CounterView {
    constructor() {
        this.incrementButton = document.getElementById('increment');
        this.counterDisplay = document.getElementById('counter');
    }

    // 綁定事件處理器,handler 由外部傳入
    bindIncrement(handler) {
        this.incrementButton.addEventListener('click', handler);
    }

    // 更新 UI,要更新的值由外部傳入
    updateCounterDisplay(value) {
        this.counterDisplay.textContent = value;
    }
}

// Controller
class CounterController {
    constructor(model, view) {
        this.model = model;
        this.view = view;

        // 綁定 view 的事件,讓 incrementButton 被點擊時可觸發 handleIncrement 函式
        this.view.bindIncrement(this.handleIncrement.bind(this));

        // 初始化畫面的顯示
        this.view.updateCounterDisplay(this.model.getCounter());
    }

    // 處理增加計數器的邏輯
    handleIncrement() {
        // 業務邏輯:增加計數器的值,這裡會更改 Model 中 counter 的值
        const newCounterValue = this.model.getCounter() + 1;
        this.model.setCounter(newCounterValue);

        // 更新 view 顯示的值
        this.view.updateCounterDisplay(newCounterValue);
    }
}

// 初始化 MVC 元件
const model = new CounterModel();
const view = new CounterView();
const controller = new CounterController(model, view);

改寫後程式碼分為 Model、View、Controller 三個部分:

  • Model(CounterModel):只負責存放 counter 的資料,紀錄目前計數器的值,並提供存取和設定資料的方法
  • View(CounterView):只負責顯示值到畫面上,會透過 bindIncrement 來綁定事件處理函式(handler),但 handler 由外部決定
  • Controller(CounterController):負責綁定 view 的事件,決定使用者輸入(點擊)後要執行什麼邏輯,並定義更新計數器的邏輯,其中後更改 Model 中的資料。

乍看之下可能會覺得,哇程式碼怎麼變更長了,一個簡單功能卻要寫這麼多程式碼,這是因為這次範例很簡單,當應用程式變得更複雜、功能更龐大時,才比較能看出 MVC 關注點分離的優點。

前面有提到 MVC 運用了多種設計模式,其中 Model 和 View 之間是主體與觀察者的一對多關係,應用了 Observer 模式,當 Model 變化時,會通知它的觀察者(View)資料已更新,雖然我們上面的範例沒有實際實作到 Observer 的這段邏輯,個人覺得應用 Observer 模式算是傳統、最初的定義,但隨著時間演變,工程師們也有各自的變體和實作方式,我覺得只要和團隊溝通好,並且能解決混雜的義大利麵程式碼問題就可以了👌,有沒有真的實作 Observer 不是重點,重點是有沒有解決難維護、難測試的問題(僅代表個人觀點,不代表 《JavaScript 設計模式學習手冊 第二版》作者觀點);而 View 和 Controller 間則是 Controller 協助 View 回應,應用了 Strategy 模式。
(補充:Strategy 模式是一種定義和封裝一系列算法/邏輯的方式,並且這些算法可被替換。)

另外補充,此篇以 JavaScript 以及前端應用來說明 MVC 該如何運用,但其實 MVC 並不僅限於前端,後端也可以遵循 MVC 架構開發、不是網頁也可以用 MVC 的架構,MVC 是一種架構應用程式的概念,不限於前後端、也不限於程式語言。

React 與 MVC

React 是否屬於 MVC 架構呢?可分為兩個面向來看,一是單一 component 內,二是整個應用程式。

單一 component

從單一 component 來看,可以視為一種 MVC 架構:

  • Model:利用 useState 儲存的資料(狀態)
  • View:component 回傳的 React element 會描述期待的畫面樣貌。開發者宣告 react element,React 比較新舊 React element,修改差異處對應的 DOM 元素,產生實際 View (開發時通常會以 JSX 語法來建立 React element,詳細可參考[React] 了解 JSX 與其語法、畫面渲染技巧)
  • Controller:React 提供的 hooks、開發者自定義的 custom hooks 或元件內定義的事件處理器都可作為邏輯控制,這些方法會處理使用者的操作和 state 的更新

以一個 Counter component 為例,我們以 useState 儲存 count 的資料(Model),以 handleDecrementhandleIncrement 來定義事件處理器,以控制使用者互動的邏輯(Controller),最後回傳的 JSX element(也就是建立了 React element)則代表要呈現的介面(View)。程式碼請見連結

import { useState } from "react";

const Counter = () => {
  // Model: 以 useState 儲存 counter 的狀態
  const [count, setCount] = useState(0);

  // Controller: 定義事件處理器,負責控制使用者互動的邏輯
  const handleDecrement = () => setCount(count - 1);
  const handleIncrement = () => setCount(count + 1);

  // View: React element 表示元件要呈現的 UI
  return (
    <div>
      <h1>Counter: {count}</h1>
      <button onClick={handleDecrement}>Decrement</button>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  );
};

export default Counter;

整個應用程式

在談「整個應用程式」之前,先簡單敘述一下我定義的「整個應用程式」是什麼,畢竟如果有一個超簡單的計數器應用程式,那其實可以用單一 component 就能完成整個應用,那單一 component 不就也等於整個應用程式(?),因此這裡我認知的「整個應用程式」是指一個有多 components,並且有多個頁面(不同路由),具有複雜狀態以及多樣使用者互動行為的一個應用程式,舉例來說,電商網站的前端應用程式。
以 React 整個應用程式來看,《JavaScript 設計模式學習手冊 第二版》作者 Addy Osmani 認為 React 不屬於 MVC 框架,因為 React 是一個建構 UI 的 JavaScript 函式庫,React 官方網站也明確介紹自己是「The library for web and native user interfaces」,從 「user interfaces」可看出 React 是用來渲染 View 的函式庫,因而 Addy Osmani 認為 React 缺乏中央 Controller,不屬於 MVC 框架。

不過我覺得如果搭配 React 官方提供的 hooks,其實還是能算作 MVC 框架的,而區分的方式和單一 component 十分類似:

  • Model:以useStateuseContext 儲存的資料(狀態)
  • View:各 component 回傳的 React element
  • Controller:React 提供的 hooks 或開發者自定義的 custom hooks,或是事件處理器的函式

整個應用中,資料可透過 useStateuseContext 來定義(Model),並透過 props 或 context 來傳遞資料到各 component 讓介面(View)可以顯示資料,而在 component 內又可以綁定事件處理器(Controller)來應對使用者互動,React 提供的 setState 方法則可以協助更新資料(Controller),整體來說是符合 MVC 框架以及互動方式的。
這裡先不談 React 常見的狀態管理工具 ReduxZustand 等,雖然複雜應用不可避免要用一些狀態管理工具,但這裡我想先聚焦 React 本身而非整個 React 生態系構成的應用,在不使用這些工具情況下,我覺得就可視為 MVC 了~

另外,Summer 大大有在這篇文章敘述 Redux 在整個 React 應用程式如何實作 MVC,推薦一讀。

優點

應用 MVC 的優點如下:

  • 可維護性高:因為邏輯、介面和資料被分開處理,修改其中一部分是不會影響其他部分,例如改動資料相關邏輯(Model)就不會影響到介面(View)
  • 增加可測試性:因關注點分離的,各區塊耦合性低,可以專注針對資料、邏輯或介面進行單元測試
  • 便於分工、提高開發效率:開發者可各自專注在不同模組,負責核心邏輯的和負責視覺 UI 的可以同時工作而不會互相干擾
  • 重用性高:Model 和 Controller 可以在多個 View 之間重用,代表可用相同的資料和業務邏輯來呈現不同的 UI 介面,相對的,View 也可應用於不同 Model,代表相同 UI 介面可呈現不同的資料內容

缺點

應用 MVC 的缺點如下:

  • 提高複雜性:對於簡單的應用來說,MVC 的架構可能會過於複雜。分離邏輯、資料和介面會帶來額外的開發成本和學習曲線,開發前也會需要更多時間心力去規劃與設計
  • 過度設計的風險:開發者為了遵循 MVC 的設計原則,有時可能會過度設計,導致過多的層級和複雜程式碼結構,但不一定符合應用程式當下的需求
  • 過多檔案和模組:使用 MVC 將應用程式分成 3 個部分開發,可能導致專案內檔案數量增加,當應用程式變大時會較難管理、組織檔案
  • 需要較高的協作性:MVC 分離關注點可讓開發者同時工作、便於分工,但可能需要更多的協作和溝通,開發者之間需要明確瞭解其他部分負責的任務範圍,避免重疊或衝突

Reference


上一篇
[Day 15] Proxy 模式
下一篇
[Day 17] Promise 模式
系列文
30天的 JavaScript 設計模式之旅23
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言