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

Дисклеймер: все совпадения случайны. Если вы узнали в описании свой компонент или кейс, не принимайте близко к сердцу. Это обсуждение, а не осуждение.

За время работы я сталкивался с некоторым разнообразием в подходах к декомпозиции.

  • В некоторых командах в ходу огромные компоненты на 2-3 тысячи строк, где содержится практически вся бизнес-логика, выносятся только ui-компоненты вроде полей ввода или кнопок.

  • В других — компоненты меньше, в пределах 1 тысячи строк (линтеры ругаются, мол, компоненты очень длинные), но подход в целом такой же. Часто используется в коде теги template, условные операторы, здесь часто можно увидеть различные layout-s + слоты. Компоненты принимают множество булевых, которые так или иначе меняют верстку и поведение компонента, не дай бог дублирование кода и т.д.

  • Есть и такие команды, где компоненты атомарны на столько, что есть отдельные компоненты для текста, отдельные для заголовка. Для того, чтобы вывести простенький заголовок с кнопочкой, импортится целая пачка «атомов» и из них собирается итоговый, в целом, простой компонент.

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

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

Описание подхода

На мой взгляд необходимо различать ui-компоненты и компоненты, узко специализированные для конкретной бизнес-логики. И подход к их проектированию также должен быть различен.

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

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

Рассмотрим сферический компонент информации о продукте. Как он может выглядеть в коде? Например, так:

<catalog-product
  :product-id="product.id"
  :sku-id="product.skuId"
  :group-id="product.groupId"
  :image="parseImageUrl(product.image)"
  :link="getProductLink(product.id)"
  :name="product.name"
  :state="product.state"
  :vendor="product.vendor"
  :color="product.color"
  :size="product.size"
  :amount="product.quantity"
  :price="product.retailPriceTotal"
  :old-price="product.catalogPriceTotal"
  :discounts="getProductDiscounts(product.discounts, PRODUCT_TYPES.DISCOUNTS.BONUS)"
  :marks="getProductMarks(product.marks, PRODUCT_TYPES.MARKS.BUSINESS)"
/>

или так:

 <catalog-product :product="product" />

Во втором варианте все парсеры, геттеры, хелперы, энумы и т. д. инкапсулированы внутри компонента catalog-product и его дочерних компонентах. В первом, мы вынуждены все это дело импортить и писать в родительском для catalog-product компоненте. Надо понимать, что в этом родительском компоненте кроме catalog-product есть еще 1-2-5-13 других компонентов, каждый из которых будет ожидать тот или иной подход к декомпозиции.

Как аргумент против второго подхода я иногда слышу, что модель продукта может быть разной. К примеру, на каталоге у нас одна структура, на карточке другая, в корзине третья. Часто так и есть, но и компоненты эти — разные бизнес-сущности. Помните принцип: «SSOLID) — принцип единой ответственности — объединяйте вещи, изменяющиеся по одним причинам, разделяйте вещи, изменяющиеся по разным причинам»? Даже если бы модель данных была бы одинаковой, эти три компонента (карточка каталога, карточка товара, карточка товара в корзине), imho, должны быть сделаны физически разными компонентами. Даже если в момент принятия решения они выглядят идентично. Это разные бизнес-сущности. Чаще всего, даже в этот самый момент они уже чем-то различаются. У одних изображения кликабельны, у других нет; там два-три изображения, реагирующих на hover мыши, тут одно, но большое; здесь выводим полное описание, там не выводим и т. д., и т.п.

Также иногда говорят и о том, что входящий объект нетипизирован и мы заранее не можем знать его структуру. Да, всё так. Но чем нам поможет это знание, если на вход компонент все равно либо получит данные из модели, либо нет? Кроме того, зачастую нам совершенно не важно на верхнем уровне знать, какие именно поля интересует тот или иной компонент или какого они типа. Модель в итоге либо подойдет, либо нет. Если при использовании typescript-а мы еще можем усыпить свою бдительность тем, что вернули из api модель определенного типа и со спокойной [нет] душой начинаем оттуда доставать поля, то в случае с javascript мы все равно про модель доподлинно ничего не знаем и вынуждены постоянно проверять и перепроверять все, что получаем на вход. В продакшене так подавно — что бы мы ни думали об api, оно все равно может вернуть всё, что угодно, за ним постоянно надо проверять и страховаться от сюрпризов. Пусть проверки по каждому конкретному полю на себя берет специализированный компонент. Он сам знает, как ему быть, получи он нужное значение или надо ли ему отражаться, приди это значением кривым, пустым или его вообще не будет.

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

Да, у нас становится чуть больше кода и немного опухает итоговый бандл. Но этот вопрос решается формированием переиспользуемых хелперов, сервисов, миксинов, хуков и т. п., а эти лишние килобайты не имеет смысла считать вообще. Я помню случай, когда мы оптимизировали стейт, чтобы сэкономить 10 или 20 кб. А потом обнаружили, что контентеры залили в баннеры десяток неоптимизированных изображений, каждое из которых можно было сжать на 200–300 кб. Отказываться от разумной оптимизации безусловно не стоит, но ко всему должен быть разумный подход. И в данном конкретном случае, на мой взгляд, экономия лишних килобайт не стоит удобства поддерживаемости кода.

Где должна располагаться бизнес-логика сторонних эффектов?

Речь идет об обработчиках кликов, каких-то выборов, вводов, выводов и т. д. И снова мое imho — в тех же дочерних компонентах. Снова за единую ответственность и инкапусляцию. Исключение составляет кейс, когда выбор в одном компоненте аффектит на работу другого компонента, находящегося на другой цепочке компонентов. Здесь есть вариант устроить перекличку с применением Vuex, шины или любого другого pubsub-а, но выглядит всё это лишним оверхедом, хотя вполне допустимо. Нет никакой причины упираться в догму того или иного подхода. Если удобнее вынести логику выбора даты или времени в родительский компонент, чтобы другие компоненты получили данные о вариантах доставки, так и стоит сделать. Что можем обособить, обособляем, чем надо поделиться, выносим выше. В общем, без фанатизма.

Резюмируя, суть предложенного подхода для обсуждения в том, что ui-компоненты (вроде инпутов, селекторов и т. д.) максимально отвязать от моделей данных, прописывая в пропсы конкретные поля, а в специализированные бизнес-компоненты, созданные только чтобы инкапсулировать ту или иную логику в рамках конкретной модели, передавать всю модель (всю структуру) так, чтобы компонент сам брал из нее только то, что нужно. В итоге компонент в коде выглядит примерно следующим образом*:

<store-details :item="storeDetails" />

А сам store-details выглядит как-то так:

<div class="store-details">
  <store-details-logo :item="item" />
  <store-details-distance :item="item" />
  <store-details-address :item="item" />
  <store-details-subway :item="item" />
  <store-details-schedule :item="item" />
  <store-details-services :item="item" />
  <store-details-route :item="item" />
  <store-details-availability :item="item" />
  <store-details-disclaimer :item="item" />
  <store-details-select-button :item="item" />
</div>

* Да, я знаю, что item, data, prop и пр — не самые лучшие имена для входящих пропсов =)

Предлагаю конструктивно обсудить предложенный к декомпозиции компонентов бизнес-логики подход. Он безусловно не является каким-то изобретением и не несёт на весь мир свет и добро. Это, скорее, сформулированный манифест.

Также, очень любопытно узнать ответы на следующие вопросы:

  • Какие подходы к декомпозиции в ваших командах вы используете?

  • Есть ли вообще у вас сформулированный к декомпозиции подход?

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


  1. TrueRomanus
    00.00.0000 00:00
    +1

    Ваш подход хороший, т.е. кейс где Вы сделали на каждый "похожий но немного другой" отдельный компонент который внутри собирает нужный вид с нюансами. Проблемы у такого похода начинаются когда требуется совместить или использовать две и более модели которые вроде как обычно не связаны друг с другом но вот в каком-то одном месте они связаны и друг от друга зависят (внешний вид зависит). Мы можем объединить по этому же принципу в один компонент но он будет принимать уже не одну модель а n моделей что вполне может приводить к ситуации как Выше в статье мы вроде вынесли и собрали в одном месте но пропов (моделей) у него много для его настройки. Эту ситуацию можно допустим решить изменением структуры ответа у бекэнда на содержащую общую модель но не всегда это резонно и возможно, особенно если данные уже есть на стороне фронта смысла допустим получать их еще раз нет. Второе неудобство у данного подхода в том что если допустим я имею десять таких компонентов и что-то меняться фундаментально то в них во всех (или как минимум в большинство) придется зайти и что-то поменять. Я думаю что подход к разделению компонентов не может быть один, надо применять разные в зависимости от разных кейсов для применения компонентов. У нас в команде используется несколько разных подходов в том числе и все которые Вы описали в статье. Помимо них есть например еще подход когда компонент с бизнес логикой выноситься отдельно а компонент с представлением не содержит логики вообще. Вот пример кнопку мы разделили на два компонента один содержащий логику и состояние https://github.com/P-RCollaboration/ProvueCoreComponents/blob/main/src/states/ButtonState.vue и второй содержащий представление https://github.com/P-RCollaboration/ProvueCoreComponents/blob/main/src/views/material/MaterialButtonView.vue Суть такого подхода в том что можно без изменения компонента с бизнес логикой тасовать компоненты с его представлениями коих может быть много. Пример с кнопкой когда у нас логика нажатия и обработчик кнопки одинаковый у всех кнопок но выглядеть она может в разных частях системы по разному и для каждого представления будет свой компонент но без логики. Вот тут можно посмотреть пример как это склеить воедино https://github.com/P-RCollaboration/ProvueCoreComponents/blob/main/src/examples/helloworld/ButtonsDemo.vue Пример для кнопки но его можно натянуть и на бизнес логику.


  1. Dark_zarich
    00.00.0000 00:00
    +1

    Feature Sliced Design используем, он, в какой-то мере тоже задаёт определенные рамки для проектирования.