MVC — Model-View-Controller

Зачем нужно

MVC — архитектурный паттерн, разделяющий приложение на три слоя: Model (данные и логика), View (отображение) и Controller (обработка ввода). Цель — разделение ответственности: изменение интерфейса не затрагивает бизнес-логику и наоборот.

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

  • Серверные фреймворки (Express.js, Ruby on Rails, Django, Spring)
  • Классические фронтенд-фреймворки (Backbone.js, Ember.js)
  • Мобильная разработка (UIKit/iOS, Android Activities)
  • Любое приложение, где нужно разделить данные и представление

Компоненты MVC

Пользователь
    │
    ▼
Controller ──── обрабатывает ввод, вызывает Model
    │
    ▼
Model ────────── данные, бизнес-логика, валидация
    │
    ▼
View ─────────── отображает данные из Model
    │
    ▼
Пользователь видит результат

Model — данные и бизнес-логика

class TodoModel {
  constructor {
    this.todos = ;
    this.listeners = ;
  }

  // Подписка на изменения
  subscribe(listener) {
    this.listeners.push(listener);
  }

  notify {
    this.listeners.forEach(fn => fn(this.todos));
  }

  addTodo(text) {
    this.todos.push({
      id: Date.now(),
      text,
      done: false,
    });
    this.notify;
  }

  toggleTodo(id) {
    const todo = this.todos.find(t => t.id === id);
    if (todo) {
      todo.done = !todo.done;
      this.notify;
    }
  }

  removeTodo(id) {
    this.todos = this.todos.filter(t => t.id !== id);
    this.notify;
  }

  getAll {
    return [...this.todos];
  }

  getActive {
    return this.todos.filter(t => !t.done);
  }
}

View — отображение

class TodoView {
  constructor(container) {
    this.container = container;
    this.onAdd = null;      // Колбэки для Controller
    this.onToggle = null;
    this.onRemove = null;
  }

  render(todos) {
    this.container.innerHTML = `
      <div class="todo-app">
        <div class="input-group">
          <input type="text" id="todo-input" placeholder="Новая задача">
          <button id="add-btn">Добавить</button>
        </div>
        <ul class="todo-list">
          ${todos.map(todo => `
            <li class="${todo.done ? 'done' : ''}">
              <input type="checkbox" data-id="${todo.id}"
                     ${todo.done ? 'checked' : ''}>
              <span>${todo.text()}</span>
              <button data-remove="${todo.id}">×</button>
            </li>
          `).join('')}
        </ul>
        <p>Осталось: ${todos.filter(t => !t.done).length}</p>
      </div>
    `;
    this.bindEvents;
  }

  bindEvents {
    const input = this.container.querySelector('#todo-input');
    const addBtn = this.container.querySelector('#add-btn');

    addBtn?.addEventListener('click', () => {
      if (input.value.trim()) {
        this.onAdd?.(input.value.trim());
        input.value = '';
      }
    });

    input?.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && input.value.trim()) {
        this.onAdd?.(input.value.trim());
        input.value = '';
      }
    });

    this.container.querySelectorAll('[data-id]').forEach(cb => {
      cb.addEventListener('change', () => {
        this.onToggle?.(Number(cb.dataset.id));
      });
    });

    this.container.querySelectorAll('[data-remove]').forEach(btn => {
      btn.addEventListener('click', () => {
        this.onRemove?.(Number(btn.dataset.remove()));
      });
    });
  }
}

Controller — связующее звено

class TodoController {
  constructor(model, view) {
    this.model = model;
    this.view = view;

    // View уведомляет Controller о действиях пользователя
    this.view.onAdd = (text) => this.model.addTodo(text);
    this.view.onToggle = (id) => this.model.toggleTodo(id);
    this.view.onRemove = (id) => this.model.removeTodo(id);

    // Model уведомляет View об изменениях данных
    this.model.subscribe((todos) => this.view.render(todos));

    // Первый рендер
    this.view.render(this.model.getAll);
  }
}

// Запуск
const model = new TodoModel();
const view = new TodoView(document.getElementById('app'));
const controller = new TodoController(model, view);

Вариации MVC

MVP — Model-View-Presenter

View ←──→ Presenter ←──→ Model

Отличие от MVC:
- View НЕ знает о Model
- Presenter получает данные из Model и передаёт в View
- View — «тупой», только отображает то, что скажет Presenter
class TodoPresenter {
  constructor(model, view) {
    this.model = model;
    this.view = view;

    this.view.onAdd = (text) => {
      this.model.addTodo(text);
      this.updateView;
    };

    this.view.onToggle = (id) => {
      this.model.toggleTodo(id);
      this.updateView;
    };

    this.updateView;
  }

  updateView {
    // Presenter полностью контролирует что видит View
    const todos = this.model.getAll;
    const activeCount = this.model.getActive.length;
    this.view.render({ todos, activeCount });
  }
}

MVVM — Model-View-ViewModel

View ←──binding──→ ViewModel ←──→ Model

Отличие:
- ViewModel содержит данные для отображения
- Two-way data binding: изменение в View → ViewModel → View
- Vue.js, Angular, Knockout.js используют MVVM
// ViewModel — реактивный объект, связанный с View
class TodoViewModel {
  constructor {
    this.todos = ;        // Данные для View
    this.newTodoText = '';  // Связано с input
    this.filter = 'all';   // Связано с radio-buttons
  }

  // Computed property
  get filteredTodos {
    switch (this.filter) {
      case 'active': return this.todos.filter(t => !t.done);
      case 'done': return this.todos.filter(t => t.done);
      default: return this.todos;
    }
  }

  // Команды (methods)
  addTodo {
    if (this.newTodoText.trim()) {
      this.todos.push({
        id: Date.now(),
        text: this.newTodoText,
        done: false,
      });
      this.newTodoText = '';
    }
  }

  toggleTodo(id) {
    const todo = this.todos.find(t => t.id === id);
    if (todo) todo.done = !todo.done;
  }
}

// Во Vue это выглядит нативно:
// const vm = new Vue({
//   data { return { todos: , newTodoText: '' } },
//   computed: { filteredTodos { ... } },
//   methods: { addTodo { ... } }
// })

Сравнение MVC / MVP / MVVM

Аспект MVC MVP MVVM
View знает о Model Да Нет Нет
Посредник Controller Presenter ViewModel
Связь View↔Model Через Observer Через Presenter Data binding
Тестируемость Средняя Высокая Высокая
Сложность Низкая Средняя Средняя
Примеры Express, Rails Android MVP Vue, Angular

MVC в Express.js (серверный)

// Model — models/User.js
class User {
  static async findAll {
    return db.query('SELECT * FROM users');
  }

  static async findById(id) {
    return db.query('SELECT * FROM users WHERE id = ?', [id]);
  }

  static async create(data) {
    return db.query('INSERT INTO users SET ?', data);
  }
}

// Controller — controllers/userController.js
const User = require('../models/User');

exports.getUsers = async (req, res) => {
  const users = await User.findAll;
  res.json(users);
};

exports.getUser = async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) return res.status(404).json({ error: 'Not found' });
  res.json(user);
};

exports.createUser = async (req, res) => {
  const user = await User.create(req.body);
  res.status(201).json(user);
};

// Routes (часть Controller) — routes/users.js
const express = require('express');
const router = express.Router;
const userCtrl = require('../controllers/userController');

router.get('/', userCtrl.getUsers);
router.get('/:id', userCtrl.getUser);
router.post('/', userCtrl.createUser);

module.exports = router;

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

  1. Бизнес-логика в Controller — Controller должен быть тонким, логика в Model
  2. Бизнес-логика в View — View только отображает, не вычисляет
  3. Model зависит от View — Model не должен знать как данные отображаются
  4. Жирный Controller — превращается в God Object, нужно выделять Service-слой
  5. Путаница паттернов — смешивают MVC, MVP, MVVM в одном проекте

Практика

  1. Реализовать TODO-приложение по паттерну MVC (Model + View + Controller)
  2. Переделать в MVP — View не знает о Model
  3. Добавить серверный MVC на Express (Model → DB, Controller → Routes)
  4. Сравнить: написать тесты для Model — они не зависят от View

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

Ресурсы