UI/UX: Учимся использовать настоящий MVC25.03.2025 00:16
Весь UI — это композиция MVC
В 1972 году лаборатория Xerox PARC первой в мире изобрела компьютеры с графическим пользовательским интерфейсом (GUI). До этого момента все компьютеры управлялись через перфокарты или консоль. Это было прорывное изобретение. Всё оставшееся десятилетие инженеры лаборатории потратили на его улучшение. Именно они изобрели первые компьютерные мыши и язык SmallTalk, который заложил основу ООП. В ~1987 году Trygve Reenskaug создаёт паттерн MVC для более эффективной разработки GUI. Именно этот паттерн по праву считается первым в мире шаблоном проектирования.
MVC задумывался как способ упрощения организации кода в графических интерфейсах. Он разделял элементы приложения на три категории: Model, View и Controller. Model представляла из себя реализацию ментальной модели, которая укладывалась в голове пользователя. View являлось представлением этой модели на экране. А Controller был набором методов, через которые пользователь мог взаимодействовать с моделями и программой в целом.
Идеи MVC не имели ничего общего с архитектурой всего приложения. Каждый MVC был маленьким, и отвечал за небольшие элементы на экране: кнопку, навигационное меню, input формы. К сожалению истинная ценность MVC со временем потерялась. Сейчас этот паттерн больше известен как способ структурировать файлы в серверных web-приложениях. С чем он, по моему мнению, справляется плохо.
В этой статье я расскажу о том, с чем MVC справляется хорошо; об идеях, которые были заложены в него создателем. Мы рассмотрим, как MVC улучшит UI и UX ваших приложений, сделав их понятнее и прозрачнее для пользователей. Рассмотрим способы организации кода по этому паттерну, чтобы вы смогли сразу применить эти знания в своей работе. А так же посмотрим на несколько вариаций этого паттерна, которые улучшают тестирование и ускоряют разработку.
Категория MVC
Что такое MVC
На дворе 1970-ые года. Программисты всю жизни вводили в компьютер данные через перфокарты или через консоль. Достаточно было написать команду на понятном языке и компьютер всё сделает сам. А теперь начали появляться «какие-то GUI», где для получения того же результата предлагалось водить по столу какой-то коробочкой и нажимать курсором на прямоугольники.
Раньше никто с такими технологиями не работал. Нужно было сделать GUI привычнее и понятнее. И исследователи из PARC обратились к тому, что сейчас мы бы назвали иммерсивностью или скевоморфизмом (первое больше про поведение, а второе про внешний вид).
Идея была в том, чтобы превратить абстрактные прямоугольники на экране в элементы, по своему поведению и внешнему виду похожие на модели из реального мира. Если на экране кнопка, то она должна выглядеть как кнопка и нажиматься как кнопка. Если это форма с полями, то пусть она выглядит как бланк с пропусками. Если это навигация по страницам (они же документы), то пусть навигация выглядит как закладки в каталогах. Своего рода «Принцип наименьшего удивления». Эту идею в последствии доведут до совершенства Стив Джобс, Стив Возняк и инженеры компании Apple.
Models
Как нам перенести представление о предмете из реального мира на экран? Для решения задач подобного рода мы применяем абстрагирование. Мы раскладываем предмет на свойства, и выбираем только те, что важны для него в данной ситуации. У каждого предмета есть основные свойства: стол — это горизонтальная поверхность, фонарик — излучает свет, кружка — это ёмкость для жидкости. Основные свойства определяются назначением предмета и вариантами его использования: стол горизонтальный — на него можно что-то положить, фонарик излучает свет — его можно использовать для ориентации в темноте, кружка это ёмкость — в неё можно что-то налить или насыпать, и это что-то не разольётся и не рассыплется.
Результат абстрагирования до нужного нам состояния мы и будем называть моделью (Model). Модель — это набор свойств и вариантов взаимодействия с предметом, которые мы хотим смоделировать в пользовательском интерфейсе.
Это не обязательно должен быть предмет из реального мира. Главное — чтобы пользователь находил модель понятной. Чтобы в его голове сложился пазл: «Это вещь обладает такими свойствами, и я могу вот так с ней взаимодействовать».
Как нам смоделировать форму аутентификации? Какие свойства и поведение определяют такую форму? Ответив на эти вопросы мы получим её модель. Основные свойства формы — это набор input’ов, куда нужно вводить данные. А основные варианты взаимодействия — это ввод и отправка этих данных. Чуть позже мы посмотрим, как такие модели можно выразить в коде.
P.S. Один из способов подобного моделирования — это сделать объект, в котором будут свойства с данными из текстовых полей и метод для отправки всей формы на сервер. Если слушая про ООП вам начинают рассказывать про моделирование реального мира через классы «котов» и «машин», то знайте, что это пошло отсюда. Только когда Алан Кей работал над SmallTalk, он предполагал, что главная задача объектов — это обмен сообщениями. А моделирование поведения это лишь необходимое условие. Поэтому если ваша программа работает с котами — это еще не значит, что вам нужно создавать классы котов, потому что коты могут и не обмениваться сообщениями. И тогда классы превратятся не в полезный инструмент организации взаимодействия, а в способ организации кода, что уже дело вкуса.
Вариант абстрагирования «Формы регистрации» к модели
Views
Теперь, когда у нас есть модель, нам нужно отобразить её на экране. Главное условие здесь — дизайн должен с точностью отображать модель. Он должно отражать все данные модели, и ясно показывать все варианты взаимодействия с ней. Дизайн, отражающий модель, мы будем называть View.
Продолжим пример с формой регистрации. Предположим, что мы верстаем такую форму с помощью HTML.
Пользователь должен заполнить поля ФИО и Email. Сделаем два input’а: один для имени, другой для email’а. Чтобы лучше отразить модель — создадим label для каждого input’а. Можем ещё улучшить input’ы, заменив их форму с обычного текста на специфическую для каждого типа (ФИО разложим на 3 input’а, а у email будем отдельно вводит имя пользователя и адрес сайта).
Ссылка на соглашение может быть реальной ссылкой, открывающей новую вкладку. А может быть кнопкой, показывающей модальное окно с текстом соглашения. Мы даже можем сделать так, чтобы при нажатии на ссылку пользователю предлагалось скачать документ с пользовательским соглашением, заверенный электронной подписью.
Согласие с пользовательским соглашением отобразим checkbox’ом.
Для отправки формы можно использовать обычную кнопку, которая сделает HTTP запрос. Или, если это внутренняя система с которой работают операторы, мы можем послать форму на печать. Данные подставятся в бланк (как на предыдущей картинке слева) и клиент поставит свою подпись на распечатанном документе.
Варианты графических представлений для модели «Формы регистрации»
Обратите внимание, что на основе одной модели можно придумать огромное кол-во разных View, исходя из контекста, специфики компании, бюджета на дизайн и т.п. Потенциальное кол-во ограничено лишь вашей фантазией.
Controllers
Настало время запрограммировать взаимодействие пользователя с моделью. Пользователь генерирует события через устройства ввода (клавиатуру, мышь, сенсор). Модель реагирует на эти события и изменяется, согласно заложенной в ней логике. В обязанности контроллера же входит принимать эти события из разных источников и перенаправлять их в нужные модели.
При инициализации приложения или его отдельной части контроллеры подписываются на события устройств ввода, после чего следят за ними и реагируют на те события, которые входят в их зону ответственности. Для JavaScript подпиской контроллера на событие может служить вызов метода document.addEventListener('someEvent', controller). В данном случае контроллером будет функция, передаваемая в метод вторым аргументом.
Для одной модели может существовать несколько контроллеров. Представьте, что вы работаете с документом и хотите его сохранить. Вы можете сделать это через меню, выбрав пункт «Сохранить». На это отреагирует контроллер, который может называться DocumentMenuController. А можете нажать «Ctrl+S», на который отреагирует DocumentKeyboardController.
Все действия с моделью сначала идут в контроллер. Модель не подписывается на события устройств ввода. У неё свои обязанности. Такой подход помогает не только оставлять модель чистой, но и собирать события из нескольких источников одновременно.
Генерировать события для модели может не только пользователь, но и система. Например, данные должны отобразиться только тогда, когда будет завершён GET запрос с получением этих данных. Завершение запроса и будет событием, на которое среагирует контроллер.
Варианты Controller’ов для модели «Документ»
Запрограммируем ProgressBar с помощью MVC
Теперь давайте углубимся в детали реализации и посмотрим, как можно создать MVC в коде программы. Будем моделировать «ожидание окончания загрузки». В качестве языка используем TypeScript.
Model
Начнём с модели. Как человек представляет себе «ожидание окончания загрузки»? Если мы говорим только про факт загрузки, то она либо есть, либо нет. Это boolean значение. Если мы что-то делаем долгое время, то было бы неплохо показывать человеку прогресс. Например, полосочкой, которая постепенно заполняется. Вот у нас получилось 2 модели.
Loader в коде мог бы выглядеть так:
// Модель показывает факт наличия загрузки. Она либо есть, либо нет.
type Loader = {
isLoading: boolean,
}
class LoaderModel
{
// Данные модели
private loader: Loader = {
isLoading: false,
};
public get model(): Loader
{
return this.loader;
}
// Интерфейс для контроллера
public start(): void {
this.loader.isLoading = true;
}
public cancel(): void {
this.loader.isLoading = false;
}
public finish(): void {
this.loader.isLoading = false;
}
}
Обратите внимание, что в классе модели есть несколько методов, работающих с ней. Это важный аспект при разработке моделей. Когда мы моделируем концепцию, мы продумываем не только необходимые для существования этой концепции данные, но и как пользователь ожидает с этими данными взаимодействовать. В случае с Loader он ожидает, что загрузка может начаться, может завершиться…, а может и быть отменена.
Мы редко имеем такую возможность в WEB. Когда запрос уже отправлен на сервер, мы не можем на него повлиять. И как следствие не можем его отменить. Анализ модели заставил нас подумать о том, что пользователь мог бы ожидать такого поведения: «Если я устану ждать загрузку, то я нажму на крестик и загрузка прекратится». Мы можем не реализовывать такое поведение. Но мы знаем, что если у нас будет такая возможность, то этот метод лучше добавить в наш контроллер. Так компонент будет ближе к представлению, которое о нём есть у пользователя.
Progress в коде мог бы выглядеть так:
// Модель показывает прогресс какого-либо действия в процентном (%) эквиваленте
type Progress = {
percentages: number,
isGoing: boolean,
}
class ProgressModel
{
// Данные модели
private progress: Progress = {
percentages: 0,
isGoing: false,
};
public get model(): Progress {
return this.progress;
}
// Интерфейс для контроллера
public start(): void {
this.progress.percentages = 0;
this.progress.isGoing = true;
}
public finish(): void {
this.progress.percentages = 0;
this.progress.isGoing = false;
}
public update(percentages: number): void {
switch (true) {
case percentages <= 0:
this.progress.percentages = 0;
break;
case percentages >= 100:
this.progress.percentages = 100;
break;
default:
this.progress.percentages = percentages;
}
}
}
В модели Progress мы ввели ограничения на возможные состояния данных. Progress.percentages может принимать значение только от 0 до 100, потому что это проценты.
View
Отлично. Перейдём к графическим представлениям. Для маленького Loader, например для отображения в кнопке, можно придумать различные иконки:
Варианты маленьких дизайнов для модели Loader
Для большого Loader, например на всю страницу, можно использовать что-то простое:
Простой большой Loader
А можно превратить всю страницу в произведение искусства:
Сложный и красивый большой Loader
Обратите внимание, что модель при этом одна и та же: загрузка есть и нужно ждать, или загрузки нет и на странице ничего не происходит. В идентификации ментальной модели пользователя и её отделении от разных графических представлений как раз и заключается теоретическая основа паттерна MVC.
Observer между Model и View
»View реагирует на изменения в Model» — гласит одна из стрелок на второй схеме в статье. Как это понимать? Каждый раз, когда изменяется состояние модели (Model), графическое представление этой модели (View) должно отразить это изменение для пользователя. Когда модель Loader переходит из состояния isLoading = false в isLoading = true, на экране должен появиться Loader. Когда isLoading снова становится false, тогда Loader с экрана должен исчезнуть.
Существует несколько способов реализации такого реагирования:
Через Контроллер/Mediator:
Контроллер изменяет модель
Контроллер получает новое состояние модели
Контроллер отдаёт его в View
С помощью паттерна Observer:
Модель даёт возможность другим объектам подписываться на обновления своих состояний. При каждом обновлении объект модели посылает всем подписавшимся на него своё новое состояние
View при создании подписывается на обновление модели
Когда модель изменяется, она отправляет View своё новое состояние
С помощью паттерна Proxy:
Создаётся объект типа Proxy с тем же интерфейсом, что и у модели. Теперь любой метод, вызванный на модели, будет обрабатываться объектом Proxy до и после вызова
Добавляем в Proxy объект View
При каждом вызове метода на модели он будет проходить через Proxy, и когда метод модели завершит свою работу, Proxy будет брать новое состояние модели и прокидывать его в View
Можно придумать еще много других вариантов. Некоторые из них описаны в GOF. Другие поставляются вместе с UI-Framework’ами.
Полная реализация
Обычно я пишу client-side на Vue.js, поэтому воспользуюсь его механизмами для создания подписки на обновления. Вот так может выглядеть полная реализация модели Progress и одного из её представлений -ProgressBar:
Одно из многих возможных View для модели Progress. В данном случае это ProgressBar (постепенно заполняющаяся горизонтальная шкала):
import type {Progress} from "@/components/progress/progressModel.ts";
defineProps<{
progress: Progress,
}>();
Тестовый контроллер (простой, чтобы можно было покликать):
import ProgressBar from "@/components/progress/ProgressBar.vue";
import {ProgressModel} from "@/components/progress/progressModel.ts";
const progress = new ProgressModel();
Во всех примерах используется Vue.js + TypeScript + TailwindCSS. Можете скопировать код в новый проект и изучить его самостоятельно. Рассматривайте это просто как один из вариантов реализации.
Вариации на тему: MVP и MVVM
Предположим, мы начали организовывать наши компоненты по MVC. Но как мы можем протестировать View? Можно создать модель, связать её с View, перевести модель в нужное состояние и… что теперь? Нам нужно проверить, что View корректно отображает модель. Обратите внимание, что в MVC в обязанности View входит и перевод модели в презентабельное состояние, и отображение модели на экране.
Но тестировать отображение на экране трудно. Можно сохранить все возможные варианты представления (например html) и соотнести их с состоянием моделей. Для этого хорошо подходят характеризационные тесты (characterization test). Либо можно превратить View в строку и искать в ней вхождение тестируемых подстрок. Но чтобы написать и поддерживать такие тесты уйдет много времени. Как нам упростить этот процесс?
Отображение модели на экране тестировать тяжело. А что насчёт перевода модели в презентабельное состояние? Представьте, что мы пишем на JS/TS и у нас в модели есть время в формате Date. Но мы не можем отобразить его на экране сразу. Сначала нам нужно его отформатировать — привести к строке, например: 2025.03.23. Можем ли мы протестировать эту обязанность отдельно от обязанности отображения этих строк на экране? Примерно такими размышлениями мы можем дойти до MVP.
Мы создаём еще 2 категории классов: Presenter и ViewModel. Presenter берёт на себя обязанность следить за изменениями в модели и преобразовывать их в простенькую DTO, которую мы назовём ViewModel. Теперь View останется только взять уже отформатированные строки из ViewModel и отобразить их на экране. Таким образом мы можем протестировать перевод модели в презентабельное состояние даже не генерируя html и/или не поднимая графическую оболочку.
С MVVM немного сложнее. Мы оставляем за моделью и View те же обязанности, что и в MVP. Но между этими двумя категориями объектов есть остальные прослойки (Controller и наши новые Presenter и ViewModel). Controller принимает события от пользователя и изменяет модель. Но откуда пользователь запустил эти события? Они были на его экране, отображённые через View. Presenter отформатировал данные из модели, и передал их в View. Модель и View здесь самые главные. Всё остальное — вспомогательные элементы.
Вот эти вспомогательные элементы MVVM и предлагает объединить в одну категорию: ViewModel. Да, термин тот же, но значение у него уже другое. ViewModel будет реагировать на события от View и изменять модель, а потом брать данные изменённой модели, форматировать данные и передавать их «обратно» в View. Удобная вещь, когда для реализации используется компонентный подход (как, например, в Svelte или Vue.js).
Добавлю немного от себя. Для меня разделений на MVP и MVVM не существует. Я вижу все варианты MVC как схему, которую нарисовал выше. Это объединение всех категорий классов из MVC/MVP/MVVM, которые собраны в одну категорию и между которыми выстроены лучшие, на мой взгляд, связи.
Многие считают, что View в MVC не подписывается на модель, а все данные проходят через Controller. В таком случае контроллер работает как шаблон Mediator. То же самое и про MVP — только там Mediator — это Presenter. Я считаю такие связи хуже, чем подписки на события.
Я держу в голове только одну большую схему с теми связями, которые там нарисованы. И, в зависимости от ситуации, объединяю обязанности нескольких категорий классов или меняю между ними типы стрелок. То есть у меня на проекте может быть что-то из основных трёх шаблонов, может быть просто M-VM, а может все сразу (MVC-P-VM?). Хотя, скорее всего, такого никогда не случалось. Для меня запомнить одну модель, а потом создавать на лету её вариации проще, чем запоминать 3 отдельных шаблона. Можете использовать мой подход, если тоже считаете его удобным.
Как использовать это в работе
Если вы дизайнер, то попробуйте создавать компоненты на основе моделей. Возьмите первый пришедший в голову дизайн компонента, который удовлетворит потребности пользователя. Абстрагируйте его до модели: выпишите необходимые данные и как с этим компонентом можно взаимодействовать. А потом прикиньте несколько других вариантов дизайна на основе получившейся модели. Так вы сможете найти интересные решения и ваши дизайны станут разнообразнее. А продумывание вариантов использования улучшит пользовательский опыт.
Если вы разработчик, то попробуйте разбивать компоненты из макетов на 3 категории из MVC. Посмотрите на компонент, идентифицируйте данные и методы, которые будут нужны для его реализации, а потом воссоздайте их в коде, строго разделив обязанности. Так ваши программы станут надёжнее, ведь работа с моделью заставит продумывать инвариант компонента. А код станет проще для чтения, потому что вы будете строже распределять обязанности между классами.
Заключение
Статья на этом завершена. Надеюсь вы узнали что-то новое и я помог улучшить ваш рабочий процесс. Попробуйте использовать этот паттерн на работе уже завтра. Возьмите один компонент и продумайте его в формате MVC. Возможно вы адаптируете этот процесс под себя и он сделает ваш рабочий процесс эффективнее.
P.S.
Я хочу поделиться с вами моими размышлениями по поводу идеи о ментальных моделях. Эта статья стала промежуточным результатом моих попыток ответить на вопрос: «А что, если мы можем собрать список всех ментальных моделей, использующихся в WEB-дизайне?». Что если это возможно?
Представьте, что у нас есть список из, скажем, 20–30 моделей. Некоторые модели отвечают за отображение. Модели отображения текста можно взять из markdown: заголовки, параграфы, списки. Можно выделить модели группировки: секции, колонки, модальные окна. Для записи можно выделить текстовые поля, ограниченные текстовые поля (цвета, даты, телефоны и т.п.), селекты и загрузку файлов.
Представьте, что этот список конечен и мы собрали все модели. Какие возможности это откроет!
Мы сможем вести библиотеку готовых компонентов, где дизайны будут основаны на моделях. Это облегчит их поиск и даст возможность посмотреть на разные дизайны для одной и той же ментальной модели. Мы сможем выбирать самые подходящие из них в каждой конкретной ситуации
Мы сможем создать градацию дизайнов для одной модели: от самой простой и быстрой в реализации, до самой сложной в реализации, но очень красивой (типа змейки из статьи, которую я так и не нарисовал XD). Пока проект в стадии MVP (не путать с вариацией MVC) мы можем сократить затраты на дизайн, использовав простые варианты компонентов. А потом, когда проект начнёт приносить деньги, мы сможем вложить их в улучшение дизайна. При чем функционально ничего не пострадает. И у нас уже будут готовые варианты того, как улучшить тот или иной компонент
Дизайнеры и программисты смогут использовать такую библиотеку, чтобы улучшать насмотренность и тренироваться делать всё более сложные и красивые компоненты
Программисты смогут копировать готовый код моделей, а потом докручивать их под свой дизайн, что ускорит разработку
Это лишь одно из многих моих «исследований» и я не знаю, как далеко я в нём зайду. Но если вы дочитали до сюда и тоже видите в этой идее потенциал, напишите об этом пару слов в комментариях. Возможно это придаст мне уверенности и я доведу эту работу до конца.