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

Для наглядного примера из практики рассмотрим приложение «Такси ВКонтакте», а именно указание «нитки» маршрута.

Что представляет собой указание «нитки» маршрута.
Что представляет собой указание «нитки» маршрута.

Проще говоря, у нас есть четыре источника пользовательского ввода для указания точки маршрута:

  1. избранные адреса;

  2. указать пином на карте;

  3. недавние адреса (когда input пустой);

  4. подсказки геокодинга (на основании значения в input).

Давайте посмотрим, как это было реализовано. Во-первых, мы используем библиотеку компонентов VKUI. Нас интересуют компоненты View и Panel. Все три экрана на первой иллюстрации — это компоненты Panel, то есть табы внутри View, такая вот отсылка к мобильной разработке.

Как устроено взаимодействие input’ов и источников ввода? У каждого input’а есть хеш, и по событию focus input выставляет хеш в состояние в качестве активного, а по событию blur — очищает состояние. Именно на этот хеш ориентируются источники ввода, когда добавляют или изменяют точку.

На первый взгляд всё выглядит приемлемо, но есть одно «но»: если «недавние адреса» и «подсказки геокодинга» находятся на одном табе (он же компонент Panel) вместе с input’ами, то «выбор на карте» и «избранные адреса» находятся на отдельных табах, и переход на них будет сопровождаться событием blur, что сбросит значение активного хеша. Вывод: перед переходом между табами нам нужно «подхватывать» значение активного хеша и ещё раз его устанавливать. Похоже на игру «горячая картошка».

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

И что же нам делать? Разделять ответственность. Ответственность за положение элементов на странице мы переложим на тот самый главный компонент, а ответственность за ввод точки маршрута передадим новому компоненту — продвинутому input'у.

Как нам нужно преобразовать главный компонент? Мы забираем у него ответственность сопоставлять поля ввода адреса (input'ы) и источники ввода («недавние адреса», «избранные адреса», «подсказки геокодинга», «выбор на карте»), и оставляем ему ответственность за работу со списком (добавление, удаление и изменение порядка следования).

Что такое продвинутый input? Это абстракция над обычным input'ом. Если в обычный input нам предлагается вводить значение с помощью всплывающей клавиатуры, то продвинутый input пошёл дальше и предлагает ещё четыре дополнительных источника («недавние адреса», «подсказки геокодинга», «выбрать на карте», «избранные адреса»). Более подробная схема выглядит так:

Поскольку задачу компоновки мы отделили, то список с продвинутыми input'ами сейчас выглядит следующим образом:

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

Мы достигли главного: в коде исчезли игры в «горячую картошку». Каждый input имеет свои источники ввода, которые чётко знают, куда отдавать данные. Другими словами, мы сократили количество кода «времени выполнения» и увеличили код «времени инициализации», что дало нам больше декларативности. Достаточно один раз пройтись по исходникам сверху вниз и слева направо, избавив себя от необходимости прокручивать в голове сценарий установки и сопровождения хеша активной точки.

Но не всё так радужно. Мы избавились от волокиты с хешем, но что делать с тем, что раньше на все input'ы у нас было четыре источника ввода, а теперь на каждый продвинутый input приходится по четыре источника? И если некоторые источники ввода, такие как «недавние адреса» или «избранные адреса», используют простой запрос к бекенду и мы его можем закешировать, то, например, «подсказки геокодинга» используют вебсокет, и устанавливать на каждый продвинутый input по соединению будет расточительно.

Сокет должен быть только один. Но каждый продвинутый input должен иметь свой сокет.

Точнее, каждый источник ввода «подсказки геокодинга» внутри продвинутого input'а должен иметь свой сокет, как это было и раньше, когда он не входил в состав продвинутого input'а.

Сделаем шаг назад и взглянем на текущую реализацию компонента «подсказки геокодинга». Схематично конечно же:

К его работе нет вопросов, а значит и реализацию мы должны постараться полностью сохранить. Мы сохраним компонент "подсказки геокодинга" и хук useWebSocket, и создадим достойную замену действующему сейчас сокету. Требования такие:

  1. WebSocket должен существовать в единственном экземпляре;

  2. каждый потребитель WebSocket'а должен считать себя единственным (хотя на самом деле этот сокет используется многими потребителями).

Решение будет следующим: в useWebSocket передаём прокси, который связан с медиатором. Медиатор управляет единственным настоящим вебсокетом и этими прокси:

Прокси создаёт уникальный идентификатор, регистрируется у Mediator’а и связывается с его методами, например, send. Mediator отвечает за приём запросов, передачу их в настоящий сокет и возврат ответа в прокси в соответствии с идентификатором. Также он поддерживает вебсокет в открытом состоянии (если вдруг оборвётся соединение). Чтобы не потерять запросы, когда вебсокет не готов к работе (состояние connecting/closing/closed), мы инкапсулируем запросы в объекты и помещаем их в стек. Как только сокет будет готов к работе, мы начнём их отправлять.

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

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

  1. Найдите ноду с помощью document.querySelector(‘…’). Это самый простой и ненадёжный способ. Совсем неочевидная причина в том случае, если мы не нашли ноду: а что если на том месте, где должен быть этот элемент, сейчас крутится спиннер? Через какое время можно повторить запрос? Разве что подтянуть все возможные свойства, влияющие на наличие интересующей нас DOM-ноды.

  2. Можно использовать колбэк-реф , который установит его в состояние, а затем пробросить в продвинутый input для использования в портале. Недостаток в том, что при каждой отрисовке функционального компонента ref сначала принимает значение null, и только потом получает ссылку на DOM-элемент. Мерцающий графический интерфейс нам не подойдёт.

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

Из этих трёх способов ни один не подойдёт. Нам необходима декларативность и предсказуемость. Значит, снова разделяем ответственности. Нам нужен DOM-элемент и два React-компонента. Первый React-компонент — это Portal, он отвечает за отрисовку своих дочерних компонентов в DOM-элемент. Второй — Container, он отвечает за то, чтобы после монтирования в DOM прикрепить к себе наш DOM-элемент.

Создание DOM-элемента, Portal’а и Container’а вынесем в отдельный хук usePortal:

const portal = (domElement: HTMLDivElement): FC => (props) => {
  const { children } = props

  return ReactDOM.createPortal(children, domElement)
}

const portalContainer = (domElement: HTMLDivElement): FC => () => {
  const ref = useRef<HTMLDivElement>(null)

  useDidMount(() => {
    ref.current.appendChild(domElement)
  })

  return <div ref={ref} />
}

export const usePortal = () => {
  const container = useRef(document.createElement('div'))

  const Portal = useMemo(() => portal(container.current), [])
  const Container = useMemo(() => portalContainer(container.current), [])

  return [Container, Portal]
}

Например, компонент Panel со списком input'ов, подсказками геокодинга и недавних адресов будет таким:

Благодаря тому что продвинутый инпут отрисовывет своё содержимое через портал в контейнеры мы можем группировать контролы согласно макету
Благодаря тому что продвинутый инпут отрисовывет своё содержимое через портал в контейнеры мы можем группировать контролы согласно макету

А это сам продвинутый input:

Portal1 отрисовывает содержимое в Container1, а Portal2 в Container2
Portal1 отрисовывает содержимое в Container1, а Portal2 в Container2

Заключение

Если что-то может сломаться, оно обязательно сломается. Даже если мы уверены, что точка с искомым хешем точно должна быть в списке, нам всё равно необходимо обработать случай, когда её там не оказалась. Код обработки этого случая может никогда и не пригодиться, но если пригодился, то придётся вмешаться. По возможности стоит вовсе избегать таких ситуаций. Именно это мы и сделали. Раньше на каждый источник ввода («недавние адреса», «подсказки геокодинга», «выбрать на карте», «избранные адреса») приходилось N текстовых input’ов, и целевой искали по хешу из списка. А после переработки мы получили структуру, где целевой input только один и поиск по хешу не используется вовсе.

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


  1. zartdinov
    02.12.2021 14:39
    +1

    Если я правильно понял, с контролем глобального состояния не было бы такой проблемы.


    1. zartdinov
      02.12.2021 15:01

      Вообще, я больше по бэку, давайте распишу:

      const state = {
        origin: { ... },
        destination: { ... },
        previousFavoriteAddresses: [{ ... }, ...],
      }

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


      1. Marat1403 Автор
        02.12.2021 16:22
        +1

        Полагаю, вы поняли не правильно. Именно про это и шла речь в заключении. Подумайте, какими значениями вы проинициализирете origin и destination, скорее всего null или undefined. Так как вы с бэкенда, вам наверняка знакома статическая типизация. Заставит ли она вас сделать проверку на null, всякий раз когда вы решите обновить точку!? Но если вы уверены, что написали код так, что перед обновлением значения origin/destination там обязательно будут - значит это мёртвый код. Нет гарантии, что другой разработчик не нарушит это хрупкое равновесие и в какой-то момент там будет null, а новое значение просто уйдёт в никуда. Поддерживать такой инвариант очень затратно. Поэтому мы и ушли от подобного глобального состояния. Наш инвариант: компоненты (карта, геокодинг, ...) есть только тогда, когда есть что изменять.


        1. zartdinov
          02.12.2021 18:32

          Вроде понял, но я обычно да, инициализирую null в таких случаях и в везде закладываю логику, что это нормальное значение, иногда возвращаю его (например, при очистке select'a).


  1. Nikitakun1
    02.12.2021 15:29
    +2

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


    1. Marat1403 Автор
      02.12.2021 15:37

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