Основная суть MV-образных паттернов заключается в разделении бизнес-логики от интерфейса.
MVC
Что такое MVC
Представим такую ситуацию, что у нас есть приложение, которому нужно будет заменить интерфейс и сохранить как прошлое, так и новое отображение интерфейса. Но до этого мы реализовывали приложение в полной связности с бизнес-логикой. То есть BL находится прямо внутри UI и неразрывно с ним связана.
MVC же подразумевает то, что мы заранее выделили логику подсчёта всех значений (cos, sin, pow) в отедльной сущности (aka model), а уже будем вызывать эту логику из самого интерфейса (aca view) через связующее звено (aka controller)
Если приводить пример из реальной жизни, то когда мы взаимодействуем с банком, то обращение идёт не напрямую с деньгами, а через определённых посредников.
- View - сотрудник банка или банкомат, через который мы делаем определённые запросы на операции в банке
- Controller - посредник для банкомата или сотрудника банка, через которых они отправляют запросы на выполнение операций
- банкомат вызывает заранее подгтовленные функции по выполнению операций над нашим счётом
- работник обращается к корпоративному приложению для выполнения операции
- Model - внутреннее устройство банка, которое выполняет все операции над счётом клиента
MVC подход используется у нас на любом этапе приложения. Начиная от клиент-серверного взаимодействия и бэкэнда, заканчивая архитектурой приложения на клиенте (web, мобильного, декстопного)
Пример MVC на NodeJS
Реализуем приложение на Express и шаблонизаторе HandleBars
bun install express hbs nodemon
Первое, что мы сделаем - это во view напишем логику отображения в шаблонизаторе.
view / user.hbs
<!DOCTYPE html>
<html>
<head>
<title>Пользователи</title>
<meta charset="utf-8" />
</head>
<body>
<h1>Создать нового пользователя</h1>
<form action="/users/create" method="POST">
<label>Имя</label>
<input name="username" /><br><br>
<label>Возраст</label>
<input name="age" type="number" min="1" max="110" /><br><br>
<input type="submit" value="Отправить" />
</form>
<h1>Список пользователей</h1>
{{#each users}}
<div style="border: 1px solid green; padding: 15px">
<h3>Username - {{this.username}}</h3>
<p>Возраст - {{this.age}}</p>
<button onclick="removeById({{this.id}})">
Удалить
</button>
</div>
{{/each}}
<script>
function removeById(id) {
fetch(`http://localhost:5000/users/remove?id=${id}`, {
method: 'DELETE'
}).then(() => window.location.reload())
}
</script>
</body>
<html>
view / user-error.hbs
<!DOCTYPE html>
<html>
<head>
<title>Пользователи</title>
<meta charset="utf-8" />
</head>
<body>
<h1>Произошла ошибка. {{message}}</h1>
<a href="/users">Вернуться на страницу пользователей</a>
</body>
<html>
Далее нам нужно будет описать модель пользователя, в которой находится интерфейс этого пользователя и прямые методы для работы с ним. Тут находится только логика работы с пользователем.
domain / user / model.js
// aka DB
let users = [
{ id: '1', username: 'Ulbi TV', age: 23 }
]
// Методы для работы конкретно с пользователем
module.exports = {
// создаём пользователя
create: ({ username, age }) => {
const newUser = {
username,
age,
id: String(Date.now())
}
if(!users.find(user => user.username === users)) {
users.push(newUser)
} else {
throw new Error('Пользователь уже существует')
}
return newUser;
},
// удаление по ID
removeById: ({ id }) => {
const userIndex = users.findIndex(user => user.id === String(id));
if(userIndex === -1) {
throw new Error('Пользователь не найден')
}
users.splice(userIndex, 1);
return id;
},
// удаление по имени пользователя
removeByUsername: ({ id }) => {},
// Получаем всех
getAll: () => {
return users;
},
// получаем по ID
getById: ({id}) => {
return users.find(user => user.id === id);
},
}
Далее опишем контроллер для работы с моделью. Он будет давать нам контролируемую обёртку над моделью, в которой будут находиться все проверки и взаимодействие с внешним миром.
domain / user / controller.js
// использует в себе модель
const userModel = require('./model');
/**
* все методы контроллера принимают в себя Reqest и Response
* Так как он работает непосредственно с запросами
*/
module.exports = {
// получение всех пользователей
getAll: (req, res) => {
return res.render('users.hbs', {
users: userModel.getAll()
})
},
// создание пользователей с валидацией и проверкой
create: (req, res) => {
try {
const { age, username} = req.body;
if(!age || !username) {
throw new Error('Не указан username или возраст');
}
userModel.create({ age, username })
return res.redirect('/users')
} catch (e) {
return res.render('users-error.hbs', {
message: e.message
});
}
},
// удаление пользователя с валидацией и проверкой
removeById: (req, res) => {
try {
const id = req.query.id;
if(!id) {
throw new Error('id не указан');
}
userModel.removeById({ id })
res.render('users-view.hbs', {
users: userModel.getAll()
})
} catch (e) {
return res.render('users-error.hbs', {
message: e.message
});
}
}
}
Ну и так же мы можем реализовать множество различных способов взаимодействия с нашей моделью. Модель просто является источником истины, которую можно провайдить различными способами через наш контроллер: с помощью REST API, SOAP или того же GraphQL
domain / user / rest-controller.js
const userModel = require('./model');
module.exports = {
getAll: (req, res) => {
return res.json(userModel.getAll())
},
}
domain / user / soap-controller.js
const userModel = require('./model');
module.exports = {
getAll: (req, res) => {
const xmlUsers = XML.parse(userModel.getAll());
return res.send(xmlUsers);
},
}
Ну и далее нам остаётся только поднять сервер
server.js
const express = require('express')
const path = require('path');
const userController = require('./domain/users/controller')
const PORT = 5000;
const app = express();
// добавляем поддержку hbs
app.set("view engine", "hbs");
app.set('views', path.resolve(__dirname, 'views'));
app.use(express.urlencoded({ extended: false }));
// подцепляем контроллер под роуты
app.get('/users', userController.getAll)
app.post('/users/create', userController.create)
app.delete('/users/remove', userController.removeById)
// вызываем сервер
app.listen(PORT,() => console.log('server started on PORT = ' + PORT))
Пример клиентского приложения с MVC
Корневой HTML, в который подключаются скрипты
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + TS</title>
</head>
<body>
<div id="counter1"></div>
<div id="counter2"></div>
<div id="counter3"></div>
<div id="users"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Описываем интерфейсы для View, Model, Controller
types / controller.ts
import {Model} from "./model";
export interface Controller {
model: Model;
}
types / model.ts
export interface Model {}
types / view.ts
import {Controller} from "./controller";
export interface View {
mount: () => void;
controller: Controller;
}
Общение через контроллер
Первый модуль у нас будет общаться по стандартной схеме, когда все потоки данных между View и Model контролирует Controller
Вот сама модель счётчика
modules / counterTwo / CounterTwoModel.ts
import {Model} from "../../types/model";
export class CounterTwoModel implements Model {
value: number;
constructor() {
this.value = 0;
}
increment() {
this.value += 1;
return this.value;
}
decrement() {
this.value -= 1;
return this.value;
}
multipleAndDivide() {
this.value *= 5;
this.value /= 3;
this.value = Math.ceil(this.value);
return this.value;
}
}
Тут уже находится контроллер, который дёргает запросы из модели
modules / counterTwo / CounterTwoController.ts
import {CounterTwoModel} from "./CounterTwoModel";
import {Controller} from "../../types/controller";
export class CounterTwoController implements Controller {
model: CounterTwoModel;
constructor() {
this.model = new CounterTwoModel();
}
handleIncrement() {
console.log('increment', this.model)
return this.model.increment();
}
handleDecrement() {
console.log('handleDecrement')
return this.model.decrement();
}
handleMultiply() {
console.log('handleMultiply')
return this.model.multipleAndDivide();
}
}
А тут уже во View мы вызываем только методы контроллера
modules / counterTwo / CounterTwoView.ts
import {CounterTwoController} from "./CounterTwoController";
import {View} from '../../types/view'
export class CounterTwoView implements View {
controller: CounterTwoController;
root: HTMLElement;
private title: HTMLElement;
private incrementButton: HTMLElement;
private decrementButton: HTMLElement;
private multipleButton: HTMLElement;
constructor(root: HTMLElement) {
this.root = root;
this.controller = new CounterTwoController();
this.title = document.createElement('h1');
this.title.innerText = 'Value = 0';
this.incrementButton = document.createElement('button');
this.incrementButton.innerText = 'increment';
this.decrementButton = document.createElement('button');
this.decrementButton.innerText = 'decrement';
this.multipleButton = document.createElement('button');
this.multipleButton.innerText = 'multiply';
this.bindListeners();
}
private onIncrementClick = () => {
this.updateTitle(this.controller.handleIncrement())
}
private onDecrementClick = () => {
this.updateTitle(this.controller.handleDecrement())
}
private onMultiplyClick = () => {
this.updateTitle(this.controller.handleMultiply())
}
private bindListeners() {
this.incrementButton.addEventListener('click', this.onIncrementClick);
this.decrementButton.addEventListener('click', this.onDecrementClick);
this.multipleButton.addEventListener('click', this.onMultiplyClick);
}
public updateTitle(value: number) {
this.title.innerText = `Value = ${value}`;
}
public mount() {
this.root.appendChild(this.title);
this.root.appendChild(this.incrementButton);
this.root.appendChild(this.decrementButton);
this.root.appendChild(this.multipleButton);
}
}
Общение по кругу
В этом примере Controller отдаёт данные из Model во View, а View, в свою очередь, передаёт данные напрямую в Model
Это модель счётчика. Она уже заранее знает про существование View и уже работает непосредственно с данными, которые находятся внутри View.
Основной особенностью тут является то, что Model изменяет данные внутри себя и уведомляет View о том, что эти данные изменились.
modules / counter / CounterModel.ts
import {CounterView} from "./CounterView";
export class CounterModel {
view: CounterView;
value: number;
constructor(view: CounterView) {
this.value = 0;
this.view = view;
}
increment() {
this.value += 1;
this.view.updateTitle()
}
decrement() {
this.value -= 1;
this.view.updateTitle()
}
multipleAndDivide() {
this.value *= 5;
this.value /= 3;
this.value = Math.ceil(this.value);
this.view.updateTitle()
}
}
Это контроллер, который оборачивает в себе логику модели. Он принимает в себя модель и вызывает напрямую его методы
modules / counter / CounterController.ts
import {CounterModel} from "./CounterModel";
export class CounterController {
model: CounterModel;
constructor(model: CounterModel) {
this.model = model
}
handleIncrement() {
console.log('increment', this.model)
this.model.increment();
}
handleDecrement() {
console.log('handleDecrement')
this.model.decrement();
}
handleMultiply() {
console.log('handleMultiply')
this.model.multipleAndDivide();
}
}
Это View, в котором мы передаём методы Model в Controller
modules / counter / CounterView.ts
import {CounterController} from "./CounterController";
import {CounterModel} from "./CounterModel";
export class CounterView {
controller: CounterController;
model: CounterModel;
root: HTMLElement;
private title: HTMLElement;
private incrementButton: HTMLElement;
private decrementButton: HTMLElement;
private multipleButton: HTMLElement;
constructor(root: HTMLElement) {
this.root = root;
this.model = new CounterModel(this);
this.controller = new CounterController(this.model);
this.title = document.createElement('h1');
this.title.innerText = 'Value = 0';
this.incrementButton = document.createElement('button');
this.incrementButton.innerText = 'increment';
this.decrementButton = document.createElement('button');
this.decrementButton.innerText = 'decrement';
this.multipleButton = document.createElement('button');
this.multipleButton.innerText = 'multiply';
this.bindListeners();
}
private bindListeners() {
this.incrementButton.addEventListener('click', this.controller.handleIncrement.bind(this));
this.decrementButton.addEventListener('click', this.controller.handleDecrement.bind(this));
this.multipleButton.addEventListener('click', this.controller.handleMultiply.bind(this));
}
public updateTitle() {
this.title.innerText = `Value = ${this.model.value}`;
}
public render() {
this.root.appendChild(this.title);
this.root.appendChild(this.incrementButton);
this.root.appendChild(this.decrementButton);
this.root.appendChild(this.multipleButton);
}
}
Общение View и Controller с Model
Далее опишем модель, где и View, и Controller общаются с Model напрямую
Тут мы уже делаем максимально валидный пример, когда в Controller мы передаём Model, а во View Controller. По такой регрессии мы реализуем полностью заменяемые модули, которые должны соответствовать просто по интерфейсам для взаимного переиспользования.
Далее опишем модель пользователя, внутри которой будет логика работы с пользователем и фильтрации списка пользователей
modules / user / UserModel.ts
import {delay} from "../../helpers";
export interface User {
id: string;
username: string;
age: number;
createdAt: string;
}
export type SortOrder = 'asc' | 'desc';
export type SortField = 'age' | 'username';
export class UsersModel {
users: User[];
searchValue: string;
sortOrder: SortOrder;
sortField: SortField;
constructor() {
this.users = [];
this.searchValue = '';
this.sortOrder = 'asc';
this.sortField = 'username';
}
// запрос пользователей с aka сервера
async fetchUsers(): Promise<User[]> {
try {
return this.users;
} catch (e) {
this.users = [];
return [];
}
}
// создание пользователей
createUser(username: string, age: number) {
if(this.users.find(user => user.username === username)) {
throw Error('Пользователь уже существует')
}
const newUser: User = {
id: String(Math.random()),
username,
age,
createdAt: Date.now().toString(),
}
this.users.push(newUser)
return newUser;
}
// сортировка пользователей
sortUsers(field: SortField, order: SortOrder) {
const sortedUsers = [...this.users.sort((a, b) => {
if(order === "asc") {
return a[field] > b[field] ? 1 : -1;
}
return a[field] < b[field] ? 1 : -1;
})]
this.users = sortedUsers;
return sortedUsers;
}
}
Контроллер же оборачивает логику модели и предоставляет её нашей вюшке
modules / user / UserController.ts
import {SortField, SortOrder, UsersModel} from "./UsersModel";
export class UsersController {
model: UsersModel;
constructor(model: UsersModel) {
this.model = model
}
// создание пользователя
public handleCreate(username: string, age: number) {
console.log('handleCreate')
if(!username || !age) {
throw Error('Укажите username и age');
}
return this.model.createUser(username, age);
}
// сортировка пользователя
public handleSort(field: SortField, order: SortOrder) {
console.log('handleSort')
if(!field) {
throw Error('Укажите поле сортировки');
}
return this.model.sortUsers(field, order);
}
}
А тут уже находится логика View, которая в себе использует контроллер и методы из модели пользователя.
- через контроллер мы получаем актуальные значения из модели
- через методы модели мы заносим новые значения
modules / user / UserView.ts
import {UsersController} from "./usersController";
import {SortField, SortOrder, User} from "./UsersModel";
import './user.css';
export class UsersView {
controller: UsersController;
root: HTMLElement;
private form: HTMLDivElement;
private users: HTMLElement;
private usernameInput: HTMLInputElement;
private ageInput: HTMLInputElement;
private createButton: HTMLButtonElement;
private sortSelectors: HTMLDivElement;
private fieldSelect: HTMLSelectElement;
private orderSelect: HTMLSelectElement;
private sortButton: HTMLButtonElement;
constructor(root: HTMLElement, controller: UsersController) {
this.root = root;
this.controller = controller;
this.createUserForm()
this.createSortSelectors()
this.createUsersList();
this.bindListeners();
}
private onCreateClick = () => {
try {
const newUser = this.controller.handleCreate(this.usernameInput.value, Number(this.ageInput.value))
this.renderNewUser(newUser);
} catch (e) {
this.showError((e as Error).message)
}
}
private onSortClick = () => {
const newUsers = this.controller.handleSort(this.fieldSelect.value as SortField, this.orderSelect.value as SortOrder)
this.renderUsers(newUsers);
}
private bindListeners() {
this.createButton.addEventListener('click', this.onCreateClick)
this.sortButton.addEventListener('click', this.onSortClick)
}
private showError(message: string) {
alert(message);
}
private getUserElement(user: User) {
return `
<div class="user">
<h3>Username = ${user.username}</h3>
<h5>Age = ${user.age}</h5>
</div>
`
}
private renderNewUser(user: User) {
const userNode = document.createElement('div');
userNode.innerHTML = this.getUserElement(user);
this.users.appendChild(userNode)
}
private renderUsers(users: User[]) {
const usersElements = users.map(user => {
return this.getUserElement(user);
})
this.users.innerHTML = usersElements.join('')
}
private createUsersList() {
this.users = document.createElement('div');
}
private createSortSelectors() {
this.sortSelectors = document.createElement('div');
this.fieldSelect = document.createElement('select');
const usernameOption = document.createElement('option');
usernameOption.value = 'username';
usernameOption.innerText = 'Имя пользователя';
const ageOption = document.createElement('option');
ageOption.value = 'age';
ageOption.innerText = 'Возраст';
this.fieldSelect.add(usernameOption);
this.fieldSelect.add(ageOption);
this.orderSelect = document.createElement('select');
const ascOption = document.createElement('option');
ascOption.value = 'asc';
ascOption.innerText = 'По возрастанию';
const descOption = document.createElement('option');
descOption.value = 'desc';
descOption.innerText = 'по убыванию';
this.orderSelect.add(ascOption);
this.orderSelect.add(descOption);
this.sortButton = document.createElement('button');
this.sortButton.innerText = 'сортировать';
this.sortSelectors.appendChild(this.fieldSelect)
this.sortSelectors.appendChild(this.orderSelect)
this.sortSelectors.appendChild(this.sortButton)
}
private createUserForm() {
this.form = document.createElement('div');
this.usernameInput = document.createElement('input');
this.usernameInput.placeholder = 'Введите имя пользователя'
this.ageInput = document.createElement('input');
this.ageInput.placeholder = 'Введите возраст'
this.createButton = document.createElement('button');
this.createButton.innerText = 'Создать'
this.form.appendChild(this.usernameInput)
this.form.appendChild(this.ageInput)
this.form.appendChild(this.createButton)
}
public mount() {
this.root.innerHTML = `
<h1>Пользователи</h1>
`
this.root.appendChild(this.sortSelectors)
this.root.appendChild(this.form)
this.root.appendChild(this.users)
}
}
Тут мы уже собираем наше приложение со всеми моделями и контроллерами. Основной метод mount()
в каждом из них монтирует View на странице
mount.ts
import {CounterTwoView} from "./modules/counterTwo/CounterTwoView";
import {UsersView} from "./modules/users/UsersView";
import {UsersController} from "./modules/users/usersController";
import {UsersModel} from "./modules/users/UsersModel";
const counterView = new CounterTwoView(document.getElementById('counter1')!)
counterView.mount();
const usersModel = new UsersModel();
const usersController = new UsersController(usersModel)
const usersView = new UsersView(
document.getElementById('users')!,
usersController
)
usersView.mount();
Итог
MVC представляет из себя подход, в котором мы делим бизнес-логику и представление. Это деление позволяет нам легко заменять разные части приложения друг другом.
- Модель - является единственным источником истины для приложения
- Контроллер - является интерфейсом, которым пользуется представление, для обработки и передачи данных в модель
- Представление - это непосредственно логика интерфейса пользователя, которая пользуется контроллером.
MVVM
Этот паттерн применим только к графическим интерфейсам
Тут у нас появляется сущность ViewModel вместо Controller
То есть такой подход говорит нам связывать данные напрямую из view с model с помощью двустороннего связывания (как v-bind во Vue или банан в коробке [()]
Angular)
И последовательность в таком приложении достаточно короткая - связываем данные из View с ViewModel, где VM общается по обновлению и уведомлению с Model, чтобы связанные данные обновили интерфейс View
MVP
MVP - это паттерн в котором мы полностью убираем логику из View и вызываем обновления изнутри Presenter
Презентер полностью управляет взаимодействием модели и представления. Представление отправляет события (например, нажатие кнопки) в презентер, который затем взаимодействует с моделью и обновляет представление.