今天要介紹的是 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),這類程式碼的問題是:
那我們該如何解決?就是依照小華的建議,將「同類型物品統一擺放在同一區」,對應程式碼,就是讓程式的「關注點分離」(Separation of concerns,SoC),讓每個部分都有各自的關注焦點,通常會區分為三個關注焦點:介面、資料與邏輯,而這也是 MVC 模式的核心概念,MVC 鼓勵以關注點分離的方式來改善應用程式的組織。
MVC 是指程式由三個部分組成:
示意圖如下:
圖 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 三個部分:
bindIncrement
來綁定事件處理函式(handler),但 handler 由外部決定乍看之下可能會覺得,哇程式碼怎麼變更長了,一個簡單功能卻要寫這麼多程式碼,這是因為這次範例很簡單,當應用程式變得更複雜、功能更龐大時,才比較能看出 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 架構呢?可分為兩個面向來看,一是單一 component 內,二是整個應用程式。
從單一 component 來看,可以視為一種 MVC 架構:
useState
儲存的資料(狀態)以一個 Counter component 為例,我們以 useState
儲存 count 的資料(Model),以 handleDecrement
和 handleIncrement
來定義事件處理器,以控制使用者互動的邏輯(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 十分類似:
useState
或 useContext
儲存的資料(狀態)整個應用中,資料可透過 useState
或 useContext
來定義(Model),並透過 props 或 context 來傳遞資料到各 component 讓介面(View)可以顯示資料,而在 component 內又可以綁定事件處理器(Controller)來應對使用者互動,React 提供的 setState
方法則可以協助更新資料(Controller),整體來說是符合 MVC 框架以及互動方式的。
這裡先不談 React 常見的狀態管理工具 Redux 或 Zustand 等,雖然複雜應用不可避免要用一些狀態管理工具,但這裡我想先聚焦 React 本身而非整個 React 生態系構成的應用,在不使用這些工具情況下,我覺得就可視為 MVC 了~
另外,Summer 大大有在這篇文章敘述 Redux 在整個 React 應用程式如何實作 MVC,推薦一讀。
應用 MVC 的優點如下:
應用 MVC 的缺點如下: