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 вызывает этот метод
}

Практика

  1. Реализуй Todo-приложение по MVC-паттерну
  2. Перепиши его в MVP — сравни обязанности View и Presenter
  3. Изучи, как Vue.js реализует MVVM
  4. Определи, какой паттерн ближе всего к React+Redux

Связанные темы

Ресурсы