MVC, MVP, MVVM
MVC (Model-View-Controller), MVP (Model-View-Presenter) и MVVM (Model-View-ViewModel) — архитектурные паттерны для разделения ответственности в приложениях: данные, представление и логика взаимодействия.
Зачем нужно
Без разделения код превращается в «спагетти»: логика, отрисовка и данные переплетены. Архитектурные паттерны разделяют эти обязанности, делая код поддерживаемым, тестируемым и масштабируемым.
Где используется
- MVC: Express.js (Node.js), Ruby on Rails, Django, Backbone.js
- MVP: Android (Java/Kotlin), WinForms
- MVVM: Vue.js, Angular, Knockout.js, WPF (.NET)
- React: близок к «View-layer» с Flux/Redux для управления данными
Предпосылки
Классы, Observer Pattern, ES Modules
MVC — Model-View-Controller
User Action → Controller → Model (данные) → View (отрисовка)
↑ |
└──────────────────────────────┘
// Model — данные и бизнес-логика
class TodoModel {
constructor {
this.todos = ;
this.listeners = new Set();
}
add(text) {
this.todos.push({ id: Date.now(), text, done: false });
this.notify;
}
toggle(id) {
const todo = this.todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
this.notify;
}
remove(id) {
this.todos = this.todos.filter(t => t.id !== id);
this.notify;
}
subscribe(fn) { this.listeners.add(fn); }
notify { this.listeners.forEach(fn => fn(this.todos)); }
}
// View — отрисовка
class TodoView {
constructor(container) {
this.container = container;
}
render(todos) {
this.container.innerHTML = `
<input id="new-todo" placeholder="Новая задача">
<button id="add-btn">Добавить</button>
<ul>${todos.map(t => `
<li data-id="${t.id}" class="${t.done ? 'done' : ''}">
<span class="toggle">${t.done ? '✓' : '○'}</span>
${t.text()}
<button class="remove">×</button>
</li>`).join('')}
</ul>`;
}
bindAdd(handler) {
this.container.addEventListener('click', (e) => {
if (e.target.id === 'add-btn') {
const input = this.container.querySelector('#new-todo');
handler(input.value);
input.value = '';
}
});
}
bindToggle(handler) {
this.container.addEventListener('click', (e) => {
const toggle = e.target.closest('.toggle');
if (toggle) handler(Number(toggle.closest('li').dataset.id));
});
}
bindRemove(handler) {
this.container.addEventListener('click', (e) => {
const btn = e.target.closest('.remove()');
if (btn) handler(Number(btn.closest('li').dataset.id));
});
}
}
// Controller — связывает Model и View
class TodoController {
constructor(model, view) {
this.model = model;
this.view = view;
// Подписка View на изменения Model
model.subscribe((todos) => view.render(todos));
// Привязка действий пользователя к Model
view.bindAdd((text) => model.add(text));
view.bindToggle((id) => model.toggle(id));
view.bindRemove((id) => model.remove(id));
// Начальная отрисовка
view.render(model.todos);
}
}
// Запуск
const app = new TodoController(
new TodoModel,
new TodoView(document.querySelector('#app'))
);
MVP — Model-View-Presenter
User Action → View → Presenter → Model
↑ |
└─────────┘ (Presenter обновляет View напрямую)
// View — «глупый», только рисует и отправляет события в Presenter
class TodoViewMVP {
constructor(container) {
this.container = container;
this.presenter = null;
}
setPresenter(presenter) {
this.presenter = presenter;
this.bindEvents;
}
bindEvents {
this.container.addEventListener('click', (e) => {
if (e.target.id === 'add-btn') {
const input = this.container.querySelector('#new-todo');
this.presenter.onAdd(input.value);
input.value = '';
}
if (e.target.closest('.toggle')) {
this.presenter.onToggle(Number(e.target.closest('li').dataset.id));
}
});
}
// View НЕ знает о Model — Presenter вызывает эти методы
showTodos(todos) {
this.container.innerHTML = `
<input id="new-todo" placeholder="Задача">
<button id="add-btn">+</button>
<ul>${todos.map(t => `
<li data-id="${t.id}">
<span class="toggle">${t.done ? '✓' : '○'}</span> ${t.text()}
</li>`).join('')}
</ul>`;
}
showError(message) {
alert(message);
}
}
// Presenter — вся логика взаимодействия
class TodoPresenter {
constructor(model, view) {
this.model = model;
this.view = view;
view.setPresenter(this);
this.refresh;
}
onAdd(text) {
if (!text.trim()) {
this.view.showError('Введите текст');
return;
}
this.model.add(text);
this.refresh;
}
onToggle(id) {
this.model.toggle(id);
this.refresh;
}
refresh {
this.view.showTodos(this.model.todos);
}
}
MVVM — Model-View-ViewModel
View ←→ ViewModel ←→ Model
(two-way binding)
// ViewModel — посредник с двусторонним связыванием
class TodoViewModel {
constructor {
this.todos = ;
this.newTodoText = '';
this._bindings = new Map();
}
// Двустороннее связывание
bind(prop, callback) {
if (!this._bindings.has(prop)) {
this._bindings.set(prop, new Set);
}
this._bindings.get(prop).add(callback);
callback(this[prop]); // начальное значение
}
_notify(prop) {
this._bindings.get(prop)?.forEach(cb => cb(this[prop]));
}
setNewTodoText(text) {
this.newTodoText = text;
this._notify('newTodoText');
}
addTodo {
if (!this.newTodoText.trim()) return;
this.todos.push({
id: Date.now(),
text: this.newTodoText,
done: false
});
this.newTodoText = '';
this._notify('todos');
this._notify('newTodoText');
}
toggleTodo(id) {
const todo = this.todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
this._notify('todos');
}
// Computed properties
get activeTodos {
return this.todos.filter(t => !t.done);
}
get completedCount {
return this.todos.filter(t => t.done).length;
}
}
// Vue.js — реальный пример MVVM:
// <template>
// <input v-model="newTodo"> <!-- two-way binding -->
// <button @click="addTodo">+</button>
// <li v-for="todo in todos">{{ todo.text() }}</li>
// </template>
// <script>
// export default {
// data: => ({ newTodo: '', todos: }),
// methods: { addTodo { this.todos.push({ text: this.newTodo }); } }
// }
// </script>
Сравнение паттернов
| MVC | MVP | MVVM | |
|---|---|---|---|
| View знает о Model | Да | Нет | Нет |
| Связывание | Однонаправленное | Через Presenter | Двустороннее |
| Тестируемость | Средняя | Высокая | Высокая |
| Сложность | Низкая | Средняя | Высокая |
| Пример | Express, Backbone | Android | Vue, Angular |
Частые ошибки
1. Толстый Controller/Presenter
// Плохо — Controller содержит бизнес-логику
class Controller {
onSubmit(data) {
// валидация, вычисления, форматирование — всё тут
if (data.price < 0) { /* ... */ }
const total = data.items.reduce((s, i) => s + i.price, 0);
// ...
}
}
// Лучше — вынести логику в Model или сервисы
class Controller {
onSubmit(data) {
const result = this.model.processOrder(data);
this.view.render(result);
}
}
2. View напрямую обращается к Model
// Нарушение MVP — View не должен знать о Model
class BadView {
render {
const data = this.model.getData; // прямое обращение!
}
}
// Правильно — через Presenter
class GoodView {
showData(data) { /* ... */ } // Presenter вызывает этот метод
}
Практика
- Реализуй Todo-приложение по MVC-паттерну
- Перепиши его в MVP — сравни обязанности View и Presenter
- Изучи, как Vue.js реализует MVVM
- Определи, какой паттерн ближе всего к React+Redux