MVP: Model View Presenter

MVP (Model-View-Presenter) — архитектурный паттерн, в котором Presenter выступает посредником между Model и View: View пассивна и делегирует всю логику Presenter-у.

Зачем нужно

В MVC View часто содержит логику (обработчики событий, форматирование). MVP идёт дальше: View становится максимально «тупой» — только отображает данные и делегирует события Presenter-у. Presenter не знает о конкретном View (только об интерфейсе), что делает его независимо тестируемым. MVP популярен в Android-разработке и был широко используем на фронтенде до появления компонентных фреймворков.

Где используется

  • Android разработка (исторически основной паттерн до ViewModel)
  • Backbone.js приложения
  • Тестирование UI-логики в изоляции от DOM
  • Проекты, где важна полная testability презентационного слоя

MVP vs MVC

MVC:
  View ←──── Controller ────► Model
  View может напрямую читать Model

MVP:
  View ←────► Presenter ────► Model
  View НЕ знает о Model, только о Presenter
  Presenter НЕ знает о конкретном View (только IView)

Реализация MVP

// Model — данные и бизнес-логика
class UserModel {
  private users: User = ;

  async fetchUsers: Promise<User> {
    const response = await fetch('/api/users');
    this.users = await response.json();
    return this.users;
  }

  filterByRole(role: string): User {
    return this.users.filter(u => u.role === role);
  }
}

// View Interface — что Presenter ожидает от View
interface IUserView {
  showUsers(users: User): void;
  showLoading(isLoading: boolean): void;
  showError(message: string): void;
  onRoleFilter: (role: string) => void; // колбэк для Presenter
}

// Presenter — вся логика, не зависит от DOM
class UserPresenter {
  constructor(
    private view: IUserView,  // зависимость через интерфейс
    private model: UserModel,
  ) {
    // Подписываемся на события View
    this.view.onRoleFilter = (role) => this.handleRoleFilter(role);
  }

  async loadUsers: Promise<void> {
    this.view.showLoading(true);
    try {
      const users = await this.model.fetchUsers;
      this.view.showUsers(users);
    } catch (error) {
      this.view.showError('Не удалось загрузить пользователей');
    } finally {
      this.view.showLoading(false);
    }
  }

  handleRoleFilter(role: string): void {
    const filtered = this.model.filterByRole(role);
    this.view.showUsers(filtered);
  }
}

// Concrete View — реализует интерфейс
class UserListView implements IUserView {
  private container: HTMLElement;
  onRoleFilter: (role: string) => void = () => {};

  constructor(containerId: string) {
    this.container = document.getElementById(containerId)!;
    document.getElementById('role-filter')!.addEventListener('change', (e) => {
      this.onRoleFilter((e.target as HTMLSelectElement).value);
    });
  }

  showUsers(users: User): void {
    this.container.innerHTML = users
      .map(u => `<li>${u.name} (${u.role})</li>`)
      .join('');
  }

  showLoading(isLoading: boolean): void {
    document.getElementById('loader')!.hidden = !isLoading;
  }

  showError(message: string): void {
    document.getElementById('error')!.textContent = message;
  }
}

// Сборка
const view = new UserListView('user-list');
const model = new UserModel();
const presenter = new UserPresenter(view, model);
presenter.loadUsers;

// В тесте: mock View
const mockView: IUserView = {
  showUsers: jest.fn,
  showLoading: jest.fn,
  showError: jest.fn,
  onRoleFilter:  => {},
};
const presenter = new UserPresenter(mockView, model);

Частые ошибки

  • Fat View — логика «просачивается» в View; View должна быть «пассивной», только рендерить и делегировать.
  • Presenter с DOM-зависимостями — если Presenter обращается к document, он перестаёт быть тестируемым без браузера.
  • MVP вместо MVVM для React — MVVM с его data binding лучше подходит для компонентных фреймворков; MVP — для vanilla JS или легаси.

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

Ресурсы