О неприступные стены удобной архитектуры растущего приложения сломано много копий. Это, в принципе, довольно предсказуемо. У всех нас свой бэкграунд, опыт разработки и способ работы с абстракциями. Что для одного чисто и понятно, для другого может быть сумбурно и перемешано. Я хочу рассказать о том выборе, который был сделан год назад и который за эти месяцы себя прекрасно показал.

Критерии

Критериями для оценки решения стали такие показатели:

  • простота понимания структуры приложения;

  • как следствие, простота онбординга сотрудников;

  • удобство параллельной разработки в команде;

  • удобство выбора места размещения кода;

  • удобство внесения изменений;

  • удобство локализации возможных багов;

Технологический стек

  • TypeScript

  • Vue 3

  • Vue-Router

  • Pinia

Начало

Думаю, все мы прекрасно знакомы с, так сказать, базовой структурой vue-приложения:

/src
|-- /api
|-- /assets
|-- /components
|-- /composables
|-- /config
|-- /layout
|-- /plugins
|-- /router
|-- /store
|-- /tests
|-- /utils
|-- /view
|-- App.vue
|-- main.ts

Пока приложение состоит из пяти-семи несложных страниц, такой подход вполне себе прокатывает. Части, как правило, вполне себе осознаваемы. Да и потребности в нескольких разработчиках как правило нет, приложение — маленькое. Когда приложение разрастается, появляются новые члены команды разработки, с такой структурой начинаются неминуемые проблемы. Сложность осознавания и взаимодействия нарастает чуть ли не в геометрической прогрессии.

Чтобы решить эту проблему народ начал искать другие подходы к ситуации. Появилась модульная архитектура и вроде как в ее продолжение и развитие FSD-подход. Именно к FSD я обратился с самого начала. Все крутилось вокруг разделения кода на отдельные независимые куски, в воздухе летал легко уловимый аромат DDD. Всё было свежо и многообещающе.

Feature-Sliced Design

Думаю, все уже наслышаны о FSD. Но на всякий приведу цитату с официального сайта этой методологии:

Feature-Sliced Design (FSD) — это архитектурная методология для проектирования фронтенд-приложений. Проще говоря, это набор правил и соглашений по организации кода. Главная цель этой методологии — сделать проект понятнее и стабильнее в условиях постоянно меняющихся бизнес-требований.

Помимо набора правил, FSD — это также целый инструментарий. У нас есть линтер для проверки архитектуры вашего проекта, генераторы папок через CLI или IDE, а также богатая библиотека примеров.

Круто!

Я полез по форумам и документации. Начал натягивать это все на глобус конкретного проекта и столкнулся с тем, с чем сталкивается, кажется, 100% (??) команд – что такое фича? чем фича отличается от энтити? А вот этот вот конкретный кусок кода – это что? Куда его положить? На форумах горели костры священных войн. Одни говорили, что в документации все написано, другие кричали, что определения некорректны. Третьи спорили, что куда и в каком виде можно вкладывать.

В итоге, сложилось ощущение, что FSD к нам пришел, как Троянский конь к воротам Трои и за красивой оберткой скрывается потенциальный портал в ад и бесконечные споры в команде. Это мне точно не подходило.

Решение

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

Корневая структура проекта

На данный момент проект имеет следующую структуру:

/src
|-- /app
|   |-- /assets
|   |-- /config
|   |-- /routes
|   |-- /ui
|   |   |-- App.vue
|   |-- main.ts
|-- /modules
|   |-- /module1
|   |-- /module2
|-- /plugins
|-- /test-utils

Модули

Структура модуля выбрана такой:

/module
|-- /api
|-- /assets
|-- /composables
|-- /directives
|-- /helpers
|-- /routes
|-- /store
|-- /types
|-- /ui
|   |-- /components
|   |   |-- /module-page-components
|   |   |   |-- /body
|   |   |   |-- /dialogs
|   |   |   |-- /footer
|   |   |   |-- /header
|   |   |-- /specific-component
|   |   |   |-- SpecificComponent.vue
|   |   |   |-- SpecificComponent.test.ts
|   |   |   |-- types.ts
|   |   |   |-- index.ts
|   |-- /dialogs
|   |-- /layouts

Все папки опциональны по наличию. Если у модуля нет необходимости, скажем, в директивах, этой папки там не будет.

/api — методы работы с бэкэндом, всё побито на отдельные файлы
/assets — любая статика, относящаяся к конкретному модулю. В нашем случае максимум пара каких‑то изображений
/composables — всевозможные хуки, относящиеся к модулю
/directives — директивы, касающиеся данного модуля
/helpers — различные утилитки‑хелперы, всякие мапперы и т. п.
/routes — настройки роутов (вернемся к роутам позже)
/store — сторы модуля
/types — папка отвечающая за типизацию модуля
/ui — папка, в которой находятся vue‑компоненты (ui‑составляющая приложения)

Модули выделяются, исходя из структуры страниц приложения, которая в свою очередь строится на структуре бизнес-сущностей. Возможно, здесь нам повезло, что это совпало. С другой стороны, выделить модуль структурно и поместить его в тот или иной раздел на сайте вообще не проблема. Организация роутинга это позволяет делать без особых проблем.

Роутинг и организация файлов для него

UI-дизайн нашего приложения подразумевал наличие заголовочного блока, общего для всего приложения, а также довольно типичных страниц, каждую и которых вполне можно было бы разделить на заголовок, тело и футер. Если посмотреть на структуру директорий в module-page-components, это разбиение в ней отражено. У каждого модуля может быть свой layout страницы, лежать он будет в соответствующей папке в /ui. Но может использовать и общий для всех layout, который будет находиться в модуле shared/ui/layouts. Эти layout-ы работают с named-роутам. Таким образом, переходя от страницы к странице, надо достаточно будет просто указать, какие компоненты соответствуют именам в layout-е.

Пример настройки роута:

modules/shared/ui/layouts/BaseLayout.vue

<template>
  <div class="base-layout">
    <AppHeader />

    <RouterView
      name="contentHeader"
      class="base-layout__header"
    />

    <RouterView
      name="contentBody"
      class="base-layout__body"
    />

    <RouterView
      name="contentFooter"
      class="base-layout__footer"
    />
  </div>
</template>

modules/module1/routes/index.ts

export default [
  {
    path: paths.TARIFFS,
    alias: paths.MAIN,
    component: BaseLayout,
    children: [
      {
        path: '',
        name: names.TARIFFS,
        components: {
          contentHeader: () => import('@tariff/ui/components/tariffs/header'),
          contentBody: () => import('@tariff/ui/components/tariffs/table'),
          contentFooter: () => import('@tariff/ui/components/tariffs/footer'),
        },
      },
    ],
  },
];

Все эти настройки роутов собираются в общий роут в папке /app и передаются в конструктор приложения при первоначальном запуске:

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    ...tariffRoutes,
  ],
});

Взаимодействие компонентов между собой

Поскольку общей точкой сбора компонентов является компонент layout-а, который может быть общим для совершенно разных страниц, пришлось решать, как организовать это горизонтальное взаимодействие. Было несколько вариантов, но в итоге остановились на использовании pinia-сторов, которые создаются под каждую страницу. Проблему накопления этих сторов в памяти решили размонтированием стора при переходе на другую страницу.

Есть мнение, что использовать сторы чуть ли не антипаттерн. Вполне допускаю, что есть более элегантное решение без применения сторов, но пока что мы его не нашли. Стор pinia идеально решает наши задачи по шарингу данных между компонентами, находящимися на одном иерархическом уровне. Если вы готовы поделиться предложениями на этот счет, добро пожаловать в комментарии.

Точки роста

Ожидаемо, модуль /shared постепенно превращается в самых большой и хуже всего структурированный элемент. Приходится внутри каждой папки разбивать код на дополнительные категории. В целом, на осознаваемость структуры это пока не влияет, но потенциально тут у нас есть проблемы.

Заключение

Выбранный подход к организации кода за годичное использование себя прекрасно показал. Приложение росло, добавлялись новые модули, в модулях менялась структура, появлялись особые оформления для страниц, которые не укладывались в общую концепцию, принятую изначально. Добавлялся не заявленный функционал. В общем, за этот год, происходило то, что обычно происходит в любом другом развивающемся проекте. Все задачи решались вполне буднично. Сроки реализации проекта не срывались, качество продукта удается поддерживать на приемлемом уровне.

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


  1. Safort
    19.07.2024 20:27

    По поводу FSD согласен, вещь довольно сырая и судя по сайту вообще заброшенная. Не понимаю зачем куча людей её себе тащут. Видимо, из-за отсутствия каких-то конкретных альтернатив.

    Но в целом статья скорее не понравилась. Мне ни о чём не говорит пример со SpecificComponent. В таких случаях нужны конкретные практические примеры и лучше на каком-нибудь gitlab/github. Покажите пример c регистрацией, онбордингом и каким-нибудь чатиком на тех же WebSockets, просто чтобы тому же джуну было понятно.


    1. HAGer2000 Автор
      19.07.2024 20:27

      Мне ни о чём не говорит пример со SpecificComponent.

      Задайте конкретный вопрос, я отвечу


  1. rob2akhiyarov
    19.07.2024 20:27

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

    Касательно предложенной реализации, почему shared это отдельный модуль? Хочется видеть модуль независимым элементом, в который не протекают другие модули, чтобы не было циклической зависимости. Слой shared должен быть утилитарным, то есть элементы дизайн системы, вспомогательные функции, композоблы, директивы и все такое, что лишено груза решения бизнес задач. Плюс очко FSD, где слой shared стоит особняком.

    Далее стоит очертить границы понимания модуля. Как мне видится, это не страница и не их группа. Модуль - это определенный блок, реализующий бизнес логику в рамках фронтенда, например виджет калькулятора ипотеки на сайте банка или виджет супер-юзера, появляющийся вообще на всех страницах и подключающийся на корневом уровне. Если вы согласны с таким утверждением, то стоит разделить роутинг и модули, чтобы получить более универсальный конструктор. Как вариант, вынести все страницы в отдельный слой entrypoints (называйте как хотите, но такой термин более понятен, нежели views или pages). По своей структуре entrypoint похож на модуль, но с большими полномочиями, в нем также могут быть свои сам-модули, привязанные к конкретной странице/группе страниц. В случае необходимости такие саб-модули легко поднимаются в modules и шарятся на другие страницы.

    Также я бы предложил вынести api в слой shared, так как это банально дешевле в плане разработки. Бизнес требования к одному модулю могут меняться, появляется необходимость дергать разные апи-роуты, которые уже реализованы на бэкенде, но в вашем случае они привязаны к одному модулю. Также такой способ позволяет перейти на автогенерацию апи-клиентов и их типизаций.

    И наконец про любимые сторы. В целом, страничный стор это скользкая дорожка, ведущая к перепотреблению памяти и, иногда, плохо читаемому коду. Такие сторы имеют привычку сильно разрастаться, в них теряется драгоценный контекст и нарушается принцип единичной ответственности. Что-то может выйти за рамки страничного стора, тогда нужно выносить логику в какой-то другой глобальный стор, чтобы не стереть данные случайно. Компоненты начинают смотреть в несколько сторов, в общем код превращается в спагетти. Например, вы разрабатывали приложение для управления задачами и использовали страничный стор. Со временем стор начал разрастаться из-за добавления новых функций, таких как фильтрация, сортировка и уведомления. Компоненты стали зависеть от нескольких сторов, в том числе глобальных, что усложнило код и сделало его трудным для поддержки. В таком случае решением может стать TanStack/Query, который управляет состоянием и кэшированием серверных данных, упрощая код, позволяя сосредоточиться на бизнес логике, а не на хранении данных, кэшированием и инвалидацией состояния стора.

    Хотелось бы еще посмотреть на структуру тестов, как разделяются разные виды тестов, как шарятся в них моки/стабы и тому подобное.

    Подведу итог, вам плюсик в карму за попытку систематизировать подход к построению стабильных и больших фронтенд приложений! Как мне кажется, мы, js-сообщество, еще только на середине этого тернистого пути к построению идеального фронтенд-конструктора, слишком много разного бэкграунда. Пока оптимальным решением я вижу такую структуру: Tests->App->Entrypoints->Modules->Shared, вышестоящий слой может пользоваться всеми нижестоящими.
    Своим комментарии я хочу лишь подчеркнуть возможные проблемы и найти их решения, вполне возможно, что и эти подходы имеют существенные минусы, которые я не заметил со своей колокольни.


    1. HAGer2000 Автор
      19.07.2024 20:27

      Спасибо за ответ!

      Со сторами у нас организована выгрузка. Этим мы решаем проблему по памяти. Ну и чисто организационная составляющая -- в стор выносим только то, что реально нужно шарить. Где-то получается лучше, где-то хуже. Вариантов пока все равно не нашли.

      Модуль shared именно отдельный модуль, поскольку его структура практически идентична всем остальным. Это переиспользуемая другими компонентами логика/ui-компоненты. По идее, никаких циклических зависимостей не должно быть, т.к. shared не может использовать никакие другие модули, а они его могут. В целом, предложение понятное. Надо покрутить его в голове.

      Что касается границ. В общечеловеческом смысле проведение границ -- это отдельное искусство. Вторая логика, так сказать. Формулирование границ и ее реализация зависит от целей и некой системы ценностей человека. Возвращаясь к коду, мы границы провели так, поскольку нам это показалось удобным и отвечало нашим потребностям, представлениям о том, как было бы удобно работать с кодом. И в итоге мы убедились, что для нас это сработало. Когда модуль стал описывать раздел сайта, папка с роутами стала фактически его интерфейсом, или точкой импорта модуля, который позволял собирать все модули в одном месте -- создание роутера. Роут модуля уже импортирует компоненты-части страницы. Сердце приложения -- main.ts -- собирает модули в роуты и использует при создании vue-приложения. Переназови мы modulues в entrypoints, это, возможно, убрало бы некое противоречие, но сути бы не изменило.

      Вынесение роутов из модулей побудило бы нас создать (если я вас правильно понял) какое-то одно место, где бы формировались роуты. Это ничем не отличается от того, что есть сейчас в классической. Это место пришлось бы также отдельно структурировать, чтобы все это не болталось в одном файле, а структура скорее всего повторяла бы структуру entrypoints. Либо я идею не так понял.

      Вынести api в shared мы тоже рассматривали. Отказались от этого, увидев еще на старте надвигающийся ком методов, которые снова пришлось бы структурировать. В модулях они уже находятся там, где нужно. Так что, отказались от этой идеи.

      Если я правильно понял, модулем вы предлагаете называть только бизнес-логику. Даже если он будет содержать ui-компоненты. Для меня модуль -- это некая максимально самостоятельная архитектурная единица. Грубо говоря, это то, что можно вынести в отдельный репозиторий (кстати тот же shared вполне мог бы стать общей либой для остальных модулей-репозиториев). Слишком намельчить в модуле и проект сразу становится сложно осознаваемым. Модулей станет очень много, а пользоваться ими все равно будут совершенно конкретные страницы.

      Тесты. Тесты у нас только unit, лежат, как я показал в папке с компонентами (либо, когда у нас, скажем, папка /helpers, лежат в /helpers/__tetsts__) Моки специфичны для каждого теста. Особо тут нечего рассказать.

      Спасибо еще раз за ответ. Подумаем над предложениями!


      1. rob2akhiyarov
        19.07.2024 20:27

        Докину немного про разделение entrypoints/modules/shared.
        Entrypoints - интерфейс сайта, точки входа, разделы, страницы. Entrypoint имеет свой роутинг, собирает свои страницы с помощью других модулей и базовых компонентов. Modules - переиспользуемые блоки, закрывающие бизнес-задачи, например карточка товара, появляющаяся на многих страницах и меняющая стор корзины, или модалка чата с поддержкой, который обычно расположен в правом нижнем углу.
        Shared - переиспользуемые блоки без бизнес-логики, фундамент приложения, на базе которого строится все остальное.
        Мне видится это очень естественным, такая модель понятна и читаема, но она по-сути мало на что влияет, в конечном счете это просто папки.
        Если в вашем случае удобнее хранить и разделять все в одном месте, то пусть будет так.

        Хочу остановиться на api и спросить: что вы делаете в случае надобности apiClient одного модуля в другом? Импортируете или копируете?
        Я вижу тут сложность в том, что интерфейс сервера не копирует интерфейс сайта, поэтому такое разделение выглядит натянутым. К тому же сейчас набирает популярность подход автогенерации апи клиентов и их типов, например tRPC.


        1. HAGer2000 Автор
          19.07.2024 20:27

          На счет api. Да, был такой кейс однажды. Если я не путаю, обратились к другому модулю. Но, эта ситуация возникла скорее из-за попытки связать фронт и бэк в каких-то одних границах. Это было не обязательно, но так решили. Когда дошло дело до такого кейса, в итоге пошли на компромисс


        1. HAGer2000 Автор
          19.07.2024 20:27

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

          Можете привести пример структуры такого модуля?


  1. karminski
    19.07.2024 20:27
    +1

    По поводу FSD. Мы сами для себя решили, что есть энтити, что фича. Например, фича- это все то, что взаимодействует с пользователем, кнопки, поля ввода и т.п. Когда кнопки и поля объединяются - это уже виджет. Энтити - компоненты, отображающие данные из api, без взаимодействия с пользователем. Страницу стараемся компоновать из виджетов.


    1. karminski
      19.07.2024 20:27
      +1

      По поводу модулей. Мы вынесли модули в отдельные npm-либы. Это резко упростило разработку, количество мерж конфликтов, тк каждый разработчик работает над своим модулем. В главном приложении по сути только подключаются эти либы.