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

Нукст можно расширять модулями, лучше так и делать как в демо проекте, это позволит потом при расширении большого проекта вынести в отдельный проект кусочки. Это уже не монолит получается.

Для остальных же фреймворков можно сделать всё тоже самое.

Исходник проекта, можно запустить потыкать

Комментарии (12)


  1. little-brother
    31.07.2022 15:38

    Если не сложно - добавьте плз схемку архитектуры: кто кого тыкает и кем погоняет.

    Как раз на выходным разбирался с архитектурой хомяка (у меня Vue + fabricJS), остановился на MVP + нашлепка к презентеру в виде менеджера (у меня объекты - это несколько объектов fabricJS со своими методами).

    PS Правда у меня скилл программирования небольшой (хорошо если уровень джуна).


    1. bagzon Автор
      31.07.2022 16:37

      Ну в целом я не знаю что рисовать, это есть в инете) Тут главный принцип:

      Во вьюшке доступ к стору - readonly

      Стейт можно поменять только через презентер, вызвав нужный публичный метод

      Презентер меняет только тот стор который ему передали


      1. Jour
        01.08.2022 09:47

        Composition API меняет немного правила ;)


        1. bagzon Автор
          01.08.2022 09:48

          Будет еще удобнее) Там не будет вьюкса и т.п


  1. vvovas
    31.07.2022 18:41

    MVP очень приятный паттерн. Работал с ним во времена WinForms лет 15 назад.

    Мы тогда тоже использовали подход, когда view дергает методы presenter'a, но потом перешли к более удобному и, на мой взгляд, правильному варианту: view не знает ничего о presenter'e и генерит события. Presenter подписан на эти события и реагирует. На события также может быть подписан mediator, который как раз имеет право дергать методы presenter'ов, потому что знает о них, чтобы реализовать реакцию одной view, на события другой.

    У вас, я так понял, есть шина событий, не думали о таком варианте?


    1. bagzon Автор
      31.07.2022 19:05

      В этом что-то есть, но я пока не могу придумать чем это может быть полезным? События сложнее отследить в отличии прямого доступа к презентеру. К тому же каждый компонент имеет право обратиться только к своему призентеру.

      И в IDE быстрые переходы и рефакторинг работает


      1. vvovas
        31.07.2022 19:43
        +1

        Если я правильно помню, мы событиями решали проблему общения с медиатором, так как он реагировал на события напрямую. Ну, и с архитектурной точки зрения view-компонент, как мне кажется, более правильно выглядит: я умею показывать данные, которые мне дают и сообщаю о том, что у меня что-то произошло, но больше ни о чем я не знаю.

        Проблема вызова только своего presenter'a решалась с помощью настроенной шины событий, которая представляла собой дерево, где корень - это медиатор, а лист - view. соответственно событие, всплывая от view просто не могло попасть к чужому presenter'у.

        Ваш вариант тоже вполне рабочий и если с ним нет проблем - почему нет?


  1. strannik_k
    02.08.2022 21:49
    +1

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

    Мне тоже всегда виделось, что это плохо масштабируется. Поэтому вынес из стора обращения к сервисам в отдельный слой, похожий на описанный у вас презентер. Правда на React пишу, но они с Vue довольно похожи.


  1. SanoLitch
    03.08.2022 11:09

    Вы сделали пол-шага к Clean Architecture. Я бы добавил inversifyjs для менеджмента зависимостей и заменил vuex на rxjs.


    1. bagzon Автор
      03.08.2022 11:10

      Незачем rxjs там где всё построено на реактивности) inversifyjs норм тема, изучу)


      1. SanoLitch
        03.08.2022 11:35
        +1

        rxjs помогает добавить реактивность в обычные ts-классы, сервисы, репозитории и тп...можно конечно событийные шины лепить на колбэках, свою реализацию observable написать на проксе - дело вкуса, но ИМХО оно того не стоит. Хотя у rxjs есть свои проблемы.


        1. bagzon Автор
          03.08.2022 11:37

          Соглашусь, но пока жырновато) Позже можно будет внедрить уже вместо шины