JavaScript MV* Patterns | Learning JavaScript Design Patterns, 2e

JavaScript Patterns 讀書會 - MV* Patterns 逐字稿。

歡迎搭配投影片一同服用。

Hi!

大家好,我是 Summer,今天想跟大家分享的是「JavaScript MV* Patterns」這個主題。

This is Summer!

我是 Summer,我是前端工程師。

「Summer。桑莫。夏天」是我的部落格,主要是前端技術的分享。

「打造高速網站,從網站指標開始!全方位提升使用者體驗與流量的關鍵」這是我寫的書,如果大家對網頁前端效能有興趣,歡迎找來看看。

下面的連結是我的 InstagramFBEmail,歡迎交流!

義大利麵程式碼

義大利麵程式碼 Spaghetti code

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,而且都要東改一點西改一點,超容易出錯的,要寫測試也不知道怎麼寫,所以都是靠手動測試,很危險的。

總結來說,以上這段程式碼有些問題:

關注點分離

關注點分離

圖片來源

義大利麵程式碼這麼難懂,那就必須來改寫,而改寫概念是「關注點分離」(Separation of concerns,SoC),通常將程式碼分為三個主要關注點:資料、邏輯和界面

關注點分離的概念通常有幾個代表性的實作架構,像是 MVC、MVP 和 MVVM,接下來會來看這三種應用程式的架構。

這次分享的的範例會用 pure js 和 React 來實作,這樣大家可以看到不同的寫法,並且,我會以分別以元件和專案架構的兩種面向來說明這三種架構方式,以及怎麼寫 unit test,這樣大家會比較清楚實際是怎麼應用的。

MVC

第一個來看 MVC。

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

不過有些人認為 MVC 並不是一種設計模式,而是多種設計模式 (Observer、Strategy、Composite、Factory、Template) 組合起來的架構,是讓開發者方便組織應用程式的實作方式。

MVC for JavaScript Developers

圖片來源: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 三部份:

以 MVC 改寫後,是不是比較清楚易懂好維護呢?這樣 MVC 的架構能讓程式碼更加模組化和易於理解,也更容易寫測試。

總結來說,MVC 的好處是:

React 元件中的 MVC

以架構面來看,React 不是 MVC 架構的,因為它只有 View 而已,而以元件的角度來看,元件可以是 MVC 架構的:

舉例來說,在這個例子中,<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 架構的,怎麼說呢?

以下是 Redux data flow 概念圖。

Redux data flow

回顧前面我們看到的 MVC 的架構圖,React App 有沒有做到架構圖描述的事情呢?

由於 View 接收使用者的輸入、觸發 Controller 更新 Model、根據 Model 的狀態顯示 View,這肯定是有做到的,因此我會認為 React App 可以當成 MVC 架構的實作。

稍後會分享 MVP 和 MVVM 的專案範例,雖然他們都假設有連接後端的能力,但是其實砍掉這一部份也是沒有影響的。

MVP

看了 MVC,接下來看 MVP。

MVP 是指程式可由三個部份組成:

和 MVC 最大的不同是,View 不再觀察 Model 的變化,而是由 Presenter 觀察 Model 然後更新 View。好處是 View 真的只要負責介面,而將邏輯都抽出來給 Presenter 了。

MVC for JavaScript Developers

圖片來源: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);
  });
});

這會有幾個問題:

再來,來看 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);
    });
  });
});

解決的問題是:

React 元件中的 MVP

React 元件中的 MVP 要怎麼寫呢?可以利用 custom hook 作為 Presenter,在元件中使用這些 hook 來處理使用者的輸入和邏輯。

來看個例子,這裡有一個計算機,包含兩個輸入框用於輸入兩個數字、一個下拉選單用於選擇運算符號和一個用於執行計算的按鈕。

React 元件中的 MVP

實作 <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,最重要的是要讓程式碼「好理解、好維護、易於修改」,因此,這段程式碼有幾個可以討論的地方:

剛剛看過了 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 中,再加上狀態管理 seStateuseContext,並在元件中使用這些 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');
  });
});

這樣重構之後,有什麼差異呢?

React 作為 V 的 MVP 專案架構

來看 React 作為 V 的 MVP 專案架構 的例子,在這個架構圖可以看到,在這個專案裡面,React 作為 View,並且自己寫 utils 作為 Presenter 與 Model 來從 API 或 local storage 取資料。

Let’s talk about React and MVP

圖片來源

來看專案結構如下,舉例來說,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 是數字 9999,在 View 要顯示九九九九,就可以放在 ViewModel 中來處理。

MVVM for JavaScript Developers

圖片來源: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 如下圖。

React 作為 V 與 VM 的 MVVM 架構專案

來看專案結構如下,舉例來說,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,這樣可以讓元件更乾淨、更好維護。

備註

// 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 通常沒有太複雜的邏輯處理能力,單純做好顯示的工作就好。

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);
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 Design Pattern Unit Test front end testing JavaScript Patterns 讀書會 Learning JavaScript Design Patterns javascript 單元測試 設計模式 sharing 閱讀筆記