Проблема
Было ли такое, что вы исправляли одну проблему и это вызывало появление нескольких новых? Было ли такое, что вы открывали проект и чувствовали, что не хотите к нему прикасаться, потому что малейшее изменение подобно вытаскиванию последнего кирпичика в башне Jenga — может привести к цепочке не очевидных эффектов и ее полному разрушению?
JavaScript — простой для изучения язык программирования, но это качество одновременно является и его слабой стороной: он слишком рано дает ощущение уверенности в собственных силах, поэтому ученики бросаются разрабатывать сложные системы, не разобравшись в лучших практиках их проектирования.
В маленьких проектах и на начальных стадиях слабое качество кода не проявляет себя в должной мере, поэтому на него часто не обращают внимание, но по мере роста и развития системы в ней снежным комом накапливается технический долг, который постепенно устремляет срок добавления каждой следующей функциональности к бесконечности, а желание сотрудников работать с таким кодом — к нулю.
Решение
В этой статье мы расскажем об известном архитектурном паттерне MVVM (Model-View-ViewModel), который хорошо показал свою эффективность в наших React проектах, существенно улучшив качество кода, предсказуемость его работы и возможность автоматического тестирования.
По определению, архитектурный паттерн описывает множество подсистем с их ответственностью и с правилами взаимодействия между ними. Множество архитектурных паттернов пытаются решить сходные с MVVM задачи: уменьшить связность кода, улучшить его расширяемость и тестируемость.
Возникает справедливый вопрос: если вы уже знаете паттерн Flux (и его популярную вариацию — Redux), зачем тратить время на изучение дополнительных? Ответ на него достаточно прост: конечно не нужно, раз Redux идеально подходит вашему проекту, но как вы можете быть в этом уверены, если не знаете альтернатив? Вы будете тащить Redux в каждое новое место, где могли быть варианты и лучше. Единственное верное решение — расширять свою область видимости и делать осознанный выбор, поэтому далее перейдем к знакомству с MVVM.
MVVM
Архитектурный паттерн MVVM состоит из 3 логических блоков:
- Model — источник исходных данных своего бизнес-домена.
- ViewModel — адаптер, соединяющий Model и View: имеет доступ к Model, преобразует ее данные в удобный формат для View, реализует логику взаимодействия с пользователем, вызывая мутации данных в Model.
- View — UI, слой отображения и контакта с пользователем.
- Provider — находится за пределами основной тройки, его роль в связывании частей воедино.
Далее расскажем о том, как все эти компоненты взаимодействуют друг с другом и об их ответственности.
Model
Теория
Model используется в роли источника данных, которые она собирает из сторонних сервисов (например, сервиса сетевого взаимодействия или сервиса локального хранилища) и комбинирует удобным для себя образом, а также предоставляет функции для работы с такими данными.
Model полностью автономна, т.е. не имеет связи ни с какими другими блоками MVVM, поэтому правильность ее данных и методов работы с ними — а это самая критичная с точки зрения бизнес логики часть — может быть верифицирована отдельно (например, с помощью автоматического тестирования).
Практика
В нашем примере в качестве Model представлен только один класс SettingsStore, работающий с настройками вымышленного сайта. В общем случае Model-ей больше либо равно одной: вдобавок к настройкам сайта может понадобиться, например, класс ProfileStore — для работы с профилем пользователя и т.д.
Функциональность в примере нарочно сужена до минимума: SettingsStore умеет загружать по сети (детали реализации скрыты в классе SettingsStoreApi) и сохранять в памяти (в поле availableCountries) список доступных пользователю стран (например, для формы верификации данных пользователя, о которой речь пойдет дальше, но на текущем шаге совершенно не важно как будут использоваться эти данные).
Все зависимости такой класс получает из конструктора (о нашей организации кода в целом и паттерне DI (Dependency Injection) мы напишем в отдельной статье), поэтому его экземпляр может быть легко создан с моками вместо настоящих сервисов и функциональность может быть протестирована в отрыве от остального приложения.
SettingsStoreImpl.ts
ViewModel
Теория
ViewModel — это адаптер между Model и View. К Model она имеет непосредственный доступ, поэтому умеет получать данные и преобразовывать их в удобный для View формат, а также реализует методы взаимодействия с ними, тоже удобные для View.
Ко View доступа у ViewModel нет — ей все равно, кто конкретно будет ее использовать: это может быть React компонент, может быть Vue компонент, а может быть пользовательский интерфейс вообще будет отсутствовать (например, в случае автоматического тестирования).
ViewModel — обычный класс, все зависимости которого переданы ему в конструкторе, таким образом, получаем не только Model, но и ViewModel изолированной и легко тестируемой.
Практика
В нашем примере ViewModel представляет из себя класс VerificationFormViewModel — его экземпляр хранит данные из заполненных полей формы и признак готовности к взаимодействию с пользователем, который зависит от данных в Model. Также ViewModel предоставляет пару методов: очистки и отправки формы на сервер (аналогично Model детали реализации скрыты в классе VerificationFormApi).
Отметим, что на данном этапе все еще нет никакой связи с View, а связь с Model и дополнительными сервисами достигается стандартно — через параметры конструктора. Не выпускаем из головы мысль об изолированном тестировании.
VerificationFormViewModel.ts
View
Теория
View взаимодействует с пользователем: отображает для него данные, полученные из ViewModel, к экземпляру которой она имеет непосредственный доступ, а в ответ на действия пользователя (клики, движения мышью и т.д.) вызывает методы ViewModel.
Удобная работа с View как раз и является основной целью таких UI фреймворков и библиотек, как React. На деле же они со временем обрастают инструментами, позволяющими логически проникать в область ViewModel (например, добавляют внутренние состояния или контекст), потому что так удобнее для основной когорты пользователей. Однако, если вы не позволяете инструментам проникать за пределы зоны их прямой ответственности, то ваш код остается слабо связанным и вы практически не зависите от выбора конкретной библиотеки ни в вопросе разработки, ни в вопросе тестирования.
View в нашем случае — это “глупый” (dumb) React компонент, который используется только чтобы показать данные и передать обратно во ViewModel действия пользователя. Такой компонент получает все необходимые данные и методы через свои параметры (props), в этом случае компоненты остаются логически простыми, предсказуемыми, переиспользуемыми и не зависимыми от остального приложения (что дает возможность, к примеру, вести параллельную разработку в Storybook или даже организовать собственный внешний UI Kit).
Практика
В качестве примера View имеем крайне простой компонент, с которым тоже легко работать параллельно и в изоляции, например в Storybook.
VerificationFormView.tsx
Provider
Теория
Provider — не участвующий в аббревиатуре MVVM, но тоже важный элемент. Он используется, чтобы связать все 3 сущности воедино, а именно, он:
- получает (из DI-контейнера или откуда-то еще) экземпляр(-ы) Model,
- создает экземпляр ViewModel, передавая ему в конструктор необходимые зависимости (Model, сервисы и т.д.),
- создает экземпляр View, передавая ей в параметры части ViewModel, а также связывает View и ViewModel для автоматического поддержания их соответствия друг другу.
Provider должен быть максимально простым и не содержать никакой логики, помимо описанной выше. В случае использования MobX, дополнительно получаем автоматическое обновление View при обновлении ViewModel прямо из коробки (подробнее об этом тоже в одной из следующих статей).
Практика
В примере показано каким образом Provider справляется со своей ролью: собирает полный интерактивный и автоматически обновляемый компонент из простых и полностью независимых блоков: Model, ViewModel и View.
Код сознательно приведен в более полном и каноническом варианте, с небольшой оптимизацией первый блок исчезает вовсе, если создавать ViewModel — как и Model — через DI, во втором блоке тогда остается только получение экземпляра ViewModel с помощью того же собственного хука useInject. Либо первый блок остается как есть, а во втором блоке канонический вариант без использования DI, но с собственным хуком useViewModel, которому передается метод создания экземпляра ViewModel.
VerificationForm.tsx
Заключение
Если вы не знакомы с альтернативами, вы вынуждены решать все проблемы одним и тем же знакомым инструментом. Молоток может быть хорош для одних задач, но нож справится с другими лучше, и ваша задача — уметь выбирать подходящий вариант.
С архитектурным паттерном MVVM вы сможете достичь четкого разделения ответственности в коде, возможности автоматического тестирования логики и независимой разработки интерфейсов, и даже исправлять визуальные дефекты не заглядывая в код интерфейсных компонент.
Надеемся, что у нас получилось расширить ваш набор инструментов. Желаем успехов в его использовании!