Приветствую! С вами Вадим, frontend разработчик компании «Перспективный мониторинг». Сегодня хочу поделиться нашей типовой структурой frontend приложения, рассказать об архитектурной методологии, которую мы используем, а также основных проблемах, с которыми нам пришлось столкнуться, и способах их решения.
У нас есть небольшой демо-проект - hba-demo-todo-app, доступный для всех желающих. Это минимальная конфигурация, далекая от реальных проектов, но для демонстрации кода я буду использовать именно ее.
Технологический стек
Для разработки фронтенда мы используем Vue.js третьей версии. С недавних пор в качестве основной архитектурной методологии мы используем Feature Sliced Design (FSD). На основе Vuetify 3 разработали собственный UI-kit, расширяющий функционал Vuetify под конкретные требования наших проектов. Также используем:
Vue-router для маршрутизации;
Pinia для управления состоянием;
Vee-validate 4 для валидации форм;
Axios для отправки запросов на сервер;
vue-native-websocket-vue3 для интеграции с WebSocket.
Для организации логики компонентов мы преимущественно придерживаемся подхода Composition API. Для сборки проектов используем Vite, но демо-проект на данный момент собирается с помощью Webpack. TypeScript используем опционально, в зависимости от требований проекта.
Переход к FSD
Ещё до внедрения FSD структура наших frontend приложений основывалась на определенном наборе принципов, сформированном нашей командой. С каждой итерацией рефакторинга какого-либо из проектов мы улучшали структуру и распространяли свои «best practice» на остальные проекты. Несмотря на это, наша типовая структура все же имела ряд недостатков, главным из которых являлось отсутствие чёткого распределения ответственности между компонентами. Код разбивался на компоненты по усмотрению разработчиков, на основе их понимания, а не каких-либо общих правил. В некоторых ситуациях это приводило к хаосу, особенно на больших проектах, в которых задействовано несколько фронтенд-разработчиков разного уровня.
После долгих обсуждений, собрав все «за» и «против» мы всё-таки приняли решение по переводу наших основных проектов на методологию FSD. Основной причиной послужило её четкое разделение кода по функциональным областям.
Забегая вперед, хочу отметить, что с приходом FSD наши проекты приобрели более чёткую и строгую структуру, но, вслед за этим, значительно возрос порог входа для новых разработчиков. Это произошло, по больше части, из-за того, что правильная и плодотворная работа с методологией требует глубокого понимание её принципов.
Структура проекта
Согласно методологии FSD, наше демо-приложение разделено на слои (layer), слои состоят из срезов (slice), а срезы в свою очередь из сегментов (segment).
В нашем демо-приложении используется стандартный набор слоев:
app – конфигурация приложения, его глобальные настройки и используемые провайдеры (например, плагины, отвечающие за маршрутизацию, хранилище состояний и т.п.);
entities – бизнес сущности и их взаимодействие, модели данных и сервисы для работы с этими данными;
features – функции приложения отвечающие за взаимодействие пользователя с бизнес сущностями;
pages – страницы приложения, объединяющие компоненты сущностей, виджеты и фичи;
shared – общий слой для компонентов, утилит и прочего кода, не имеющего прямого отношения к специфике бизнеса, при этом доступного на любом другом слое;
widgets – компоненты, объединяющие логику работы фич и бизнес сущностей.
Слой entities обычно вызывает наибольший интерес, так как представляет логику работы бизнес-сущностей. Рассмотрим содержимое этого слоя более подробно на примере среза note.
В нашем приложении note это блокнот, в котором каждый пользователь может делать свои записи (todo), блокноты можно редактировать и удалять. Срез note разбит на следующие сегменты:
api – взаимодействие с внешними API;
model – абстракции, описывающие структуру и поведение бизнес-сущности;
store – определение хранилища (в данном случае Pinia) для конкретной сущности;
ui – компоненты пользовательского интерфейса конкретной сущности.
Файл index.js представляет собой публичный API для среза, он экспортирует только те компоненты среза, которые должны быть доступны для использования в других частях приложения. Кроме того, он упрощает процесс импорта необходимых компонентов.
Такое разбиение среза помогает структурировать код, связанный с бизнес-сущностью, и сделать его более понятным.
Взаимодействие с сервером
Для организации взаимодействия между клиентом и сервером используются протоколы HTTP и WebSocket. После первоначальной загрузки приложения и установки сокетного соединения сервер присылает клиенту часть данных, необходимую для отрисовки некоторых страниц интерфейса приложения. Остальные данные запрашиваются по мере необходимости путем обращения к API сервера. Создание, обновление, удаление данных через интерфейс клиента происходит путем отправки HTTP запросов на сервер, в свою очередь сервер уведомляет клиента о всех изменениях данных бизнес-сущностей посредством сокетных событий.
Обработка входящих данных
Для обработки и сохранения данных о бизнес-сущностях, поступающих по сокетному каналу, был разработан специальный плагин для Pinia, названный wsRelated. Основная задача плагина - это автоматическое обновление хранилища в зависимости от типа входящих данных. В демо-проекте плагин расположен по пути “src/shared/utils/wsRelated”.
Для начала работы с плагином его необходимо подключить к текущему экземпляру Pinia:
import { createPinia } from 'pinia'
import { wsRelatedPlugin } from '@/shared/utils/wsRelated'
const pinia = createPinia()
pinia.use(wsRelatedPlugin)
export { pinia }
Далее, для того, чтобы использовать плагин с конкретным хранилищем Pinia, при определении этого хранилищем необходимо передать соответствующие параметры в метод defineStore. Рассмотрим на примере определения хранилища для сущности todo:
export const useTodoStore = defineStore({
id: 'todo',
state: () => ({
todos: [],
}),
wsRelated: {
todo: 'todos',
},
})
В данном примере конфигурация объекта wsRelated указывает на то, что сообщения типа todo должны обновлять массив todos в состоянии хранилища todo. Это позволяет автоматически обрабатывать WebSocket-сообщения и обновлять состояние хранилища без необходимости писать дополнительный код для каждого типа сообщения.
Основной код плагина:
export const wsRelatedPlugin = ({ options, store }) => {
if (options.wsRelated) {
return Object.keys(options.wsRelated).reduce((wsActions, itemKey) => {
const valueKey = options.wsRelated[itemKey]
wsActions[`SOCKET_${valueKey}`] = getSetDef(store, valueKey)
wsActions[`SOCKET_${itemKey}_added`] = getAddedDef(store, valueKey)
wsActions[`SOCKET_${itemKey}_updated`] = getUpdatedDef(store, valueKey)
wsActions[`SOCKET_${itemKey}_deleted`] = getDeletedDef(store, itemKey, valueKey)
return wsActions
}, {})
}
}
На вход плагин принимает объект и извлекает из него значения по двум ключам - options и store. Если в options есть свойство wsRelated, плагин создает набор обработчиков для нескольких типов событий (added, updated, deleted) и возвращает их в виде объекта. Эти обработчики будут автоматически вызываться при получении соответствующих сообщений по каналу WebSocket.
Плагин имеет ряд вспомогательных методов:
getSetDef - создает обработчик, который устанавливает значение в хранилище на основе данных, полученных через WebSocket;
getAddedDef - создает обработчик, который добавляет новый элемент массива в хранилище, если его там ещё нет;
getUpdatedDef - создает обработчик, который обновляет существующий элемент массива в хранилище;
getDeletedDef - создает обработчик, который удаляет элемент массива из хранилища по его идентификатору.
Более подробно изучить код этих методов можно в самом демо-проекте.
Особенности FSD и сложности при внедрении
Стоит еще раз отметить, что для правильного использования методологии FSD разработчикам необходимо глубокое понимание её принципов. Если проект уже написан и имеет свои особенности, то перед началом работы с методологией разработчику важно понимать не противоречат ли её правила конкретным особенностям проекта. На первых этапах внедрения это может замедлить и усложнить процесс. Отсюда вытекает первый недостаток FSD — высокий порог входа.
Ещё одной важной и довольно частой проблемой является некая «размытость» границ между фичами и виджетами. Зачастую, при работе с методологией разработчики путаются с тем, куда именно следует поместить тот или иной компонент. С одной стороны, вроде бы это фича, а с другой, напрашивается именно виджет. Я и сам иногда затрудняюсь сходу определить, к какому слою было бы правильнее отнести компонент. В нашей команде такие спорные вопросы обычно решаются путём анализа рекомендаций и правил, описанных в документации, а также обсуждением с коллегами и принятием коллективных решений. Понимание приходит с опытом, но в разработке сложных проектов даже у опытных разработчиков возникают вопросы по распределению на фичи и виджеты.
Следующей проблемой, с которой мы столкнулись при переходе на FSD, является жёсткая изолированность срезов внутри слоя. С этим ограничением мы столкнулись по большей части внутри слоя entities. Так сложилось, что в реальных проектах наши бизнес-сущности имеют жёсткую связанность, что противоречит правилам методологии. Рассмотрим следующий пример:
import { useNotesStore } from '@/entities/note'
export class Todo {
constructor(data) {
this.id = data.id
this.noteId = data.noteId
this.text = data.text
this.done = data.done
}
get note() {
if (!this.noteId) return null
const { notesData } = useNotesStore()
return notesData.find(item => item.id === this.noteId) || null
}
}
Сущность todo описывается моделью, которая включает в себя геттер note, возвращающий экземпляр блокнота для конкретной «тудушки». В контексте FSD данный пример имеет грубую ошибку, а именно: обращение к данным другого среза, лежащего на том же уровне. Данная проблема встречается достаточно часто, так как на практике далеко не всегда возможно отвязать одну сущность от другой.
Как же решить проблему, не нарушая принципов методологии? Решение зависит от конкретной ситуации – можно выделять отдельные слои, выносить код на уровень выше или вообще дублировать куски кода, но мы решили пойти своим путем и написали еще один плагин для Pinia – dispatchPlugin. Плагин позволяет централизованно управлять вызовами методов и предоставляет доступ к геттерам различных хранилищ Pinia. Благодаря данному плагину мы обходим прямые импорты между сущностями внутри слоя entities, но несмотря на это, связь между сущностями остается, просто на другом уровне.
В нашем демо-приложении плагин находится по пути “src/shared/utils/dispatch”. Для начала использования подключаем плагин к текущему экземпляру Pinia:
import { createPinia } from 'pinia'
import { dispatchPlugin } from '@/shared/utils/dispatch'
const pinia = createPinia()
pinia.use(dispatchPlugin)
export { pinia }
Основной код плагина:
const _stores = {}
export const dispatchPlugin = ({ store }) => {
_stores[store.$id] = store
}
Функция dispatchPlugin вызывается при инициализации каждого хранилища Pinia и добавляет ссылку на него в объект _stores под ключом, соответствующим его идентификатору. Плагин имеет две вспомогательные функции – dispatch и get.
Функция dispatch позволяет вызывать метод (action) у любого из доступных хранилищ Pinia. Она принимает название хранилища, имя метода и дополнительные данные – аргументы для вызова. Затем она вызывает соответствующий метод хранилища с переданными ему аргументами.
Функция get позволяет получать значения геттеров (getters) конкретного хранилища Pinia. Она принимает название хранилища и имя геттера и возвращает значение этого геттера.
Теперь ещё раз рассмотрим пример реализации модели todo, но уже с использованием плагина dispatchPlugin:
import { get } from '@/shared/utils/dispatch'
export class Todo {
constructor(data) {
this.id = data.id
this.noteId = data.noteId
this.text = data.text
this.done = data.done
}
get note() {
if (!this.noteId) return null
const notesData = get('note', 'notesData')
return notesData.find(item => item.id === this.noteId) || null
}
}
Кросс-импорты между сущностями больше не требуются, а взаимодействие с данными сущности note теперь осуществляется через прослойку, реализованную с помощью dispatchPlugin.
Таким образом, данный плагин — это своеобразный «workaround», упрощающий процесс взаимодействия одних сущностей с хранилищами других без нарушения ограничений, наложенных методологией FSD. Данный подход не идеален, но имеет право на существование и решает проблему связанности бизнес-сущностей в разрезе наших проектов.
Подведение итогов
В рамках данной статьи я постарался рассказать о тех «кирпичиках», из которых строится наша типовая структура фронтенд приложения. Проведя краткий обзор методологии, мы также затронули и её недостатки. Надеюсь, наш опыт внедрения FSD поможет кому-то сделать правильный выбор. Проект, использованный для демонстрации, является очень простым примером работы с FSD. При работе с более крупными и сложными проектами наш пример так же остается актуальным, но при этом могут возникнуть другие сложности, не описанные в статье и связанные с конкретными особенностями самого проекта. Хочу отметить, что не стоит слепо следовать всем принципам методологии, главное — это найти баланс между преимуществами её внедрения и легкостью разработки в будущем. Благодарю за внимание и приветствую конструктивную критику.