Frontend архитектура MVP (Model-View-Presenter)

Frontend сейчас сильно разрастается, всё больше компаний переписывают свои старые решения на SPA. В компании которой я работаю это не обошло стороной.

По умолчанию был выбран фреймворк Nuxt.js, т.к Vue лучше React :))
В общем суть не в фреймворке, а с чего начинаем.

Проблемы

  1. Скорость порождает говнокод в плане связей, архитектуры и т.п

  2. Многие разрабы в голове видят архитектуру фронта по разному

  3. Стандартные подходы Vue, где во Vuex экшенах делаются запросы и кладутся в стор и т.п не расширяемые

  4. Сильные зависимости от фрейморка, сложно его обновлять на что-то мажорное если понадобиться

Требования

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

  2. Легкая масштабируемость, чтобы не бояться менеджеров с их запросами

  3. Полный контроль состояний каждого блока на странице

  4. Чёткие слои в архитектуре

  5. Чтобы всё было типизировано и был удобный поиск и переход в IDE

  6. Переиспользуемость компонентов

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<TWidgetData>;  // ОБЯЗАТЕЛЬНО // Конечное состояние конкретной бизнес логики, домейн 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<TState, MainVuexModule>   implements IPresenter {   constructor(store: Store<TState>) {     super(store, initialState(), STORE_NS);   }    onCreate(): void {     setTimeout(() => {       this.onChangeState({ isLoading: false });       eventEmitter.emit<TNotification>('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<TPost[]> {     const response = await fetch('https://jsonplaceholder.typicode.com/posts');     return await response.json();   } }

Что нибудь из компонентов. Как можете увидеть в компоненты ничего не передается, каждый из компонентов возьмет своё состояние. Вызывает свой презентер и т.п

DemoMediator.vue
<template>   <cds-grid>     <cds-row align-v="stretch">       <cds-col cols="16">         <create-widget-button class="cds-mb-m" />         <widget-list />         <create-widget-modal v-if="state.showedModal === EModal.WIDGET_CREATE" />       </cds-col>     </cds-row>      <cds-row>       <cds-col cols="4">         <menu-posts />       </cds-col>       <cds-col cols="12">         <load-posts-button class="cds-mb-m" />         <post-list />       </cds-col>     </cds-row>   </cds-grid> </template>  <script lang="ts"> import { Component, Vue, Watch } from 'nuxt-property-decorator'; import eventEmitter from '~/modules/eventbus/EventEmitter'; import { EModal, storeModule, TState } from '~/demo/business/main/Domain'; import { postStoreModule, TState as TPostState } from '~/demo/business/post/Domain'; import { TNotification } from '~/demo/@types';  import CreateWidgetButton from '~/demo/business/main/view/action/CreateWidgetButton.vue'; import LoadPostsButton from '~/demo/business/post/view/action/LoadPostsButton.vue';  const PostList = () => import('~/demo/business/post/view/PostList.vue'); const MenuPosts = () => import('~/demo/business/post/view/action/MenuPosts.vue'); const WidgetList = () => import('~/demo/business/main/view/WidgetList.vue'); const CreateWidgetModal = () => import('~/demo/business/main/view/modal/CreateWidgetModal.vue');  @Component({   components: { PostList, LoadPostsButton, MenuPosts, WidgetList, CreateWidgetModal, CreateWidgetButton } }) export default class DemoMediator extends Vue {   EModal = EModal;   // в медиаторе есть доступ ко всем состояниям модуля   @storeModule.State('internalState') state: TState;   @postStoreModule.State('internalState') postState: TPostState;    // можно подписаться на любое состояние и вызывать презентер другого бизнеса и др.   @Watch('postState.list')   onLoadPosts() {     eventEmitter.emit<TNotification>('notification', {       status: 'success',       title: 'Уведомление',       content: 'Список постов загрузился'     });   }    mounted() {     // есть доступ ко всеми презентерам, в медиаторе происходит связь состояний, и постройка базовой логики     this.$presenter.mainInstance.onCreate();     this.$presenter.postInstance.onCreate();   } } </script> 

CreateWidgetButton.vue
<template>   <cds-button :disabled="state.disabled" @click="onClick">     Создать виджет   </cds-button> </template>  <script lang="ts"> import { Component, Vue } from 'nuxt-property-decorator'; import { EModal, storeModule, TState } from '~/demo/business/main/Domain';  @Component export default class CreateWidgetButton extends Vue {   @storeModule.State('internalState') state: TState;    onClick() {     this.$presenter.mainInstance.onOpenModal(EModal.WIDGET_CREATE);   } } </script>

А где же инициализация всего и вся?

В 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


ссылка на оригинал статьи https://habr.com/ru/post/680096/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *