Пожалуй, каждый разработчик сталкивается с ситуациями, когда два и более компонентов оказываются тесно связаны логически, но в DOM отображаются раздельно, вперемешку с другими элементами UI. Лобовое решение задачи синхронизации таких разрозненных модулей ведёт к просачиванию их состояния и логики наружу в третьи компоненты. Давайте обсудим эту ситуацию и способы её разрешения.
Для наглядного примера из практики рассмотрим приложение «Такси ВКонтакте», а именно указание «нитки» маршрута.
Проще говоря, у нас есть четыре источника пользовательского ввода для указания точки маршрута:
избранные адреса;
указать пином на карте;
недавние адреса (когда input пустой);
подсказки геокодинга (на основании значения в 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, и создадим достойную замену действующему сейчас сокету. Требования такие:
WebSocket должен существовать в единственном экземпляре;
каждый потребитель WebSocket'а должен считать себя единственным (хотя на самом деле этот сокет используется многими потребителями).
Решение будет следующим: в useWebSocket передаём прокси, который связан с медиатором. Медиатор управляет единственным настоящим вебсокетом и этими прокси:
Прокси создаёт уникальный идентификатор, регистрируется у Mediator’а и связывается с его методами, например, send. Mediator отвечает за приём запросов, передачу их в настоящий сокет и возврат ответа в прокси в соответствии с идентификатором. Также он поддерживает вебсокет в открытом состоянии (если вдруг оборвётся соединение). Чтобы не потерять запросы, когда вебсокет не готов к работе (состояние connecting/closing/closed), мы инкапсулируем запросы в объекты и помещаем их в стек. Как только сокет будет готов к работе, мы начнём их отправлять.
Поле и источники ввода — единый компонент, но должны выглядеть как раздельные компоненты. Когда модуль содержит компоненты, которые связаны единой ответственностью и должны отображаться независимо друг от друга, стоит рассмотреть возможность использовать порталы.
Особое внимание уделите способу определения DOM-ноды, в которую мы будем отображать графический интерфейс. Например:
Найдите ноду с помощью document.querySelector(‘…’). Это самый простой и ненадёжный способ. Совсем неочевидная причина в том случае, если мы не нашли ноду: а что если на том месте, где должен быть этот элемент, сейчас крутится спиннер? Через какое время можно повторить запрос? Разве что подтянуть все возможные свойства, влияющие на наличие интересующей нас DOM-ноды.
Можно использовать колбэк-реф , который установит его в состояние, а затем пробросить в продвинутый input для использования в портале. Недостаток в том, что при каждой отрисовке функционального компонента ref сначала принимает значение null, и только потом получает ссылку на DOM-элемент. Мерцающий графический интерфейс нам не подойдёт.
Можно зафиксировать 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:
Заключение
Если что-то может сломаться, оно обязательно сломается. Даже если мы уверены, что точка с искомым хешем точно должна быть в списке, нам всё равно необходимо обработать случай, когда её там не оказалась. Код обработки этого случая может никогда и не пригодиться, но если пригодился, то придётся вмешаться. По возможности стоит вовсе избегать таких ситуаций. Именно это мы и сделали. Раньше на каждый источник ввода («недавние адреса», «подсказки геокодинга», «выбрать на карте», «избранные адреса») приходилось N текстовых input’ов, и целевой искали по хешу из списка. А после переработки мы получили структуру, где целевой input только один и поиск по хешу не используется вовсе.