Зачем это?

React основан на компонентом подходе. Когда создается компонент, предполагается, что его будут использовать по назначению. Если в проекте есть таблицы значит надо использовать <Table /> (к примеру), формы - значит <Form />. Естественно названия носят абстрактный характер, в каждом проекте они могут иметь разные названия, но суть их одна.

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

Меня зовут Дмитрий Чернов - старший инженер-программист в компании Nord Clan. И мы начинаем.

Pattern Compound Copmonents

Перед тем как начать, расскажу предысторию. У нас в проекте в определенный момент возникли проблемы с модальными окнами. А именно это не корректное использование этих окон. К каждому окну применялись кастомные стили, кнопки использовались как попало, горячие клавиши работали в зависимости от страницы использования (т.е. обработкой занималась ключевая страница на которой вызывалась модальная форма). Разработчиков было не много но даже двух, включая меня хватило что бы многое запоганить. Цель была создать что-то такое, что не позволит использовать компонент нет так как это задумывается по дизайну или тимлидом.

В поисках решения я наткнулся на интересный паттерн Copmonent Compound - это подход который связывает несколько компонентов путем общей сущности и состояния.

Для простоты можно привести примеры из html, основанные на этом подходе - тег <select> с его дочерним тегом <option>. Тег option не может использоваться без тега select. Они непосредственно связаны.

Из более приближенного к React примерам можно упомянуть Context - его составляющие Provider и Consumer, где второй не может использоваться без первого, основаны на том же принципе. Provider обязательно должен присутствовать и быть оберткой для использования Consumer.

<!-- HTML -->
<select>
	<option>1 вариант<option/>
	<option>2 вариант<option/>
	<option>3 вариант<option/>
</select>

<!-- React -->
<React.Provider>
	<React.Consumer>
		<App />
	</React.Consumer>
</React.Provider>

Чтобы глубже понять область применения, представьте себе использование такого подхода на примере компонента Table и Row, именно их можно чаще всего встретить при поиске описания паттерна в интернете. Мы можем использовать компонент <Row /> только внутри компонента <Table></Table>. Но использовать Table без Row нам ничто не запрещает. Более того мы можем использовать другие компоненты или элементы внутри Table.

Итог - данный подход меня устроил, но к сожалению проблему он решил частично.

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


ModalPopUpCompound

???? Внимание!

- для более глубокого погружения в статью предлагаю параллельно открыть DEMO;

- по тексту вы встретите множество ссылок которые помогут перейти к месту в коде или файлу о котором идёт речь;

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

Описание ключевых наименований

PopUpCompound - главный компонент, которым необходимо обернуть рендер любых модальных окон;

popUpContext - контекст который позволяет сделать одностороннюю связку: PopUpCompound ← {CATEGORY};

CATEGORY - один из доступных обязательных компонентов - DefaultPopUp, IconLargePopUp;

usePopUp - хук необходимый для связки используется в {CATEGORY};

DataModalController - компонент управляющий отображением модальными окнами;

МО | МФ - модальное окно | модальная форма;

Точка для посадки МО

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

В index.html подготовим место куда будут рендериться наши модальные окна.

<div id="root"></div>
<!-- Посадка МО -->
<div id="modal-root"></div>

Посадка будет происходить путем использования встроенной функции в ReactDOM → createPortal;

PopUpCompound:

Основной компонент который занимается реализацией почти всей общей логики. Ключевые его аспекты это обработка горячих клавиш, и установка правил использования модальных окон. К правилам я вернусь чуть позже. А пока стоит провести аналогию, что PopUpCompound является такой же оберткой над модальными окнами, как select над option или Provider над Consumer. Отображение управляется через менеджер состояний redux - state.PopUp.modal. Дефолтное состояние modal = null, его значение содержат информацию от том какую именно модалку следует отобразить.

// src/redux/popUp/constants.ts
export enum ModalTypes {
  DATA_NOT_FOUND = "DATA_NOT_FOUND",
  DATA_REQUIRED_FOUND = "DATA_REQUIRED_FOUND"
}

// src/redux/popUp/types.ts
// пример типа данных для одной из модалок
interface DataNotFoundPopUp {
  popUpType: ModalTypes.DATA_NOT_FOUND;
  data: DataInfo;
}

Первый шаг

Для реализации подхода ComponentCompound нам понадобиться помощь Context, о котором я как раз уже ранее упоминал. Именно с помощью него мы будем связывать подготовленные МФ PopUpCompound. Создаем контекст popUpContext, и оборачиваем всё в popUpContext.Provider.

Второй шаг

Далее для подключения к подготовленным формам используется кастомный хук usePopUp.

export const usePopUp = (): IPopUpContext => {
  const context = useContext(popUpContext);
  if (!context) { // проверяем существует ли контекст
    throw new Error(
      "!!!ATTENTION!!! This component must be used within a <PopUpCompound> component."
    );
  }
  return context;
};
types
// типы которые помогут лучше понять код
// категории модальных окон, подробнее будет далее
export enum ECategoryPopUp {
  ICON_LARGE = "ICON_LARGE",
  DEFAULT = "DEFAULT",
}
export interface IPopUpContext {
  modal: Nullable<Modal>;
  category: Nullable<ECategoryPopUp>;
	//...
}
interface DataNotFoundPopUp {
  popUpType: ModalTypes.Data_NOT_FOUND;
  data: DataInfo;
}

interface DataRequiredFoundPopUp {
  popUpType: ModalTypes.Data_REQUIRED_FOUND;
  data: DataInfo;
}

// используется для типизации хранилища redux PopUpState.modal
export type Modal = DataNotFoundPopUp | DataRequiredFoundPopUp;

export interface PopUpState {
  modal: Modal | null;
  isFetching: boolean;
  category: ECategoryPopUp | null;
}

Его принцип предельно прост. Если компонент где этот хук используется находится вне контекста popUpContext, то он выбрасывает ошибку. Что прерывает работу приложения и разработчик получает в консоли ошибку.

!!!ATTENTION!!! This component must be used within a <PopUpCompound> component.

В противном случае прокидывает контекст далее для работы.

DataModalController

Если модальная форма одна на странице, её можно напрямую поместить в children PopUpCompound. Но что же делать если их огромное количество? Наклепать несколько оберток?

В сложных приложениях модальные формы выводят чуть ли не на каждый “чих”. Для этого на странице будем использовать некий контроллер, который следит за тем какую именно модалку сейчас нужно отобразить. Инфа поступает из redux - modal. Через конструкцию switch case определяет какое модальное окно отобразить (через ModalTypes).

На каждой странице должен быть свой контроллер и обрабатывать свои кейсы.

CategoryPopUp

И вот самое интересное. Категории модальных окон. Это те самые подготовленные формы, о которых я пару раз упоминал. С дизайнером мы обсудили все возможные кейсы, и выявили два вида модальных окон: DefaultPopUp и IconLargePopUp.

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

Естественно мы можем создать еще какой либо вид МФ, самое важное, то что нам нужно интегрировать в него usePopUp.

Использовав, usePopUp в МФ, мы блокируем использование её вне PopUpCompound. За счет контекста происходит односторонняя связка. Но это не запрещает нам внутри использовать другую МФ, в которую мы не будем интегрировать usePopUp, мы даже категорию выбирать не будем, а просто создадим новую форму и прокинем её в PopUpCompound. Таким образом не ограничиваем разработчика в использовании внутри чего угодно. Необходимо сделать двухстороннюю связку, что бы PopUpCompound. Принимал только определенные компоненты в себя.

Первое что пришло мне на ум, это указать какие именно компоненты мы ждем, прокинуть типы их пропсов, и вроде все в ажуре. Но на деле все оказалось не так просто. Дело в том что React не умеет отличать компоненты. Для него они по сути все одинаковы. Я потратил дня 3-4 на поиск решения. В итоге придумал следующее — создаем уникальный класс (стилей), который будет сообщать о принадлежности данного компонента к типу категорий = CategoryPopUpIdentificator.

И вот правило которое обрабатывает PopUpCompound - отслеживание первого дочернего элемента на принадлежность к классу “CategoryPopUpIdentificator”. Прекрасным инструментом для это стал MutationObserver, который отслеживает изменения в DOM. Ну и раз есть правила, в случае их нарушения, все рушится, и разработчик получает очередную ошибку.

!!!ATTENTION!!! You need the component PopUpCompound only used within a CategoryPopUp: DefaultPopUp, IconLargePopUp

const classCategoryPopUp = "CategoryPopUp";
...
// Проверяем правильно ли используется модальная форма на странице
  const observer = new MutationObserver((mutations) => {
    mutations[0].addedNodes.forEach((mutation) => {
      const firstElement = mutation.firstChild as HTMLElement;

			// смотрим принадлежит ли первый элемент к нужному классу
      const isFirstElementCategoryPopUp = firstElement.className.includes(
        classCategoryPopUp 
      );
      if (!isFirstElementCategoryPopUp)
        throw Error(
          "!!!ATTENTION!!! You need the component PopUpCompound only used within a CategoryPopUp: DefaultPopUp, IconLargePopUp"
        );
    });
    if (mutations[0].removedNodes.length) {
      // отписываемся при закрытии модальной формы
      observer.disconnect();
    }
  });

Content PopUp.

Как я и сказал DefaultPopUp и IconLargePopUp это лишь очередные обертки, категории МФ. При создании контента, для каждой конкретной модалки, мы определяем к какой категории её отнести, импортируем нужную, а так же обязательно оборачиваем весь наш рендер компонента выбранной категорией. DataNotFound и DataRequiredFound - именно эти компоненты дергает наш контроллер DataModalController в switch case.

// ...
switch (modal.popUpType) {
    case ModalTypes.DATA_NOT_FOUND:
      return <DataNotFound data={modal.data} />; // тут
    case ModalTypes.DATA_REQUIRED_FOUND:
      return <DataRequiredFound data={modal.data} />; // и тут
    default:
      return null;
  }
//..

Проверка

Давайте проверим как же все это происходит на практике. Для этого нужно два кейса.

  1. отключить основную обертку PopUpCompound, при попытке открыть МФ получаем ошибку: “!!!ATTENTION!!! This component must be used within a <PopUpCompound> component.”

  2. отключить класс classCategoryPopUp от DefaultPopUp, делая его таким образом неизвестным для PopUpCompound. Кликнув по кнопке DefaultPopUp получаем ошибку: ”!!!ATTENTION!!! You need the component PopUpCompound only used within a CategoryPopUp: DefaultPopUp, IconLargePopUp”

Итого

И так чем же полезна данная статья на мой взгляд.

Первое, мы познакомились с паттерном Compound Component, увидели новые примеры его применения, разобрали способы реализации данного подхода, и как следствие поняли, что его можно применять практически для чего угодно. Главное создать обертку которая будет отвечать за это.

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

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

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

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


  1. aamonster
    21.08.2023 09:14
    +3

    А вытащить все эти ошибки на этап компиляции (или прогона линтера) нельзя?

    Ну и обычная проблема – это не то, что разработчик злонамеренно нарушает guide lines, а то, что либо этих гайдлайнов вообще нет, либо они неполные, недостаточно понятные или плохо структурированные, так что для того, чтобы сделать правильно – надо идти к архитектору и выяснять, какой же путь у нас считается правильным (или перелопачивать кодовую базу в поисках аналогов). Ну или второй вариант – что решение по гайдлайнам значительно сложнее лобового. Тут разработчику придётся объяснять, какие именно возможности покупаются этой сложностью. В общем, главный способ "запретить разработчику делать не то что нужно" – это задокументировать, что именно нужно, и тыкать носом. А автоматические проверки – это бонус, чтобы он зря время не терял.


    1. Blue-Brain Автор
      21.08.2023 09:14

      На счет документации согласен, и использование её в комбо с кодовыми блокировками будет супер подходом. На счет линтера, не могу ничего сказать, как то этот подход обошел меня)


  1. ImagineTables
    21.08.2023 09:14

    Для простоты можно привести примеры из html, основанные на этом подходе — тег select с его дочерним тегом option. Тег option не может использоваться без тега select. Они непосредственно связаны.

    Во-первых, строго говоря это не так. Помимо select есть ещё не только optgroup (секции вариантов), который, всё-таки, связан с select, но и совершенно от него независимый datalist (всплывающие подсказки для автозаполнения).


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


    Сегодня я бы задизайнил эту фичу совершенно иначе. Я бы убрал option и считал опциями все непосредственные вложения в select. Если хочешь сделать опции богатыми оформлением — пожалуйста, используй стандартный select, а не городи огород со всплывающими div'ами. А хочешь дать юзеру чисто текстовые варианты — так и обозначь: p, p, p.


    1. Blue-Brain Автор
      21.08.2023 09:14

      Что-то вы сильно зациклились на HTML примере:) статья совсем не об этом. Буду рад более компетентному комментарию от вас. Почитайте внимательно статью.


      1. ImagineTables
        21.08.2023 09:14
        +1

        Мне сам паттерн Copmonent Compound кажется сомнительным. А какой был приведён пример — на том я и пояснил.


        1. Blue-Brain Автор
          21.08.2023 09:14

          пример больше для понятности связей и зависимостей. Может я грубо сказал что прямо в html используется тот же паттерн, но в целом он очень похож, ну на счет его сомнения тут он не прост в понимании и я хотел показать как раз очередной пример его использования помимо уже заезженных таблиц со строками. Плюс показать как можно его немного доработать.


  1. dididididi
    21.08.2023 09:14
    +1

    И тут разработчик думает, вот же уроды.

    И тратит день, чтоб обойти или стереть вашу проверку и сделать по своему.


    1. Blue-Brain Автор
      21.08.2023 09:14

      И во время pull request-а получает по рукам от ревьюера. Как раз для этого это все и задумано????


      1. markelov69
        21.08.2023 09:14
        +1

        И во время pull request-а получает по рукам от ревьюера. Как раз для этого это все и задумано

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


        1. Blue-Brain Автор
          21.08.2023 09:14

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


      1. dididididi
        21.08.2023 09:14
        +1

        А чем отличается от старого способа, кроме того, что он день потратил, чтоб обойти ваши козни?

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


        1. Blue-Brain Автор
          21.08.2023 09:14

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


          1. dididididi
            21.08.2023 09:14
            +1

            Давайте не развешивать ярлыки.

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


            1. Blue-Brain Автор
              21.08.2023 09:14

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


              1. dididididi
                21.08.2023 09:14

                Ништяк. Ты назвал меня неуважающим себя тимлидом и предложил закончить)))

                Извинись, завали клюв, прекрати выпендриваться и закончим.


                1. Blue-Brain Автор
                  21.08.2023 09:14

                  Для начала пожалуйста на вы, мы не на базаре, а в продолжение извинятся ни за что не собираюсь


              1. markelov69
                21.08.2023 09:14

                но уверяю вас ни один уважающий себя тимЛид не будет за вас что то допиливать

                Вы видать сами никогда тимлидом не были и опыт работы у вас в рамках 1 или 2х команд)

                Главные критерии по написанию кода:
                - Код должен быть легко читаем сверху-вниз слева-направо.
                - Код должен быть очевидным, прозрачными и понятным, не спрятанным за тучей абстракций.
                - Вместо замудреных теранарок лучше несколько строк с if'ами.
                - Простые вещи всегда и при любых условия должны оставаться простыми, нельзя их усложнять.
                - Любой разработчик который первый раз видит этот код, например обработчики нажатий на кнопки, должен сразу по понимать что при этом происходит, т.е. не должно быть специальных правил и кучи асбтракций, единственное правило - пиши максимально простой и очевидный код.

                Ну и в таком стиле, я думаю смысл понятен.
                Из аббревиатур это:
                - KISS
                - YAGNI

                А всё остальные это чушь и вставка палок самим себе в колеса.

                так как по теме обязанностей мы сошлись на том что есть разные цели команд.

                Ваша цели и так понятны:
                1) Максимально замедлить разработку.
                2) Максимально усложнить жизнь разработчикам и вызывать и у них максимальные чувства отвращения и желания сменить работу.
                3) Поставить бизнес на колени т.к. проект который разрабатывается пол года в вашем случае будет разрабатываться 3 года.
                4) Потешить собственное эго, наивно думая что загоняя всех в неадекватные жесткие рамки, вы типо весь такой умный и крутой :D

                Аналогично я бы задал вопросы разработчику который вместо того что бы следовать стилю кода ставил везде игноры для линтера.

                Ну как бы кто сказал что ваши правила линтера хорошие, полезные и удобные?) Вы что ли?) Все правила линтера у которых нет --fix (авто фикса) - в утиль, ибо они только мешают и раздражают.

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

                А кто сказал что ваше правило удобное, полезное и нужное?) Скорее у разработчика к вам большие вопросы ибо когда вы из элементарных вещей делаете проблему, ну это скажем так странно)


                1. Blue-Brain Автор
                  21.08.2023 09:14

                  Ну тогда и линтеры нахрен нужны, Вы я как вижу тоже за разработчиками все дописываете?! И пишет у вас тоже кто как хочет, не придерживаясь никаких правил. Не хотелось бы работать под вашим началом. Честно собрались почти одни хейтеры, увидели фразу "ЗАПРЕТИТЬ ..." и понеслось, даже код врядли смотрели. Всего пару существенных комментариев по которым можно было бы развести дискуссию и пообщаться, что-то улучшить, послушать как другие справляются с этой проблемой. А тут только "Это все чушь, нахрен это надо и т.п.". Значит Вам повезло что Вы не сталкивались пока с такими проблемами.


                  1. dididididi
                    21.08.2023 09:14

                    В смысле повезло? Он тебе написал ряд правил, как не сталкиваться с твоими проблемами. А ты везде хейтеров видишь.

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


  1. LyuMih
    21.08.2023 09:14

    (никак, если это не ломает CI/CD)


    1. Blue-Brain Автор
      21.08.2023 09:14
      +1

      ????