Архитектурный паттерн MVC

Как раздать ответственность разным частям кода в программе так, чтобы все не перессорились.

Время чтения: 9 мин

Designing is fundamentally about taking things apart... in such a way that they can be put back together. So separating things into things that can be composed that's what design is.

— Rich Hickey Design. Composition and Performance

Когда мы пишем сложные приложения, нам нужно выполнять различные операции, иногда совершенно друг на друга не похожие:

  • обновить данные на сервере;
  • показать всплывающее окно после клика пользователя;
  • валидировать данные из формы;
  • загрузить дополнительные ресурсы, картинки, скрипты;
  • вызвать стороннее API и обработать ответ.

Считается хорошим тоном делить отличающийся код на модули, которые отвечают за свои конкретные задачи. Как именно разделить код на модули, по каким критериям и принципам — на эти вопросы старается ответить паттерн MVC.

Кратко

Скопировано

MVC (сокращение от Model—View—Controller) — это архитектурный паттерн, который делит модули на три группы:

  • модель (model),
  • представление (view),
  • контроллер (controller).

Модель содержит данные приложения, за которыми приходит пользователь. Например, список своих заказов в интернет-магазине.

Представление показывает эти данные в понятном для пользователя виде. Например, на свёрстанной странице сайта или в приложении на телефоне.

Контроллеры принимают пользовательские команды и преобразуют данные по этим командам. Например, если пользователь нажимает кнопку «Удалить заказ», то контроллер отмечает этот заказ в модели удалённым.

Компоненты архитектуры

Скопировано

В архитектуре MVC пользователь взаимодействует только с представлением — чаще всего это UI.

Пользователь подаёт команды программе. Контроллер получает эти команды и преобразует данные в модели. Модель обновляется и уведомляет представление о том, что нужно перерисовать интерфейс, чтобы отобразить изменения в данных.

Взаимоотношение компонентов в паттерне MVC

Представим, что мы хотим написать приложение-фонарик. У него будет два состояния: включён и выключен. Состояния будут переключаться кнопкой «On/Off». Также у него будут кнопки включения дневного и ночного света, которые будут менять цвет лампочки на синий и жёлтый соответственно.

«Скетч» приложения-примера

Попробуем написать его, используя паттерн MVC.

Модель

Скопировано

Модель содержит данные приложения. Это самый независимый компонент архитектуры, именно от модели зависит, что будет показывать представление, и как будет работать контроллер.

В модели мы будем держать состояние фонарика. По умолчанию мы его выключим:

        
          
          const flashLightModel = {  isOn: false,  color: "blue",}
          const flashLightModel = {
  isOn: false,
  color: "blue",
}

        
        
          
        
      

Когда пользователь включит фонарик, поле isOn должно будет принять значение true, за это будет отвечать контроллер. Поле color содержит, каким цветом фонарик будет гореть.

Контроллер

Скопировано

Контроллер принимает команды от пользователя и преобразует данные в модели согласно этим командам. В нашем приложении контроллер будет содержать функцию для переключения состояния фонарика:

        
          
          const flashLightController = {  toggle() {    flashLightModel.isOn = !flashLightModel.isOn  },}
          const flashLightController = {
  toggle() {
    flashLightModel.isOn = !flashLightModel.isOn
  },
}

        
        
          
        
      

Контроллер может принимать и обрабатывать данные от представления. Например, мы можем переключать цвет специальными кнопками, тогда контроллер проверит, какую кнопку нажали, чтобы включить нужный цвет:

        
          
          const flashLightController = {  // Остальной код  selectColor(e) {    const buttonName = e.target.name    const buttonColors = {      daylight: "blue",      nightlight: "yellow",    }    const preferredColor = buttonColors[buttonName]    flashLightModel.color = preferredColor  },}
          const flashLightController = {
  // Остальной код

  selectColor(e) {
    const buttonName = e.target.name
    const buttonColors = {
      daylight: "blue",
      nightlight: "yellow",
    }

    const preferredColor = buttonColors[buttonName]
    flashLightModel.color = preferredColor
  },
}

        
        
          
        
      

В примере выше контроллер проверяет, кнопку какого цвета нажали: дневного или ночного. В зависимости от нажатой кнопки он выбирает нужный цвет.

Представление

Скопировано

Представление показывает пользователю данные из модели в удобном и понятном виде. В нашем случае представлением будет собственно фонарик, который может гореть двумя цветами, а также кнопки для его включения и переключения цветов.

        
          
          <div class="flashlight"></div><button type="button" name="power">Включить</button><button type="button" name="daylight">Дневной свет</button><button type="button" name="nightlight">Ночной свет</button>
          <div class="flashlight"></div>

<button type="button" name="power">Включить</button>
<button type="button" name="daylight">Дневной свет</button>
<button type="button" name="nightlight">Ночной свет</button>

        
        
          
        
      

Кроме разметки в представление также можно отнести код, который управляет отображением фонарика:

        
          
          const flashLightView = {  redraw() {    const { isOn, color } = flashLightModel    const flash = document.querySelector(".flashlight")    flash.classList.add(`has-color-${color}`)    if (isOn) {      flash.classList.add("is-on")    }  },}flashLightView.redraw()
          const flashLightView = {
  redraw() {
    const { isOn, color } = flashLightModel
    const flash = document.querySelector(".flashlight")

    flash.classList.add(`has-color-${color}`)
    if (isOn) {
      flash.classList.add("is-on")
    }
  },
}

flashLightView.redraw()

        
        
          
        
      

А также — код для обработки событий, которые представление будет отдавать контроллеру:

        
          
          const flashLightView = {  // Остальной код  initEvents() {    const powerButton = document.querySelector(`[name="power"]`)    powerButton.addEventListener("click", () => flashLightController.toggle())    // Код для событий других кнопок  },}
          const flashLightView = {
  // Остальной код

  initEvents() {
    const powerButton = document.querySelector(`[name="power"]`)
    powerButton.addEventListener("click", () => flashLightController.toggle())

    // Код для событий других кнопок
  },
}

        
        
          
        
      

Взаимодействие компонентов

Скопировано

При использовании архитектуры MVC мы определяем, как компоненты будут общаться друг с другом — то есть определяем потоки данных.

Поток данных

Скопировано

В классическом MVC стандартом считается, когда данные:

  • от пользователя передаются представлению;
  • от представления — контроллеру;
  • через контроллер обновляется модель;
  • модель уведомляет представление о том, что что-то изменилось.

Иногда допускается, что компоненты могут общаться напрямую с другими компонентами не по этой схеме, но мы всё же рекомендуем не отходить от канона.

Представление или контроллер?

Скопировано

В MVC часто возникает вопрос, к чему отнести какой-то код: к представлению или контроллеру. В нашем примере выше даже есть такие места.

        
          
          const flashLightView = {  // ...  initEvents() {    const powerButton = document.querySelector(`[name="power"]`)    powerButton.addEventListener("click", () => flashLightController.toggle())  },}
          const flashLightView = {
  // ...

  initEvents() {
    const powerButton = document.querySelector(`[name="power"]`)
    powerButton.addEventListener("click", () => flashLightController.toggle())
  },
}

        
        
          
        
      

Метод initEvents в представлении может относиться и к контроллеру, если мы решим, что централизованная обработка событий конкретных элементов — это задача контроллера.

Так же с методом selectColor в контроллере:

        
          
          const flashLightController = {  // Остальной код  selectColor(e) {    const buttonName = e.target.name    const buttonColors = {      daylight: "blue",      nightlight: "yellow",    }    const preferredColor = buttonColors[buttonName]    flashLightModel.color = preferredColor  },}
          const flashLightController = {
  // Остальной код

  selectColor(e) {
    const buttonName = e.target.name
    const buttonColors = {
      daylight: "blue",
      nightlight: "yellow",
    }

    const preferredColor = buttonColors[buttonName]
    flashLightModel.color = preferredColor
  },
}

        
        
          
        
      

Если мы решаем, что обработка событий — это задача представления, то мы можем отнести функцию выбора цвета в представление, а в контроллере оставить лишь метод для изменения цвета:

        
          
          const flashLightController = {  updateColor(color) {    flashLightModel.color = color  },}
          const flashLightController = {
  updateColor(color) {
    flashLightModel.color = color
  },
}

        
        
          
        
      

Так как MVC позволяет пользователю обращаться напрямую к контроллеру, то конкретных правил здесь нет, только рекомендации:

  • Стоит использовать последовательные правила для контроллера, модели и представления во всём проекте.
  • Если правила нарушаются специально, это стоит зафиксировать в документации вместе с причиной.
  • Правила стоит выводить из зон ответственности каждого компонента, которые стоит определить заранее.

Как много должен знать контроллер?

Скопировано

Другой подобный вопрос, который возникает при работе с MVC — какая зона ответственности у контроллера, где она заканчивается.

При тонком контроллере знание о том, как преобразовывать данные, находится в модели. Это не всегда полезно, потому что может замусорить код модели, но иногда оправданно. Например, если мы не хотим «размазывать» это знание по нескольким компонентам.

Такая проблема называется проблемой тонкого и толстого контроллера. Разные команды решают её по-своему, исходя из договорённостей и выгод и издержек каждого варианта для своего проекта.

Похожие паттерны

Скопировано

Кроме MVC вы могли слышать о других вариациях этой аббревиатуры. Каждая из вариаций — это паттерн, слегка отличающийся от MVC спецификой или ответственностью компонентов.

Model—View—Viewmodel

Скопировано

В MVVM (сокращение от Model—View—Viewmodel) вместо контроллера используется Viewmodel. Это «надстройка» над представлением, которая связывает данные и представление так, что разработчикам больше не нужно писать самим логику обновления UI и обработки команд пользователя.

Диаграмма компонентов архитектуры MVVM

Для работы связывания нужен Binder (биндер) — фреймворк, библиотека или целый язык, который автоматически отображает изменения из модели в UI.

Model-View-Presenter

Скопировано

В MVP (сокращение от Model-View-Presenter) место контроллера занимает презентер.

Главное отличие от MVC в том, как расположены компоненты и, соответственно, как передаются данные. Если в MVC данные передавались по кругу, то в MVP компоненты располагаются по линии. На концах находятся модель и представление, а между ними — презентер.

Диаграмма компонентов архитектуры MVP

Презентер забирает на себя всю логику обработки данных, обновления представления и обработки пользовательских команд.

Представление в этом случае пассивно: оно не делает ничего, кроме отображения данных так, как ему скажет презентер. Если в MVC представление могло брать форматирование вывода на себя, то в MVP за это тоже будет отвечать презентер.

Плюс такого подхода в том, что не возникает вопросов, какой код к чему относится. Минус — в том, что презентер быстро становится большим и сложным. Приходится разбивать его на модули поменьше, вероятно, добавлять дополнительные «слои».