Архитектура фронтенда, к которой мы пришли
Привет. Меня зовут Александр Калинин, я работаю фронтенд разработчиком в SM Lab на проекте Client Service Management. Занимаюсь разработкой веб-приложения на vue для работы с заказами клиентов Спортмастера.
На нашем проекте всего два фронтенд-разработчика, и мы вдвоём на протяжении почти года спорили о том, как лучше раскладывать файлы по папкам. Чуть меньше мы спорили о том, как называть файлы и папки. Затем перешли к расположению.
Мы все для наших проектов хотим только хорошего, но, к сожалению, у каждого понимание хорошего своё, поэтому спорить нам было о чём.
Эта статья появилась, потому что мы наконец-то выбрали решение, которое всех устраивает. Возможно, пригодится и вам.
У нас примерно такое содержание папки scr
:
Если у вас не так, то, я думаю, вам точно знакома отдельная папка с ассетсами, роутерами, сторами, файлик с хелперами и так далее.
А предмет наших споров касался в основном только папки с модулями, где лежали модули для страницы клиента, отдельно для страницы просмотра заказов, отдельно для страницы редактирования заказов.
И вроде бы все модули прекрасно лежат на своих страницах. Если бы не было модуля коммуникаций:
Это таблица, данные для которой берутся из разных сторов, но в остальном код полностью совпадает. И когда прилетела задача добавить новую колонку для этой таблицы, мы задались вопросом: что, если вынести коммуникации в отдельную папку, а данные прокидывать через пропс?
Какие тогда будут плюсы?
Один модуль === одна папка
Ну, во-первых, один модуль будет лежать в одной папке. Это просто удобно. Когда вносишь правки в модуль, удобно не бегать по папкам, а ограничить себя одной.
Скорость
Добавить колонку нужно будет в один файл, а не в три. Ускорится доставка ценной колонки для бизнеса.
DRY
Ну и, конечно же, мы избавимся от отвратительной копипасты.
Но, как говорят у нас в компании: DRY применим далеко не всегда и не везде, его фанатичное соблюдение скорее скажет о недостатке опыта разработчика. Поэтому не будем палить свой недостаточный опыт, и постараемся не так фанатично следовать этому принципу.
Нам этих плюсов хватило, чтобы вырвать модуль из страниц. Но, к сожалению, мы не стали выписывать обратных преимуществ, когда части модуля остаются в папках страниц. К этому мы ещё придём.
А как правильно вырывать из страниц? Ознакомившись с Domain Driven Design, захотелось сперва опробовать что-нибудь попроще.
Следующий вариант: FSD. Его везде называют лучшим решением для архитектуры.
Приложение делится на слои в зависимости от функциональности. Дальше на слайсы и сегменты. Есть, конечно, свои нюансы с определением, к какому слою относится твой модуль, но у нас-то все просто! Таблица коммуникаций — это простая отображалка, значит, entity. Значит, можно пробовать.
Есть также Atomic Design:
Это когда модули для приложения делятся глобально всего на 5 слоев. Каждый следующий слой собирается из более мелких. Но если уж выбирать, как делить на слои, то FSD явно выигрывает. И на атомик мы даже не тратили время.
А еще можно просто вынести в какую-нибудь папку shared все общие модули:
Некоторые это называют простой модульной архитектурой. Оставим этот вариант на всякий случай.
Нам хотелось попробовать FSD. Поэтому мы стали пробовать.
Вот так выглядели наши папки в начале пути:
Модуль коммуникаций дублирует реализацию для разных страниц.
Слева добавим список наших желаний, который сейчас горит красным:
И начнем это исправлять.
Перекладываем таблицу в папку entities. Добавляем пропс для данных. В самом модуле компоненты кладем в папку ui. Регистрируем публичную часть модуля в public api. И готово:
Теперь один модуль в одной папке. Мы быстро добавили новую колонку. И ни одной повторяющейся строчки.
К тому же нам очень понравилось сегментировать сам модуль на ui и прочие папки. А особенно регистрировать публичные части модуля в public api.
Мы даже добавили это в желания:
Нам теперь всё нравится. Казалось бы, о чем тут спорить. Но затем появилась новая задача. Добавить в одну колонку кнопку, которая открывает попап, с подробной информацией о ячейке.
Тут тоже всё просто. Кнопка эта фича. Добавляем новую фичу:
И уже начинает расходиться с нашими желаниями. Желание 1 модуль 1 папка загорелось желтым. Но жёлтый — это цвет варнинга, по традиции на него не обращаем внимания.
Всё по-прежнему хорошо. Все-таки всего лишь две папки у модуля, а раньше вообще было — аж целых три.
Но после у бизнеса появилось желание по клику на всю строку показывать отдельную таблицу с детальной информацией уже по всей строке.
Детальную информацию по строке добавили в entities:
Но как же объединить основную таблицу с дополнительной?
А для этого есть виджет в FSD. Этот слой как раз для этого и нужен:
Ну теперь-то всё отлично. Какие могут быть споры? Просто добавляем еще одну папку с виджетами. А что там с нашими желаниями? 1 модуль 1 папка горит красным. По количеству папок я согласен, стало хуже чем было. Скорость, естественно, тоже упала. Что ж, переходим к следующему варианту.
Создадим просто отдельный модуль. Из FSD нам понравилось делить папки внутри модуля на сегменты и наличие у модуля public api, это решение мы забрали в новый модуль.
Теперь с желаниями снова всё хорошо. Какие могут быть споры?
Но затем появилась новая задача. Добавить колонку в таблицу для просмотра заказа.
Добавили колонку. И другие страницы перестали открываться:
На странице редактирования и на странице клиента мы обращались к полю, которое было только на странице просмотра. И, к слову, колонка нужна была только этой странице.
Ну, это решается очень просто. Добавим условие:
Страницы мы починили. Правда вот, скорость загорелась желтым. Ну и действительно, стало не очень удобно, когда изменения нужны только для одной страницы в общем модуле. Но условие всего одно, так что по традиции на варнинг закроем глаза.
А вот потом случилось что-то страшное. Для каждой страницы от этой таблицы требовалось что-то уникальное. И мы решали добавлением условий.
До тех пор, пока это не превратилось в нечто неподдерживаемое. Добавляешь что-то одно, задеваешь на другой странице. Чинишь. Перестает работать, что добавил. Количество пропсов стало тоже огромным. Скорость загорелась красным. Значит, пора что-то менять. Но сперва сделаем пару выводов.
Во-первых, a точнее уже в-пятых. Мы хотим чтобы наши модули были простыми:
1 модуль === 1 папка
Скорость
DRY
Public API
KISS
Без лишних условий и (желательно) лишних пропсов, которые нужны только для одной страницы. И что модули должны поддерживать два типа изменений, которые влияют на скорость:
1. Общие изменения
Общие изменения для разных страниц. Когда нужно добавить общую кнопку, или нужно добавить общий блок.
2. Уникальные изменения
И уникальные изменения. Когда нужно внести изменения в модуль только для одной страницы. Мы добавили оба пункта в желания.
Модуль должен легко адаптироваться под любые запросы — касаются ли они общих изменений или одной страницы:
1 модуль === 1 папка
DRY
Public API
KISS
Скорость общих изменений
Скорость уникальных изменений
Скорость мы расшифровали. Выяснили, что проблема с уникальными изменениями:
Значит, нужно что-то с ними делать. И мы даже думали, что нашли выход в рамках нашей простой модульной системы.
Мы создали три композабла для разных страниц, которые подготавливали данные для модуля. В public api зарегистрировали их.
И вроде бы, с данными вопрос решили, некоторые условия и пропсы ушли. Но отрисовывался модуль все равно по разному на разных страницах и совсем от условий уйти не получилось.
А самое страшное, что с таким подходом мы натолкнулись на ошибку. В композаблах мы напрямую обращались к соответствующим сторам. А экспортировали их наружу из одной точки — public api. И получилось так, что на странице просмотра заказа стали появляться ошибки, связанные со стором редактирования. Где бы ни был подключен модуль, он за счет импортов тянул все сторы на страницу.
Стало понятно, что нужно раскладывать модуль обратно по папкам.
Так и сделали:
Вернулись к тому, с чего начинали. Ушли все условия. Ушло множество пропсов. Куча слотов. Ну и, видимо, просто нужно успокоиться и не смотреть на список желаний по расположению файлов. Который практически весь горел красным:
Можно было, правда, ещё общие части всё-таки вынести в четвертую папку. Починить хотя бы DRY. Но до этого дело не успело дойти.
У нас на работе за каждым подразделением закреплен куратор, и у меня состоялся с ним разговор.
Я пытался параллельно написать документацию для расположения папок на нашем проекте. С примерами, рассказать, как важно оставлять некоторые модули разбитыми в папках страниц. И попросил куратора ознакомиться с документацией. Куратор у нас из мира бэкенда.Он сразу разглядел что-то похожее на домены и предложил всё-таки посмотреть в сторону DDD.
Что ж, видимо, придется пробовать смотреть. Я написал об этом второму фронтенд-разработчику. И он мне прислал одно из определений в DDD:
Ограниченные контексты
Это когда в сложном приложении делят различные области на контексты.
И DDD оказалось таким мощным паттерном, что нам хватило буквально одного определения.
Ведь это не просто папки с модулями для страниц:
Это три разных контекста. Контекст страницы клиента, контекст страницы редактирования заказа и контекст просмотра заказа. Со своими сторами, колбеками и темплейтами.
А ещё мы не знали, но уже пользовались плюсами контекста:
1. Напрямую обращались в стор
Мы напрямую обращались в стор. Ничего не прокидывали через пропсы.
2. Напрямую вызывали API-методы контекста
Не прокидывали колбеки с API-методами, а сразу вызывали их из файлов внутри контекста.
3.Нет лишних пропсов и слотов
Соответственно, не было лишних пропсов и слотов, которые понадобились только когда модуль начали переиспользовать.
4.Нет лишних условий
Не добавляли лишний условий в template и блок script, которые также нужны для переиспользуемых модулей.
5.Скорость уникальных изменений
Ну и высокая скорость внесения изменений для контекста.
Решив начать переиспользовать модуль, мы лишили себя всех преимуществ контекста и добавили новых проблем, так как от модуля стали требовать изменения только для одного контекста.
А самое главное, что мы сами того не зная, уже создавали дополнительные контексты.
Ведь папка delivery в папке order-edit — это не просто папка с модулями для доставки. Это контекст доставки внутри заказа на редактирование. И если мы уже создаем сколько угодно новых контекстов, продвигаясь вправо, то почему бы не сделать всего один шаг влево?
Мы создаем дополнительный контекст для коммуникаций, который объединяет внутри себя контекст страницы клиента и страницы заказов. То есть мы просто собрали с разных страниц части модуля и перенесли всё в одну папку:
И мы выполнили первое наше желание — всё теперь в одной папке:
Если есть, что переиспользовать, мы переносим это в папку shared, которая лежит внутри папки с остальными частями модуля. Public api теперь есть у каждого контекста и не будет такого, что все сторы подключатся на одну страницу. Нет лишних условий. Так как теперь и шаблоны у нас уникальные для контекста. Модуль стал таким же простым, как если бы мы его использовали как и раньше — в папке определенной страницы. И подтянули обе скорости.
И совсем быстро второй пример. У нас есть модалка отмены, которая проходила примерно те же самые этапы. Ведь внешне модалки одинаковые. Но при этом данные получают из разных мест, и отправляют разные запросы для отмены. Хотя есть и общие компоненты, например, список причин.
Данные для модалки можно передавать через пропс, а колбеки повесить на эмиты модалки.
Поэтому мы, не сомневаясь, переложили в отдельную папку все файлы модуля. И сперва по количеству условий все было довольно просто:
Но и этот модуль постигла такая же участь. Условия для разных контекстов перестали быть простыми и легко поддерживаемыми.
И нас спасло решение для коммуникаций. Мы добавили новый контекст cancel-modal, который объединял два контекста для просмотра и редактирования заказа.
Мы сохранили гибкость разных контекстов, где модуль используется. Получилось все файлы собрать в одной папке. А также есть место для хранения общих компонентов.
Ну и к слову, у нас далеко не все модули такие требовательные. У нас скопилось большое количество модулей по пути, которые хорошо себя чувствуют и в шареде, и в фичах. Правда вот, ни одного виджета так и не появилось.
Если подводить итог, что оказалось для нас полезным:
1. Простая модульная архитектура
То это так называемая простая модульная архитектура, где в одной папке лежит один модуль и все понятно.
2. FSD (Public API)
Нам очень понравилось использовать public api из FSD, и делить модуль на сегменты.
3. DDD (ограниченный контекст)
Ну и ограниченный контекст из DDD, который помог собрать все преимущества в одной папке с модулем)
1 модуль === 1 папка
DRY
Public API
KISS
Скорость общих изменений
Скорость уникальных изменений
Данное решение подошло нашему проекту, но это в текущей точке развития. Надеюсь, в будущем появятся статьи, которые добавят идей, как можно еще лучше структурировать сложные модули.
И в завершение хочется сказать, что когда мы спрашивали у других разработчиков, как они бы располагали файлы в вышеприведенных случаях, часто звучала фраза — ну здесь копипаст оправдан. А я же хотел показать, что нужно перестать оправдывать копипаст. Иногда нужно оправдывать переиспользование.