JavaScript MV* Patterns | Learning JavaScript Design Patterns, 2e
05 Mar 2024JavaScript Patterns 讀書會 - MV*
Patterns 逐字稿。
歡迎搭配投影片一同服用。
Hi!
大家好,我是 Summer,今天想跟大家分享的是「JavaScript MV* Patterns
」這個主題。
This is Summer!
我是 Summer,我是前端工程師。
「Summer。桑莫。夏天」是我的部落格,主要是前端技術的分享。
「打造高速網站,從網站指標開始!全方位提升使用者體驗與流量的關鍵」這是我寫的書,如果大家對網頁前端效能有興趣,歡迎找來看看。
下面的連結是我的 Instagram、FB 和 Email,歡迎交流!
義大利麵程式碼
Spaghetti code is a pejorative phrase for unstructured and difficult-to-maintain source code. Spaghetti code can be caused by several factors, such as volatile project requirements, lack of programming style rules, and software engineers with insufficient ability or experience. Wiki
我們應該看過「義大利麵程式」,義大利麵程式碼是對非結構化且難以維護的原始碼的貶義詞,這種程式碼看起來非常難懂,就像一盤難以分辨各種成分的義大利麵一樣,所以後來很多開發者就在想辦法改善這樣的程式碼,像是用 MVC 的方式來做改進,通常是指將控制、資訊和顯示的部份分開,這樣程式碼會比較容易維護和測試。
舉例來說,這裡有段程式碼,利用使用者輸入兩個數字和一個運算符號來做計算,然後執行相對應的算術運算,demo。
// Spaghetti code example
const num1 = prompt('請輸入第一個數字:');
const num2 = prompt('請輸入第二個數字:');
const operator = prompt('請輸入運算符號 (+, -, *, /):');
let result;
if (operator === '+') {
result = Number(num1) + Number(num2);
} else if (operator === '-') {
result = Number(num1) - Number(num2);
} else if (operator === '*') {
result = Number(num1) * Number(num2);
} else if (operator === '/') {
result = Number(num1) / Number(num2);
} else {
console.log('無效的運算符號!');
}
console.log('結果:' + result);
這段程式碼看起來很簡單,但是我曾經維護過一段用這樣方式撰寫的超長的程式碼,每次要新增修改功能、解 bug 的時候,都很懂很難追 code,而且都要東改一點西改一點,超容易出錯的,要寫測試也不知道怎麼寫,所以都是靠手動測試,很危險的。
總結來說,以上這段程式碼有些問題:
- 第一,很難懂、很難維護。這段程式碼中沒有明確地區分不同的功能,這樣會使得程式碼難以維護和擴展,因為任何調整都需要仔細 trace 並且修改多個部份的程式碼。
- 第二,很難寫測試。尤其寫 unit test 一次只能測一個重點,才能有效找出問題,義大利麵的程式碼無法隔絕依賴,這種把所有功能都攪在一起的程式碼必須要先重構才能寫測試。
關注點分離
義大利麵程式碼這麼難懂,那就必須來改寫,而改寫概念是「關注點分離」(Separation of concerns,SoC),通常將程式碼分為三個主要關注點:資料、邏輯和界面。
關注點分離的概念通常有幾個代表性的實作架構,像是 MVC、MVP 和 MVVM,接下來會來看這三種應用程式的架構。
這次分享的的範例會用 pure js 和 React 來實作,這樣大家可以看到不同的寫法,並且,我會以分別以元件和專案架構的兩種面向來說明這三種架構方式,以及怎麼寫 unit test,這樣大家會比較清楚實際是怎麼應用的。
MVC
第一個來看 MVC。
MVC 是指程式可由三個部份組成:
- Model:用來存放和管理資料,包含驗證資料的格式等正確性,當 Model 改變時會通知 View(註 1)。
- View:觀察並且顯示 Model 目前的狀況,可經由輸入觸發 Controller 更新 Model(註 2)。
- Controller:管理邏輯、處理使用者的互動。
不過有些人認為 MVC 並不是一種設計模式,而是多種設計模式 (Observer、Strategy、Composite、Factory、Template) 組合起來的架構,是讓開發者方便組織應用程式的實作方式。
圖片來源:MVC for JavaScript Developers
以 MVC 改寫義大利麵程式碼
前面提到的義大利麵程式碼,可以用 MVC 的架構來改寫,點這裡看 demo。
// Model
class CalculatorModel {
constructor() {
this.num1 = null;
this.num2 = null;
this.operator = null;
this.result = null;
}
}
// View
class CalculatorView {
getInput() {
const num1 = parseFloat(prompt('請輸入第一個數字:'));
const num2 = parseFloat(prompt('請輸入第二個數字:'));
const operator = prompt('請輸入運算符號 (+, -, *, /):');
return { num1, num2, operator };
}
displayResult(result) {
alert('結果:' + result);
}
displayError(error) {
alert('錯誤:' + error.message);
}
}
// Controller
class CalculatorController {
constructor(model, view) {
this.model = model;
this.view = view;
}
run() {
try {
const { num1, num2, operator } = this.view.getInput();
this.model.num1 = num1;
this.model.num2 = num2;
this.model.operator = operator;
this.calculate();
this.view.displayResult(this.model.result);
} catch (error) {
this.view.displayError(error);
}
}
calculate() {
switch (this.model.operator) {
case '+':
this.model.result = this.model.num1 + this.model.num2;
break;
case '-':
this.model.result = this.model.num1 - this.model.num2;
break;
case '*':
this.model.result = this.model.num1 * this.model.num2;
break;
case '/':
if (this.model.num2 === 0) {
throw new Error('除數不能為零');
}
this.model.result = this.model.num1 / this.model.num2;
break;
default:
throw new Error('無效的運算符號');
}
}
}
// Main
const model = new CalculatorModel();
const view = new CalculatorView();
const controller = new CalculatorController(model, view);
controller.run();
改寫這個義大利麵程式碼後,分成 MVC 三部份:
- Model:
CalculatorModel
只會負責存放資料,包含兩個輸入數字和運算符號,它不做運算和顯示提示訊息。 - View:在
CalculatorView
負責顯示提示訊息,包含getInput
方法用於取得使用者的輸入,以及displayResult
和displayError
方法用於顯示結果或錯誤訊息。 - Controller:
CalculatorController
負責行程安排,管理使用者輸入、呼叫計算,並將結果顯示出來的流程控制。
以 MVC 改寫後,是不是比較清楚易懂好維護呢?這樣 MVC 的架構能讓程式碼更加模組化和易於理解,也更容易寫測試。
總結來說,MVC 的好處是:
- 關注點分離、好維護、好測試。
- 程式碼能依目的切分不同部份,更易於切分工作、同時開工。
React 元件中的 MVC
以架構面來看,React 不是 MVC 架構的,因為它只有 View 而已,而以元件的角度來看,元件可以是 MVC 架構的:
- Model:利用 state 或 context 或 Redux 等工具來做管理狀態。
- View:渲染與使用者互動的畫面、顯示資料。
- Controller:元件的生命週期 lifecycle method 或 hook、Redux 中的 action、custom hook 都可作為邏輯控制,因為它們負責管理流程與處理使用者的操作(例如:點擊按鈕)並更新狀態。
舉例來說,在這個例子中,<TodoList>
元件利用 useEffect
hook 控制載入 todo list,當元件載入時 useEffect
hook 就會執行,然後呼叫 fetchData
函式,這個函式會去取得資料,取得資料後再更新狀態,demo。
const TodoList = () => {
const [todos, setTodods] = useState([]);
useEffect(() => {
const fetchTodos = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(['Task 1', 'Task 2', 'Task 3']);
}, 500);
});
};
const fetchData = async () => {
const todos = await fetchTodos();
setTodods(todos);
};
fetchData();
}, []);
return (
<div>
<h2>Todos:</h2>
{todos.map((item) => {
return <div key={item}>{item}</div>;
})}
</div>
);
};
React App 中的 MVC
雖然這本書說 React App 不是 MVC 架構的,但對我來說剛好相反,React App 是 MVC 架構的,怎麼說呢?
- Model:利用 Redux 將資料集中儲存在 store 或 context 來做管理狀態。
- View:View 透過 action 觸發 dispatcher 來更新 store 的狀態,當 store 的狀態改變時,重新渲染 View。
- Controller:利用 Redux 的 action 更新 store,或是利用 context API +
useReducer
來進行資料傳遞與狀態管理。
以下是 Redux data flow 概念圖。
回顧前面我們看到的 MVC 的架構圖,React App 有沒有做到架構圖描述的事情呢?
由於 View 接收使用者的輸入、觸發 Controller 更新 Model、根據 Model 的狀態顯示 View,這肯定是有做到的,因此我會認為 React App 可以當成 MVC 架構的實作。
稍後會分享 MVP 和 MVVM 的專案範例,雖然他們都假設有連接後端的能力,但是其實砍掉這一部份也是沒有影響的。
MVP
看了 MVC,接下來看 MVP。
MVP 是指程式可由三個部份組成:
- Model:同 MVC 是用來存放和管理資料。
- View:同 MVC 是顯示 Model 目前的狀況,可經由輸入觸發 Presenter 更新 Model。
- Presenter:管理邏輯、處理使用者的互動,也是 View 和 Model 之間的橋樑,能將 Model 綁定到 View 上呈現。
和 MVC 最大的不同是,View 不再觀察 Model 的變化,而是由 Presenter 觀察 Model 然後更新 View。好處是 View 真的只要負責介面,而將邏輯都抽出來給 Presenter 了。
圖片來源:MVC for JavaScript Developers
以 MVP 改寫 MVC
MVP 是改進 MVC 邏輯拆得不夠乾淨的問題,因為 View 觀察 Model,表示 View 仍包含邏輯。這對想要提升重用性、好維護,以及寫測試來說,MVP 都可以做得更好。
順道一提,這在寫測試的時候就是一種依賴,而我們希望依賴愈少愈好,MVP 能更簡化測試程式、有效測到重點。
舉例來說,前面提到的 CalculatorView
,其中 getInput
便隱含了依照順序顯示不同 prompt 讓使用者輸入數字與運算符號的邏輯。
// View
class CalculatorView {
getInput() {
const num1 = parseFloat(prompt('請輸入第一個數字:'));
const num2 = parseFloat(prompt('請輸入第二個數字:'));
const operator = prompt('請輸入運算符號 (+, -, *, /):');
return { num1, num2, operator };
}
// 略...
}
用 MVP 的概念改寫如下,主要是抽出 View 的邏輯、改放到 Presenter。在這裡把所有隱含的控制邏輯都從 View 抽出來給 Presenter,View 只要做顯示就好了,其他像是根據 Model 的狀態顯示怎樣的 View 都是由 Presenter 來控制,demo。
// View
class CalculatorView {
getFirstInput() {
return parseFloat(prompt('請輸入第一個數字:'));
}
getSecondInput() {
return = parseFloat(prompt('請輸入第二個數字:'));
}
getOperator() {
return prompt('請輸入運算符號 (+, -, *, /):');
}
displayResult(result) {
console.log('結果:' + result);
}
displayError(error) {
console.log('錯誤:' + error.message);
}
}
// Presenter
class CalculatorPresenter {
constructor(model, view) {
this.model = model;
this.view = view;
}
// 邏輯都抽出來給 P
run() {
try {
const num1 = this.view.getFirstInput();
const num2 = this.view.getSecondInput();
const operator = this.view.getOperator();
this.model.num1 = num1;
this.model.num2 = num2;
this.model.operator = operator;
this.calculate();
this.view.displayResult(this.model.result);
}
// 略...
}
calculate() {
// 略...
}
}
因此,選擇 MVP 的時機或優點是 Presenter 重用性高、切分乾淨好維護、易於分工、便於測試。
MVP 為什麼比較好測試
為什麼說 MVP 比較好測試呢?
首先,來看 MVC 的範例要怎麼寫測試。
describe('Calculator', () => {
let calculator;
beforeEach(() => {
calculator = new Calculator();
});
it('should get 3 when perform addition by adding 1 and 2', () => {
calculator.model.num1 = 1;
calculator.model.num2 = 2;
calculator.model.operator = '+';
calculator.calculate();
expect(calculator.model.result).toBe(3);
});
});
這會有幾個問題:
- 第一,因為 View 還是包含邏輯,所以要在測試時模擬操控 View 來讓使用者依序輸入、進行運算、顯示結果,是比較困難的。像是在這裡就沒辦法深入測試到使用者輸入的情境,來控制輸入框的出現、設定值、點擊按鈕、顯示結果等,這樣的測試是不夠全面的。等於說就是測試 Model 和部份的 Controller 而沒有 View,cover 到的情境很少。
- 第二,這樣的測試比較像是測試實作細節,一旦重構或是變更實作方式,就會測試失敗。
再來,來看 MVP 的範例要怎麼寫測試。
describe('CalculatorPresenter', () => {
let model, view, Presenter;
beforeEach(() => {
model = {
num1: 0,
num2: 0,
operator: '',
result: 0,
};
view = {
getFirstInput: jest.fn(),
getSecondInput: jest.fn(),
getOperator: jest.fn(),
displayResult: jest.fn(),
displayError: jest.fn(),
};
Presenter = new CalculatorPresenter(model, view);
});
describe('run', () => {
it('should get 3 when perform addition by adding 1 and 2', () => {
view.getFirstInput.mockReturnValue(1);
view.getSecondInput.mockReturnValue(2);
view.getOperator.mockReturnValue('+');
Presenter.run();
expect(view.displayResult).toHaveBeenCalledWith(3);
});
});
describe('calculate', () => {
it('should get 3 when adding 1 and 2', () => {
model.num1 = 2;
model.num2 = 3;
model.operator = '+';
viewModel.calculate();
expect(model.result).toBe(5);
});
});
});
解決的問題是:
- 第一,因為 View 不包含邏輯,所以可以順暢的操控 View 來讓使用者依序輸入、進行運算、顯示結果,可以測到完整的情境。像是在這裡就可以直接呼叫
getFirstInput
、getSecondInput
、getOperator
、displayResult
、displayError
來模擬使用者的操作,這樣的測試是比較全面的。 - 第二,比較接近以使用者的角度來寫測試,測試 View 容易測試實作細節,測 Presenter 是測試功能或行為,能讓測試更具彈性。
- 第三,好分工好重用,因為拆分詳細,所以在不同分工之下,可以針對不同功能拆分實作以及寫測試。
React 元件中的 MVP
React 元件中的 MVP 要怎麼寫呢?可以利用 custom hook 作為 Presenter,在元件中使用這些 hook 來處理使用者的輸入和邏輯。
來看個例子,這裡有一個計算機,包含兩個輸入框用於輸入兩個數字、一個下拉選單用於選擇運算符號和一個用於執行計算的按鈕。
實作 <Calculator>
元件如下(demo),思考這段程式碼,如果要來 code review,這樣的寫法有什麼問題呢?
const Calculator = () => {
const [num1, setNum1] = useState('');
const [num2, setNum2] = useState('');
const [operator, setOperator] = useState('+');
const [result, setResult] = useState('');
const handleChange = (e) => {
const { name, value } = e.target;
if (name === 'num1') {
setNum1(value);
} else if (name === 'num2') {
setNum2(value);
} else if (name === 'operator') {
setOperator(value);
}
};
const calculateResult = () => {
const parsedNum1 = parseFloat(num1);
const parsedNum2 = parseFloat(num2);
switch (operator) {
case '+':
setResult(parsedNum1 + parsedNum2);
break;
case '-':
setResult(parsedNum1 - parsedNum2);
break;
case '*':
setResult(parsedNum1 * parsedNum2);
break;
case '/':
setResult(parsedNum1 / parsedNum2);
break;
default:
setResult('Invalid operator');
}
};
return (
<div>
<input
data-test-id="number1"
type="number"
name="num1"
value={num1}
onChange={handleChange}
/>
<select
data-test-id="operator"
name="operator"
value={operator}
onChange={handleChange}
>
<option value="+">+</option>
<option value="-">-</option>
<option value="*">*</option>
<option value="/">/</option>
</select>
<input
data-test-id="number2"
type="number"
name="num2"
value={num2}
onChange={handleChange}
/>
<button data-test-id="calculate" onClick={calculateResult}>
Calculate
</button>
<div data-test-id="result">{result}</div>
</div>
);
};
進行 code review,最重要的是要讓程式碼「好理解、好維護、易於修改」,因此,這段程式碼有幾個可以討論的地方:
- 首先,這個元件把顯示畫面、計算邏輯和資料狀態都雜在一起,因此任何部份都無法重用。如果有人想重用,最簡單的方法會是複製整個檔案,這樣整個專案很可能到處都會有長得很相似的程式碼。我曾經在某個專案看到超多長得很類似的 modal,它們都是完整的複製特定檔案直接來改的,而不是重用可以用的部份,那就是因為大家都只求快、不整理程式碼,這就會導致之後要調整維護的時候要花更多時間,而且團隊在閱讀程式碼時都要重新思考程式碼的意涵。
- 再來,過於冗長。因為都雜在一起,所以整個檔案變得很大,通常檔案會希望保持在 200 行以下,這是 Google 給的 code review 的建議,這樣的好處是讓程式碼盡量維持簡單,簡單自然就會好懂好維護。
- 最後,如果要分工來做,或是東西要很大、要切分不同階段分 PR 進 code,這樣的實作就是很難達到這個目標的。
剛剛看過了 MVC 和 MVP 架構,如果要用在 React 元件上,有什麼想法呢?
MVC 寫測試
在重構前,先利用 React Testing Library 幫 <Calculator>
元件寫測試,在這個 test case 是測試 5 * 3
要得到 15 的狀況,可以看到測試方式就是模擬使用者的操作,經由輸入數字、選運算符號、點擊按鈕得到結果。
這樣的測試方式是沒有問題的,尤其是現在做元件測試都會希望以使用者的角度來寫測試,這樣的測試是比較全面的,也是更有彈性的,不會因為修改實作細節而導致測試失敗,耗費太多心力維護測試。
describe('Calculator', () => {
it('should get 15 when 5 multiplied by 3', () => {
const { getByTestId } = render(<Calculator />);
// 輸入數字
fireEvent.change(getByTestId('number1'), { target: { value: '5' } });
fireEvent.change(getByTestId('number2'), { target: { value: '3' } });
// 選取運算符號
fireEvent.change(getByTestId('operator'), { target: { value: '*' } });
// 點擊計算按鈕
fireEvent.click(getByTestId('calculate'));
// 驗證結果
expect(getByTestId('result')).toHaveTextContent('15');
});
});
Presenter Hooks
接下來,要利用 MVP 的概念來做重構 <Calculator>
元件。
在 React 中,通常會將 MVP 架構的 Presenter 的功能封裝在自定義的 hook 中,再加上狀態管理 seState
或 useContext
,並在元件中使用這些 hook 來處理使用者輸入、商業邏輯等。
重構 <Calculator>
元件,將計算的商業邏輯封裝在 Presenter useCalculatorHook
這個自定義 hook 會包含了計算機當中需要的所有狀態和商業邏輯,demo。
// Presenter hook
const useCalculatorHook = () => {
const [num1, setNum1] = useState(0);
const [num2, setNum2] = useState(0);
const [operator, setOperator] = useState('+');
const [result, setResult] = useState(0);
const handleNum1Change = (e) => setNum1(parseFloat(e.target.value));
const handleNum2Change = (e) => setNum2(parseFloat(e.target.value));
const handleOperatorChange = (e) => setOperator(e.target.value);
const calculate = () => {
switch (operator) {
case '+':
setResult(num1 + num2);
break;
case '-':
setResult(num1 - num2);
break;
case '*':
setResult(num1 * num2);
break;
case '/':
setResult(num1 / num2);
break;
default:
setResult(0);
}
};
return {
calculate,
handleNum1Change,
handleNum2Change,
handleOperatorChange,
num1,
num2,
operator,
result,
};
};
接下來可以在元件 <Calculator>
中使用這個 hook。
const Calculator = () => {
const { calculate, handleChange, num1, num2, operator, result } =
useCalculatorHook();
return (
<>
<input
data-test-id="number1"
type="number"
value={num1}
onChange={handleChange}
/>
<select data-test-id="operator" value={operator} onChange={handleChange}>
<option value="+">+</option>
<option value="-">-</option>
<option value="*">*</option>
<option value="/">/</option>
</select>
<input
data-test-id="number2"
type="number"
value={num2}
onChange={handleChange}
/>
<button onClick={calculate}>Calculate</button>
<div data-test-id="result">{result}</div>
</>
);
};
在這個元件中使用了 useCalculatorHook
custom hook 回傳狀態和方法,它不只是 Presenter 其實還有 Model,並根據這些狀態來渲染用於顯示畫面的元件。Presenter 把邏輯和狀態封裝在 custom hook 中,這樣元件可以保持簡潔、好懂好維護、也易於分工。之後如果想要重用這個計算機的邏輯,只要重用 useCalculatorHook
hook 就可以了。
MVP 寫測試
由於已經把計算機切分成 <Calculator>
元件和 useCalculatorHook
hook,那就可以分別對這兩部份寫測試,前面已經幫 <Calculator>
寫好測試了,接下來要幫 useCalculatorHook
寫測試,在這裡如前面還是用 React Testing Library 來寫測試。
在這個 test case 是測試 5 + 3
要得到 8 的狀況,可以看到測試方式是經由 export 出來的方法來輸入數字、運算符號、計算函式來得到結果。
describe('useCalculatorHook', () => {
test('should get 8 when add 5 and 3', () => {
const TestComponent = () => {
const {
calculate,
handleNum1Change,
handleNum2Change,
handleOperatorChange,
num1,
num2,
operator,
result,
} = useCalculatorHook();
return (
<div>
<input
data-test-id="number1"
value={num1}
onChange={handleNum1Change}
/>
<select
data-test-id="operator"
value={operator}
onChange={handleOperatorChange}
>
<option value="+">+</option>
</select>
<input
data-test-id="number2"
value={num2}
onChange={handleNum2Change}
/>
<button data-test-id="calculate" onClick={calculate}>
Calculate
</button>
<div data-test-id="result">{result}</div>
</div>
);
};
const { getByTestId } = render(<TestComponent />);
// 輸入數字
fireEvent.change(getByTestId('number1'), { target: { value: '5' } });
fireEvent.change(getByTestId('number2'), { target: { value: '3' } });
// 選取運算符號
fireEvent.change(getByTestId('operator'), { target: { value: '+' } });
// 點擊計算按鈕
fireEvent.click(getByTestId('calculate'));
// 驗證結果
expect(getByTestId('result')).toHaveTextContent('8');
});
});
這樣重構之後,有什麼差異呢?
- 第一,提高重用性,從這個例子可以看到,由於把邏輯和狀態都抽出來給
useCalculatorHook
hook,所以如果有其他地方也需要計算機的邏輯,只要重用這個 hook 就可以了。 - 第二,提高易維護性,因為把邏輯和狀態都抽出來給
useCalculatorHook
hook,所以<Calculator>
元件只要負責顯示畫面,這樣元件就會變得比較簡潔,也比較好懂好維護。 - 第三,易於分工,因為拆分詳細,所以在不同分工之下,可以針對不同功能拆分實作以及寫測試。
- 第四,易於測試,因為把邏輯和狀態都抽出來給
useCalculatorHook
hook,所以可以分別對這兩部份寫測試,這樣的測試是比較全面的,也是更有彈性的。還有,對於寫測試來說,最怕的就是畫面一改,測試就壞了,所以寫測試盡可能不要跟畫面細節有太多關連,如果畫面常常變更、或是還沒確定,先寫測試 hook 的部份也許是個保證功能正常運作的權宜之計,待稍後畫面確定了再補測試即可。不過針對畫面細節調整來盡量保持測試彈性這個議題,有很多可以討論的,像是用測試 ID 而非畫面上的文字等等都是可以的,另外,愈接近使用者的實際操作行為的測試是更可靠也更有彈性,這點也需要取捨。 - 第五, 由於在功能複雜的狀況下,可能需要拆分成不同的部份來實作、寫測試,並且分別提交 PR。這樣確保能切得乾淨,並且能針對切分出來的部份實作測試,PR 也盡量能足夠小到方便 review。
React 作為 V 的 MVP 專案架構
來看 React 作為 V 的 MVP 專案架構 的例子,在這個架構圖可以看到,在這個專案裡面,React 作為 View,並且自己寫 utils 作為 Presenter 與 Model 來從 API 或 local storage 取資料。
來看專案結構如下,舉例來說,Model 主要放在 datasource,就登入功能來說,Presenter 放在 login-presenter.js
,並且 View 放在 login-view.jsx
。utils connect-component.js
用來綁定 View 和 Presenter,再將從 API 或 local storage 拿回來的資料放在 memory 作為 Model,這樣就能讓 View 和 Presenter 進行綁定,並且讓 View 更乾淨、更好維護。
├── src
│ ├── components/login
│ │ ├── login-presenter.js
│ │ ├── login-view.jsx
│ │ └── ...
│ ├── core/datasource
│ │ └── ...
│ ├── util
│ │ ├── connect-component.js
│ │ └── ...
│ ├── view/layout
│ │ └── ...
│ └── ...
├── package.json
└── README.md
綁定 View 和 Presenter
在這個例子中,以登入功能來說,View 放在 login-view.jsx
,作為介面顯示以及接收使用者的操作。
class LoginView extends PureComponent {
constructor(props) {
super(props);
this.presenter = props.presenter;
this.presenter.setView(this);
this.state = {
email: '',
password: '',
loading: false,
error: null,
};
}
showProgress() {
// 略 ...
}
hideProgress() {
// 略 ...
}
showLoginSuccess() {
// 略 ...
}
showLoginFatalError() {
// 略 ...
}
showLoginError(message) {
// 略 ...
}
handleChange = field => (event) => {
// 略 ...
};
submitForm = (e) => {
// 略 ...
};
render() {
return (
// 略 ...
);
}
};
Presenter 放在 login-presenter.js
,作為管理邏輯、處理使用者的互動,並且將 Model 綁定到 View 上呈現。
LoginPresenter
提供 View 利用 setView
來對 View 進行控制,依照狀態顯示不同的 View,像是決定要不要顯示 loading 和 success 或 error message。由此可知,在 MVP 架構中,Presenter 的主要職責是處理邏輯和界面操作,並且在與 View 進行資料傳遞時仍要保持解耦。Presenter 並不直接操作 UI,而是透過呼叫 View 的方法來將相對應的操作委派給 View。
class LoginPresenter {
setView(view) {
this.view = view;
}
async onLogin(email, password) {
this.view.showProgress();
const userDatasource = await UserDatasource.getInstance();
const response = await userDatasource.signIn(email, password);
this.view.hideProgress();
if (response.success) {
this.view.showLoginSuccess();
} else if (response.fatal) {
this.view.showLoginFatalError();
} else {
this.view.showLoginError(response.error);
}
}
}
utils connect-component.js
用來綁定 View 和 Presenter。ConnectComponent
函式接受兩個參數:view 和 presenter。該函式的目的是將一個 View 和一個 Presenter 綁定在一起。它的做法是建立一個新的元件,並將 View 和 Presenter 作為屬性傳遞給該元件,這樣就可以方便地將 View 和 Presenter 組合在一起,實現元件的邏輯和 UI 之間的分離。
const ConnectComponent = (view, presenter) =>
React.createClass({
displayName: `ConnectComponent(${view.name}, ${presenter.name})`,
render: function () {
const _presenter = new presenter();
return React.createElement(view, {
...this.props,
presenter: _presenter,
});
},
});
有興趣的話可以看看這個專案的原始碼。
MVVM
最後來看 MVVM。
MVVM 是指程式可由三個部份組成:
- Model:Model:同 MVC 是用來存放和管理資料。
- View:顯示 ViewModel 目前的狀況,在這裡 View 不會直接與 Model 接觸,而是透過 ViewModel 拿到轉換後的資料、修改資料。
- ViewModel:利用宣告性資料綁定(declarative data binding)與 View 綁定,讓 View 更乾淨、更好分工(註 3)。
常遇到一種狀況是資料與顯示不一致,例如:在 Model 是數字 9999,在 View 要顯示九九九九,就可以放在 ViewModel 中來處理。
圖片來源:MVVM for JavaScript Developers
以 MVVM 改寫 MVC
同樣的,MVVM 也是覺得 MVC 切得不夠乾淨,讓我們來重構前面的例子,改寫為綁定 View 與 ViewModel,將 View 要顯示的資料,從 Model 取出後,經過 ViewModel 處理後再傳遞給 View,這樣就能抽出更多邏輯,切得更乾淨、View 更好重用,demo。
// View
class CalculatorView {
constructor() {
this.container = document.getElementById('result');
}
// 略...
displayResult(result) {
document.getElementById('result').textContent = result;
}
// 更新
update(data) {
this.container.textContent = data;
}
}
// ViewModel
class CalculatorViewModel {
// 略...
// 將 Model 資料轉換為 View 所需的格式
get formattedData() {
return calculate();
}
run() {
try {
const num1 = this.view.getFirstInput();
const num2 = this.view.getSecondInput();
const operator = this.view.getOperator();
this.model.num1 = num1;
this.model.num2 = num2;
this.model.operator = operator;
this.calculate();
this.view.displayResult(this.model.result);
} catch (error) {
this.view.displayError(error);
}
}
calculate() {
// 略...
}
}
// Main
const model = new CalculatorModel();
const view = new CalculatorView();
const viewModel = new CalculatorViewModel(model, view);
viewModel.run();
// 將 ViewModel 和 View 進行綁定
// 當 ViewModel 的 formattedData 變更時,更新 View
Object.defineProperty(viewModel, 'formattedData', {
get: function () {
return `結果: ${this.model.result}`;
},
set: function (newValue) {
view.update(newValue);
},
});
// 初始化 View
view.update(viewModel.formattedData);
// 使用 Proxy 來監聽 model 的變化
const userProxy = new Proxy(model, {
set: function (target, property, value) {
target[property] = value;
// 當 model 變化時,更新 view
view.update(viewModel.formattedData);
return true;
},
});
MVVM 有比較好嗎?這個見仁見智,像是因為這個例子滿簡單的,所以感受不到 MVVM 與 MVP 太大差異,同樣的在寫測試方面也是差不多的。
describe('CalculatorViewModel', () => {
let model;
let view;
let viewModel;
beforeEach(() => {
model = {
num1: 0,
num2: 0,
operator: '',
result: 0,
};
view = {
getFirstInput: jest.fn(),
getSecondInput: jest.fn(),
getOperator: jest.fn(),
displayResult: jest.fn(),
displayError: jest.fn(),
};
viewModel = new CalculatorViewModel(model, view);
});
describe('run', () => {
it('should get 8 when perform addition by adding 5 and 3', () => {
view.getFirstInput.mockReturnValue(5);
view.getSecondInput.mockReturnValue(3);
view.getOperator.mockReturnValue('+');
viewModel.run();
expect(view.displayResult).toHaveBeenCalledWith(8);
});
});
describe('calculate', () => {
it('should get the sum of 3 when perform addition by adding 1 and 2', () => {
model.num1 = 2;
model.num2 = 3;
model.operator = '+';
viewModel.calculate();
expect(model.result).toBe(5);
});
});
});
React 元件中的 MVVM
這裡有個「剪貼簿」的功能,利用 custom hook useCopyToClipboard
封裝剪貼的邏輯,並回傳複製成功的 flag,以及讓使用者其他元件呼叫來操作貼上的方法。
對於這個元件 <CopyPopover>
來說,它從 custom hook useCopyToClipboard
取得 Model 複製成功的 flag copySuccess
,會在元件本身處理為成功或失敗的訊息 message
,這樣就能切分資料與顯示不一致的狀況,又能維持某個程度的共用。
const useCopyToClipboar = () => {
const textRef = useRef(null);
const [copySuccess, setCopySuccess] = useState(false);
const copyToClipboard = () => {
try {
textRef.current.select();
document.execCommand('copy');
setCopySuccess(true);
} catch (error) {
console.error(error);
setCopySuccess(false);
}
};
return {
textRef,
copySuccess,
copyToClipboard,
};
};
const CopyPopover = () => {
const { textRef, copySuccess, copyToClipboard } = useCopyToClipboard();
const [message, setMessage] = useState();
useEffect(() => {
if (copySuccess) {
setMessage('Copy Success');
} else {
setMessage('Copy Fail');
}
}, [copySuccess]);
return (
<Popover
content={
<InputStyle
ref={textRef}
value={text}
onClick={copyToClipboard}
onChange={copyToClipboard}
readOnly
/>
}
placement="top"
popoverBody={<span>{message}</span>}
trigger="click"
/>
);
};
對我來說,在 React 元件裡面,用 MVP 或是 MVVM 的寫法是差不多的,因為在 React 裡面的宣告性資料綁定可用 state 和 props 來實現,也就是實現資料綁定的機制在 React 原生的 hook 都寫好了,只要用就可以了。唯一的差異可能是 MVVM 我會把乾淨的資料會放在 Redux 或 Context,然後再根據元件本身要用的顯示資料在 State,讓 hook 來做更多邏輯的處理。
React 作為 V 與 VM 的 MVVM 架構專案
來看個 React 作為 V 與 VM 的 MVVM 架構專案,在這裡可以看到,React 作為 View 和 ViewModel,並且自己寫 utils 作為 Model 來從 API 或 local storage 取資料。
Mockup 如下圖。
來看專案結構如下,舉例來說,Model 主要放在 Data 資料夾底下,View 會放在該功能的 View.js 檔案,ViewModel 放在該功能的 ViewModel.js 檔案。不經由其他工具綁定,View 直接在本身呼叫 ViewModel 的方法和資料,而再將從 API 或 local storage 拿回來的資料放在 memory 作為 Model,並在 ViewModel 再做處理後再傳遞給 View 作為顯示,這樣就能讓 View 和 ViewModel 進行綁定,並且讓 View 更乾淨、更好維護。
├─ Data
├─ Domain
└─ Presentation
└─ View
└─ Product
├─ List
│ ├─ Components
│ │ ├─ ProductList.js
│ │ └─ AddButton.js
│ ├─ View.js
│ └─ ViewModel.js
├─ ...
└─ index.js
綁定 View 和 ViewModel
前面提到顯示產品列表的功能,它的 ViewModel ProductListViewModel
會從 API 或 local storage 取得資料,並且 export 方法與處理後的資料,再傳遞給 View ProductList
作為顯示。
const ProductListViewModel = ({ GetProductsUseCase }) => {
const [error, setError] = useState('');
const [products, setProducts] = useState([]);
async function getProducts() {
const { result, error } = await GetProductsUseCase.execute();
setError(error);
setProducts(result);
}
return {
error,
getProducts,
products,
};
};
關於 <ProductList>
元件,它的資料是從 ViewModel ProductListViewModel
得到的,取得資料後就會顯示在畫面上,這樣就能抽出更多邏輯,切分資料與顯示不一致的狀況,又能維持某個程度的共用。
const ProductList = () => {
let navigate = useNavigate();
const { products, getProducts } = DI.resolve('ProductListViewModel');
useEffect(() => {
getProducts();
}, []);
return (
<div className="page">
<div
style=
>
<h2>Product List</h2>
<Button title={'New'} onClick={() => navigate(`/product/new`)} />
</div>
<List
data={products}
onRowClick={(id) => navigate(`/product/detail/${id}`)}
/>
</div>
);
};
有興趣的話可以看看這個專案的原始碼。
總結:MVC vs MVP vs MVVM
對我來說,總結 MVC vs MVP vs MVVM 最大的差異就是到底要切到多乾淨,尤其是 View 到底可以怎麼切邏輯、切多少邏輯出來,意即:MVP 利用 Presenter 觀察 Model 然後更新 View,而 MVVM 利用 ViewModel 做資料綁定以及客製化資料供 View 使用,都可以讓 View 更乾淨、更好分工。
一定要用 MVC 或 MVP 或 MVVM 嗎?我會根據複雜度來決定,例如:navigation 或 sidebar 元件只是呈現資料而不用處理邏輯,那就可以用 MVC,但如果是一個複雜的元件,例如:一個表單元件,裡面有很多邏輯,那就可以用 MVP 或 MVVM,這樣可以讓元件更乾淨、更好維護。
備註
- 註 1:在 Controller 和 View 透過
getter
拉取 Model 的資料,這裡提到「透過getter
拉取資料」,是什麼意思呢?來看一個簡單範例。在這個範例中,UserModel
類別代表模型,它有一個_name
屬性,並且利用get
定義了一個name
的getter
方法來取得名稱。UserView
類別代表 View,它有一個renderName
方法來渲染使用者名稱。UserController
類別代表控制器,它接收模型和視圖作為參數,並且有一個displayUserInfo
方法來取得使用者資訊並通知 View 渲染。在主函式中建立了一個 Model instanceuserModel
、一個 View instanceuserView
和一個 Controller instanceuserController
。 然後,控制器呼叫displayUserInfo
方法,透過getter
方法從模型中取得使用者名稱,並將其傳遞給視圖進行渲染。這個範例表示如何使用getter
方法從 Model 中取得資料,然後傳遞給 View 進行渲染,從而實現了 MVC 架構中的資料流動。
// Model
class UserModel {
constructor(name) {
this._name = name;
}
// 定義 getter 方法來取得名稱
get name() {
return this._name;
}
}
// View
class UserView {
renderName(userName) {
console.log(`User Name: ${userName}`);
}
}
// Controller
class UserController {
constructor(model, view) {
this.model = model;
this.view = view;
}
// 取得使用者名稱並渲染 view
displayUserInfo() {
const userName = this.model.name; // 透過 getter 取得名稱
this.view.renderName(userName);
}
}
// 建立 model、view 和 controller 實例
const userModel = new UserModel('John Doe');
const userView = new UserView();
const userController = new UserController(userModel, userView);
// controller 取得使用者資訊並通知渲染 view
userController.displayUserInfo();
在 controller 和 view 皆可透過 getter 取得 model 的 name。
// 在 controller 透過 getter 取得 model 的 name
displayUserInfo() {
const userName = this.model.name; // 透過 getter 取得名稱
this.view.renderName(userName);
}
// 在 view 透過 getter 取得 model 的 name
renderName() {
const userName = this.model.name; // 透過 getter 取得名稱
console.log(`User Name: ${userName}`);
}
由於 View 就是這樣單純的顯示從 Model 取出來的資料,或是將使用者的互動傳遞給 Controller,因此在 MVC 架構中 View 通常沒有太複雜的邏輯處理能力,單純做好顯示的工作就好。
- 註 2:關於「模版化」,首先來看字串串接,字串串接(concatenation)是一種很恐怖的組織 view 的方式,因為實在是很難懂也很難維護,而且效能很差,如下範例所示:
const users = [
{ name: 'Alice', age: 30, email: 'alice@example.com' },
{ name: 'Bob', age: 25, email: 'bob@example.com' },
{ name: 'Charlie', age: 35, email: 'charlie@example.com' },
];
let html = '<table>';
html += '<thead><tr><th>Name</th><th>Age</th><th>Email</th></tr></thead>';
html += '<tbody>';
users.forEach((user) => {
html += `<tr><td>${user.name}</td><td>${user.age}</td><td>${user.email}</td></tr>`;
});
html += '</tbody></table>';
console.log(html);
模版化(templating)是指用模板文字的語法,來切分變數和標籤,做成可重用的樣板,達到建立動態 HTML 內容的目的,這樣是更乾淨、更高效好維護的。
以模板文字 (template literal) 改寫以上程式碼 (demo)。
const users = [
{ name: 'Alice', age: 30, email: 'alice@example.com' },
{ name: 'Bob', age: 25, email: 'bob@example.com' },
{ name: 'Charlie', age: 35, email: 'charlie@example.com' },
];
const html = `
<table>
<thead>
<tr><th>Name</th><th>Age</th><th>Email</th></tr>
</thead>
<tbody>
${users
.map(
(user) =>
`<tr><td>${user.name}</td><td>${user.age}</td><td>${user.email}</td></tr>`
)
.join('')}
</tbody>
</table>
`;
console.log(html);
標籤樣板字面值 (tagged template literal) 和 template literal 不同,tagged template literal 是函式而 template literal 是字串,比起 template literal 好處是 tagged template literal 可以多做一些處理,(demo)
const users = [
{ name: 'Alice', age: 30, email: 'alice@example.com' },
{ name: 'Bob', age: 25, email: 'bob@example.com' },
{ name: 'Charlie', age: 35, email: 'charlie@example.com' },
];
const userTemplate = (strings, name, age, email) => {
// 在這裡可以對模板字串進行處理
// strings 是一個包含原始字串的陣列
// name, age, email...是傳遞給模板字串中變數的值的陣列
return strings[0] + name + strings[1] + age + strings[2] + email + strings[3];
};
const template = (name, age, email) => userTemplate`
<tr>
<td>${name}</td>
<td>${age}</td>
<td>${email}</td>
</tr>`;
const userList = users.map((user) => template(user.name, user.age, user.email));
const html = `
<table>
<thead>
<tr>
<th>Name</th>
<th>Age</th>
<th>Email</th>
</tr>
</thead>
<tbody>
${userList}
</tbody>
</table>`;
console.log(html);
- 註 3:宣告性資料綁定(declarative data binding)是一種程式設計風格,用於簡化開發 UI 的過程。它透過描述資料和 UI 之間的關係來定義界面,而無需手動操作 DOM 或 UI 元素。在宣告性資料綁定中,只需要指定資料的狀態以及 UI 應該如何反應這些狀態,而不需要關心 DOM 具體如何更新邏輯。舉例來說,在 React 中可以使用 JSX 語法來聲明 UI 的結構,並使用元件來封裝和管理 UI 的不同部分,例如:使用 state 和 props 將資料和 UI 元素連接起來。如下範例,有一個簡單的計數器元件,利用
useState
hook 建立count
狀態變數,並初始化為 0,然後再利用increment
函式,當使用者點擊它時遞增計數器的值。當狀態變化時,React 會自動重新渲染元件,並將最新的count
值反應到 UI 上。這種方式可以讓我們只需要關注資料和 UI 之間的關係,而不需要手動管理 UI 的更新過程。
const Counter = () => {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
};
參考資料
- JavaScript Patterns: MVC Versus MVP Versus MVVM
- Let’s talk about React and MVP
- Clean MVVM with React and React Hooks
JavaScript Patterns 讀書會 相關資訊
日期 | 主題 | 主講者 |
---|---|---|
2/6 | 1 - 7 | Kuro |
2/20 | 7 | Caesar |
3/5 | 8 JavaScript MV* Patterns |
Summer |
3/19 | 9 非同步程式設計模式 | Alex Liu |
4/2 | 10 Modular JavaScript Design Patterns | 奶綠茶 |
4/16 | 11 命名化空間模式 | Muki |
4/30 | 12 React 設計模式 | 泰銘 |
5/14 | 13 | Anna |
5/28 | 14 | PJ |