В этой статье рассмотрим, как соблюсти паттерн MVP, не нарушить SOLID и найти подходящие инструменты для реализации Presenter при проектировании приложения на Vue.js.
Представим, что мы разрабатываем to-do приложение. Данные хранятся на бэкенде, приложение запрашивает их по сети, затем их как-то форматирует и преобразовывает. Эти преобразованные данные нужно будет выводить на страницу, а также предоставить пользователю рычаги для их изменения.
Основная сложность выделения Presenter в паттерне MVP при разработке на Vue
Попробуем выделить основные части приложения:
- M — Model — Бэкенд с данными и методами для изменения;
- V — View — Фронтенд с разноцветными кнопками и чекбоксами;
- P — Presenter — Часть приложения между фронтендом и бэкендом, для промежуточной обработки.
О Presenter давайте подробнее. Он должен:
- Отправлять данные на бэкенд;
- Запрашивать данные с бэкенда;
- Предоставлять методы для форматирования данных, приходящих с бэкенда.
- Инкапсулировать внутри себя повторяющуюся логику, которую мы не хотим делегировать Модели напрямую.
В сложном приложении Presenter является не только посредником между бэкендом и фронтендом, но также позволяет инкапсулировать ту повторяющуюся логику, которую мы реализуем на фронтенде, вместо того чтобы запрашивать ее по сети или реализовывать прямо в интерфейсе .
В идеальном мире View-компонент занимается только лишь отрисовкой готовых данных, а все подготовительные процедуры выполняет Presenter.
Пример решения задачи без Presenter
Чтобы стало понятнее, что такое «повторяющаяся логика», и почему не все следует хранить на бэкенде, приведем пример.
Мы хотим, чтобы у премиум-пользователя, обычного пользователя, и демо-пользователя были свои приветственные сообщения на главной странице. Это приводит нас к примерно такой конструкции:
Пока еще такой участок кода можно расширять — при появлении новых ролей понадобится лишь добавить новый пункт в свитч.
Тем не менее, если эту логику хранить прямо в компоненте главной страницы, то уже сейчас от кода будет заметен легкий «душок». Наш компонент начинает не только отображать данные как компонент View, но и интерпретировать их, реализовывать какую-то логику по преобразованию и форматированию, что характерно для Presenter. На данном этапе, скорее всего, метод, реализующий эту логику, будет computed свойством компонента с именем вроде welcomingPageTitle.
О нарушениях SOLID и росте проблемы
Проходит неделя, и заказчик ставит новую задачу с такими требованиями:
Запускаем новую секцию. На главной странице будет отображаться маскот, типа скрепки Clippy из Microsoft Office, который будет давать пользователю подсказки:
1. Демо-пользователю зарегистрироваться;
2. Обычному пользователю купить премиум доступ;
3. Премиум-пользователю продлить подписку;
Задание приняли, идем выполнять!
Стало чуть сложнее, но если мы действуем по принципу «вижу цель, не вижу препятствий», то дальше копипастим кусок логики из другого компонента и чуть-чуть переписываем под свой случай. Здесь мы уже начинаем обращаться к состоянию приложения, но самая большая проблема в том, что мы, во-первых, повторяем сами себя, то есть нарушаем DRY, во-вторых, у нашего компонента слишком много причин для изменений, а это нарушает Single Responsibility Principle — принцип единой ответственности, первый пункт SOLID.
Масштаб проблемы увеличивается, но ее все еще никто не локализовал.
О невозможности масштабировать ПО
Проходит еще неделя, и заказчик сообщает, что логику нужно изменить:
- Демо-пользователю предлагаем сначала купить премиум, а если он уже посещал страницу с оффером — зарегистрироваться бесплатно;
- Обычному пользователю показываем два варианта: перейти на страницу с премиум, если он не посещал ее, а если посещал — настаиваем, чтобы вернулся и купил;
- Премиум-пользователю напоминаем про окончание подписки, только если он видит это сообщение впервые за сессию.
Логики становится слишком много, но мы пытаемся как-то расширить имеющуюся систему:
На этом этапе код становится совсем плохим: появляются вложенные условия, сложные нечитаемые конструкции, и все это прямо внутри View. Возникает соблазн дописать этот вариант до рабочего результата, а сверху оставить предупреждение: «Работает — не лезь!»
Мы же будем умнее и перепишем систему.
Повторяющаяся логика и суть Presenter
Чтобы провести рефакторинг, нужно выделить повторяющуюся логику. В нашем случае нам нужна такая система, которая:
- Глобальная и единственная;
- Имеет доступ к состоянию приложения;
- Умеет принимать решения в зависимости от этого состояния;
- Умеет возвращать какой-то контент в зависимости от запроса.
В идеале внутри View это должно выглядеть так:
Обратите внимание на вызов get(“title”). Мы просто говорим нашей системе, что хотим получить заголовок, и она его отдает каким-то неизвестным компоненту образом. Именно такую логику и следует выносить в Presenter. Такие операции ограничены только лишь нуждами приложения, поэтому выносить их на бэкенд нецелесообразно. При этом такие запросы могут повторяться с незначительными изменениями в разных компонентах по всему приложению. Если эту логику не выделить, мы будем дублироваться.
Пример модуля Presenter
А что, если мы заменим наши getters из хранилища состояния на лямбда-функции, возвращающие булевые значения?
Такие функции называются «предикатами». Предикат всегда возвращает true или false, и обычно по своему смыслу отвечает на вопрос. Например «Посещал ли пользователь страницу с премиумными подписками?» Их имена обычно начинаются с is, has, can и т.д.
Также создаем массив объектов, которые будет хранить набор предикатов и соответствующее значение, подходящее под эти условия. Например в случае с текстом:
Важно: если мы занесем данные в порядке, при котором самое сложное условие будет в конце, нам будет достаточно лишь вернуть первый прошедший проверку вариант с конца.
Это немного напоминает логику работы каскада в CSS, из всех возможных стилей мы выбираем наиболее специфичный.
Также у нас будет метод get, который принимает место в нашей «библиотеке», и возвращает content самого подходящего объекта с данными.
Пример использования:
Идея этого класса взята не из воздуха, а с реального проекта. Автор статьи писал чуть более сложный вариант этой системы для коммерческого приложения.
В дальнейшем этот же алгоритм применялся для поиска конфигурационных файлов, и даже целых компонентов.
Практика: как воплотить Presenter в жизнь с помощью плагинов
Класс, который мы написали, пока что не будет работать. Его нужно соответствующим образом подключить.
1. Наш класс будет синглтоном, подключаемым один раз при создании приложения;
2. Наш класс должен иметь доступ к $store и $router . Для того, чтобы в его методах проверки можно было использовать значения текущего пути, а также данные приложения;
3. Наш класс должен быть доступен через this.$presenter.
Вот тут мы и вспоминаем про плагины. Для примера, Vue Router — это плагин.
- Плагин является синглтоном, который подключается один раз;
- Плагин может быть любым классом, объектом, функцией. Эта сущность может иметь доступ к другим сущностям приложения;
- Плагин доступен в любом компоненте под тем ключом, под каким мы его регистрируем.
Класс, раздающий контент по условиям, назовем как Compositor. Класс плагина — как Presenter. Compositor будет доступен как Presenter.compositor. Это нужно, чтобы мы могли сделать несколько различных классов, к которым будем обращаться как presenter.compositor, presenter.payments, presenter.stupidityStuff и т.д.
Создадим файл PresenterPlugin.js
Здесь есть трюк, который выходит за рамки документации Vue.
Мы будем экспортировать не объект, а функцию, возвращающую объект. Это нужно для того, чтобы заранее закрыть в замыкании конкретный инстанс контроллера, а не создавать его через new внутри этого объекта.
Далее открываем main.js и подключаем.
Вот что происходит:
1. Функция presenterPlugin принимает в себя инстанс класса Presenter;
2. Эта функция возвращает объект с методом install, принимающим App;
3. install вызывается с помощью use.
На данном этапе мы просто сделали обертку над одной строчкой кода:
О чем не напишут в документации
К сожалению, наш плагин по функционалу все еще не дотягивает до роутера или хранилища состояния.
Мы не сможем использовать наш Presenter внутри метода setup, а также внутри методов render в функциональных компонентах и внутри script setup.
Все дело в том, что там this не будет указывать на конкретный компонент, а значит и this.$presenter не сработает и выдаст ошибку.
Официальная документация об этом ничего не говорит, так что можете либо читать дальше, либо открывать исходники роутера и искать ответ самостоятельно.
Дорабатываем плагин
Открываем файл с плагином и дописываем:
Pro tip: чтобы избежать конфликта имен, можно использовать символы вместо строковых ключей в provide/inject.
Теперь мы можем в любом компоненте вызвать наш хук usePresenter и получить тот же результат, что и при использовании useStore или useRouter!
В этой статье мы в очередной раз (а кто-то, возможно, впервые) разобрали паттерн MVP, и лучше поняли, как его применять в контексте фронтенд разработки на Vue. А также, надеюсь, было полезно узнать о некоторых неочевидных возможностях плагинов во Vue.