Frontend архитектура MVP (Model-View-Presenter)
Frontend сейчас сильно разрастается, всё больше компаний переписывают свои старые решения на SPA. В компании которой я работаю это не обошло стороной.
По умолчанию был выбран фреймворк Nuxt.js, т.к Vue лучше React:))
В общем суть не в фреймворке, а с чего начинаем.
Проблемы
Скорость порождает говнокод в плане связей, архитектуры и т.п
Многие разрабы в голове видят архитектуру фронта по разному
Стандартные подходы Vue, где во Vuex экшенах делаются запросы и кладутся в стор и т.п не расширяемые
Сильные зависимости от фрейморка, сложно его обновлять на что-то мажорное если понадобиться
Требования
Кодовая база в едином стиле, чтобы любой мог зайти, изучить документацию и сразу понял что где
Легкая масштабируемость, чтобы не бояться менеджеров с их запросами
Полный контроль состояний каждого блока на странице
Чёткие слои в архитектуре
Чтобы всё было типизировано и был удобный поиск и переход в IDE
Переиспользуемость компонентов
Model View Presenter
Данный шаблон в основном используется в мобильной разработке, и если глянуть то там, в презентер передается вьюшка, и любое действие со стороны вью тригерило действие в презентере, и презентер вызывал нужные методы из вьюшки.
Во фронте мне данный подход не понравился, т.к мы делаем зависимость от представления, а хочется прям жёстко разделить бизнес логику, модель и сервисы от фреймворка в целом.
Новый подход позволит метнуться и на реакт, и на вью3 или другое решение.
Идеология
Бизнес блок — это конкретные компоненты на странице объединенные по смысловой нагрузке и общему состоянию. Все компоненты одного бизнес блока не имеют права обращаться к данным других бизнес блоков. Полная изоляция в рамках своего бизнеса. Это позволяет заюзать кнопку создания заказа где угодно, и ей ничего не надо будет. Она сама всё сделает внутри себя.
Примеры:
Каталог: список позиций, кнопка показать больше позиций, карточка позиции.
Чайник: слайдер с фотками чайника, описание чайника, тайтл чайника, цена чайника.
Корзина: кнопка купить товар, список товаров корзины в шапке, ссылка на переход в корзину.
Модель — состояние бизнес блока, в нем содержится описание всех типов, стор, события и т.п что характеризует бизнес блок. Это конечно не православная активная\пассивная модель в DDD например, но так проще ориентироваться и понимать что происходит.
Тоже самое по изоляции, модель ничего не знает за границами своего стора.
Презентер — то, где находится вся бизнес логика, он является основной точкой работы с бизнес блоками, только презентер может изменить состояние и больше никто. В нем идет обращение к сервисам, мапперам и ко всему тому, что требуется.
Презентер не имеет права вызывать другие презентеры других бизнес блоков, только в рамках своего бизнеса.
Каждый публичный метод презентера начинается с префикса on это важно, презентеру говорят о том, что что-то произошло, сделай что нибудь. А не приказывают)
Методы ничего не возвращают, только изменяют своё состояние, на которое уже подписаны вьюшки и другие. Бывают исключения что удобнее что-то вернуть, тогда да, например получить ссылку на скачивание.
Сервис — слой где делаются запросы.
Связи с внешним миром: роутинг, уведомления и т.п — через единую шину событий. Что-то произошло в презентере, в шину кидаем событие с данными, и в медиаторе или странице обработали.
Медиатор — компонент который имеет доступ ко всем состояниям и презентерам, этакое место связей блоков между собой.
Примеры:
Надо по изменению состояния корзины обновить счётчик акции в баннере
При подаче заявки в процедуру обновить в шапке текущий статус процедуры
Правила построения бизнес блоков — по умолчанию бизнес растет в горизонталь, и каждый из бизнесов не знает про соседние, только в медиаторах или страницах.
Но бывает так что нужный общий главный родитель, который будет содержать общий контекст для остальных.
Для этого создаем главный бизнес блок, и его состояние могут использовать во вьюшках и медиаторах его дочерние бизнес блоки. Изменить состояние опять же родителя нельзя, только вызывать доступные публичные методы его презентера.
Состояние родителя передается в презентеры дочерних через методы initWithContext (…data)
Состояние во вьюшках можно получить напрямую из родительского в режиме readonly.
Допускается в соседние бизнес блоки в медиаторах передавать в пропсах базовые данные, например ID или что-то очень маленькое для инициализации запроса или еще чего. Нельзя в пропсы передавать больше объекты и т.п, только через презентер.
Примеры
Начнем с директорий
Директория business содержит конечные бизнес блоки с архитекрутой MVP.
Внутри уже store это Vuex модуль, служит чисто для удобства работы со стейтом (реактивность и т.п).
Директория view содержит компоненты бизнес блока которые чаще всего не принимают никаких снаружи пропсов и т.п, всё берут со стора. Могут еще тригерить события, на которые подпишутся в медиаторе например.
Директория mediator содержит агрегирующие компоненты нескольких бизнес блоков.
Директория pages страницы для роутинга приложения, по сути это тоже медиатор, но медиатор можно более скрупулезно разделить по ОО в отличии страниц. В страницах чаще всего просто юзаются компоненты медиаторов.
Остальные директории уже относят больше к Nuxt.js.
Посмотрим на код
В домене описываются все возможные интерфейсы, типы, и состояния бизнес блока, вот пример из демо. Презентер инициализирует стор и описывает методы из интерфейса.
Domain.ts
import { namespace } from 'vuex-class';
import { IVuexObservable, TFetchState } from '~/mvp/store';
export type TWidgetData = {
id: string;
title: string;
description: string;
};
export enum EModal {
NONE,
WIDGET_CREATE
}
export type TModalData = Partial;
// ОБЯЗАТЕЛЬНО
// Конечное состояние конкретной бизнес логики, домейн
export type TState = TFetchState & {
disabled: boolean;
list: TWidgetData[];
showedModal: EModal;
dataModal: TModalData | null;
};
// ОБЯЗАТЕЛЬНО
// презентер, в нем вся логика, обращение к модели за данными, заполнение стора и оповещение вьюшки.
export interface IPresenter extends IVuexObservable {
onCreate(): void;
onCloseModal(): void;
onOpenModal(type: EModal): void;
onTogglePermissionCreate(): void;
onCreateWidget(title: string, description: string): void;
}
// если есть сервис то и для сервиса описываем интерфейс
// ОБЯЗАТЕЛЬНО базовое состояние для вьюшки
export const initialState = (): TState => ({
isLoading: true,
isError: false,
statusCode: 200,
disabled: true,
errorMessage: '',
list: [],
showedModal: EModal.NONE,
dataModal: null
});
export enum EEvents {
CREATE_WIDGET = 'mvp:main:createWidgetEvent'
}
Presenter.ts
import eventEmitter from '~/modules/eventbus/EventEmitter';
export default class Presenter
extends VuexObservable
implements IPresenter
{
constructor(store: Store) {
super(store, initialState(), STORE_NS);
}
onCreate(): void {
setTimeout(() => {
this.onChangeState({ isLoading: false });
eventEmitter.emit('notification', {
status: 'success',
title: 'Уведомление',
content: 'Модуль загружен',
position: 'top'
});
}, 700);
}
onCloseModal(): void {
this.onChangeState({ showedModal: EModal.NONE });
}
onOpenModal(type: EModal): void {
this.onChangeState({ showedModal: type });
}
onTogglePermissionCreate(): void {
this.onChangeState({ disabled: !this.state.disabled });
}
onCreateWidget(title: string, description: string): void {
// тут допустим уходит запрос в сервис, возвращаются данные и сетим уже в стейт
this.onChangeState({ title, description }, 'addWidget');
// шлём в общую шину событий уведомление
eventEmitter.emit(EEvents.CREATE_WIDGET, title);
}
}
Service.ts из соседнего блока
import { IService, TPost } from '~/demo/business/post/Domain';
export default class Service implements IService {
async fetchListPosts(): Promise {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
return await response.json();
}
}
Что нибудь из компонентов. Как можете увидеть в компоненты ничего не передается, каждый из компонентов возьмет своё состояние. Вызывает свой презентер и т.п
DemoMediator.vue
CreateWidgetButton.vue
Создать виджет
А где же инициализация всего и вся?
В nuxt.js за это отвечают плагины, вот например код плагина и стора.
На ваше усмотрение можно сделать полноценный DI или описание базовый ServiceManager. Так же можно сделать какую нить абстратную штуку которая будет автоматически всё регистрировать в системе вашего фреймворка.
В демо версии я не стал упарываться)
presenter.ts
import { Plugin } from '@nuxt/types';
import * as Main from '~/demo/business/main/Domain';
import * as Post from '~/demo/business/post/Domain';
import MainPresenter from '~/demo/business/main/Presenter';
import PostPresenter from '~/demo/business/post/Presenter';
export interface IPresenterPlugin {
mainInstance: Main.IPresenter;
postInstance: Post.IPresenter;
}
const presenter: Plugin = (context, inject) => {
let presenterMainInstance: Main.IPresenter;
let presenterPostInstance: Post.IPresenter;
inject('presenter', {
get mainInstance(): Main.IPresenter {
if (presenterMainInstance) {
return presenterMainInstance;
}
presenterMainInstance = new MainPresenter(context.store);
return presenterMainInstance;
},
get postInstance(): Post.IPresenter {
if (presenterPostInstance) {
return presenterPostInstance;
}
presenterPostInstance = new PostPresenter(context.store);
return presenterPostInstance;
}
});
};
store.ts
import * as Main from '~/demo/business/main/Domain';
import * as Post from '~/demo/business/post/Domain';
import MainVuexModule from '~/demo/business/main/store/MainVuexModule';
import PostVuexModule from '~/demo/business/post/store/PostVuexModule';
export default ({ store }: Context) => {
store.registerModule(
Main.STORE_NS,
MainVuexModule
);
store.registerModule(
Post.STORE_NS,
PostVuexModule
);
};
Касаемо архитектуры в Nuxt.js
Нукст можно расширять модулями, лучше так и делать как в демо проекте, это позволит потом при расширении большого проекта вынести в отдельный проект кусочки. Это уже не монолит получается.
Для остальных же фреймворков можно сделать всё тоже самое)
Исходник проекта, можно запустить потыкать: https://github.com/gustoase/habr-mvp-nuxt2