Архитектура боевого корпоративного frontend-приложения
О неприступные стены удобной архитектуры растущего приложения сломано много копий. Это, в принципе, довольно предсказуемо. У всех нас свой бэкграунд, опыт разработки и способ работы с абстракциями. Что для одного чисто и понятно, для другого может быть сумбурно и перемешано. Я хочу рассказать о том выборе, который был сделан год назад и который за эти месяцы себя прекрасно показал.
Критерии
Критериями для оценки решения стали такие показатели:
простота понимания структуры приложения;
как следствие, простота онбодинга сотрудников;
удобство параллельной разработки в команде;
удобство выбора места размещения кода;
удобство внесения изменений;
удобство локализации возможных багов;
Технологический стек
TypeScript
Vue 3
Vue-Route
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
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 постепенно превращается в самых большой и хуже всего структурированный элемент. Приходится внутри каждой папки разбивать код на дополнительные категории. В целом, на осознаваемость структуры это пока не влияет, но потенциально тут у нас есть проблемы.
Заключение
Выбранный подход к организации кода за годичное использование себя прекрасно показал. Приложение росло, добавлялись новые модули, в модулях менялась структура, появлялись особые оформления для страниц, которые не укладывались в общую концепцию, принятую изначально. Добавлялся не заявленный функционал. В общем, за этот год, происходило то, что обычно происходит в любом другом развивающемся проекте. Все задачи решались вполне буднично. Сроки реализации проекта не срывались, качество продукта удается поддерживать на приемлемом уровне.