Как стать автором
Обновить

Как запретить разработчику делать не то что нужно?

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров9.5K

Зачем это?

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

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

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

Pattern Compound Components

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

В поисках решения я наткнулся на интересный паттерн Component 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 этого не умеют. Потому для решения подобной задачи использовался паттерн Компаунд Компонент с небольшими модификациями.

Теги:
Хабы:
Всего голосов 13: ↑8 и ↓5+6
Комментарии32

Публикации

Информация

Сайт
nordclan.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия