Существует ряд задач, для решения которых RxJS подходит лучше всего. Одна из таких задач — это комбинация нескольких "потоков" событий с целью создания какого-либо жеста. В этой статье шаг за шагом напишем универсальный hook для React, который позволит подключить жест передвижения к любому HTML-элементу
Почему решение именно с этими подходами
Эту логику также можно было бы оформить как HoC (higher-order component), но hook лучше подходит, т.к. проще типизируется и не создает лишних уровней вложенности в react-дереве.
Можно не использовать RxJS, чтобы сочетать 3 event emitter (на события pointerdown, pointermove, pointerup), но надеюсь ты, о любознательный читатель, по ходу повествования оценишь компактность и изящность решения с помощью RxJS. Помимо эстетических ощущений, есть и объективная причина — на "чистые" event emitter сложно-невозможно написать тесты.
Вместо MouseEvents используется относительно новый стандарт PointerEvents, который позволяет не писать специфичный код для мобильных устройств.
Также в коде используется TypeScript, ну потому что сейчас TS — это единственный способ писать сколько-то ни было большой проект с временем жизни больше года. Flow, увы, развивается не так быстро и не распространен.
Что получим в итоге
В итоге мы получим такое приложение —https://codesandbox.io/p/sandbox/react-usedraggable-hook-on-rxjs-with-composable-refs-vz3pp
Серый div можно будет двигать по вертикали.
Step by step
Для начала определим программный дизайн нашего решения. Вот его основные элементы:
1. Логика генерации событий drag-жеста в отдельном модуле. API модуля никак не зависит от конечного фреймворка, в котором он будет использован. Логика должна быть покрыта тестами.
*Напомню, что тесты — это также и спецификация твоего кода. Они очень хорошо помогают в том, что происходит, когда ты возвращается к коду через n-месяцев или n-лет.
2. Логику в React-приложении оформляем как универсальный hook.
3. Используем hook в компоненте, который хотим, чтобы пользователь смог двигать.
4. Корректно отписываемся от слушания всех событий при уничтожении (unmount) react-элемента.
Модуль drag-жеста
Drag-жест — это жест перемещения объекта по экрану. Он состоит из композиции событий:
- Нажали на элемент (pointerdown);
- После этого нажатия начинаем слушать события перемещения указателя (pointermove);
- Слушаем и реагируем на перемещения путем изменения стиля transform: translateY(<...>px);
- Слушаем до тех пор пока пользователь не отожмет (не уберет с экрана) указатель (pointerup).
Код:
Даа, RxJS с первого взгляда кажется имеет не самый понятный API, но, когда привыкаешь, реально начинаешь получать удовольствие от того, как компактно можно описывать сложные операции.
Что здесь происходит:
- На вход нашей функции подаются три "потока" (будем называть это "потоками", но на самом деле это более общая абстракция, основанная на паттерне Observable):
- поток с событиями нажатия мышкой на наш элемент — down$,
- поток с событиями "отжатия" мышкой — up$
- поток с событиями перемещения курсора — move$
Аргументы заканчиваются на знак $ не из-за стремления к нерусским деньгам (закадровый смех), а для обозначения, что это переменные являются потоками. Это общепринятое соглашение в RxJS.
Функция возвращает новый поток, который отправляет события только тогда, когда происходит непосредственно само перемещение мышки после нажатия на элемент.
Тело функции можно дословно прочитать так: начинаем слушать поток событий нажатия мышкой (down$), когда событие произошло, переключаемся на слушание другого потока, который возвратит функция внутри оператора mergeMap.
Новый поток в mergeMap — это "слушание" событий перемещения указателя мышки move$, которые мы слушаем до тех пор, пока не появится событие из потока "отжатия" указателя up$ (за это отвечает оператор takeUntill).
Все события из потока move$ преобразуем (оператор map) в относительное перемещение. Оно относительно начальному положению элемента.
Тест на эту логику выглядит следующим образом:
Здесь используется библиотека, которая облегчает тестирование RxJS, — https://github.com/cartant/rxjs-marbles
Её API основано на той же схеме описания потоков, что используется для объяснения какого-то решения, которое использует потоки, — marble-диаграмма, или диаграмма на шариках (бусинках). Типичная диаграмма может выглядеть так:
В этом примере первая нить — это поток каких-то событий (в нашем случае это поток move$ — перемещения указателя), второй поток — это аргумент оператора takeUntil, эмит события в этом потоке "останавливает" эмит событий в результирующем потоке (в нашем случае эмит события "указатель поднят" в потоке up$ останавливает слушание событий перемещения указателя).
Аналогично читается наш тест на rxjs-marbles:
- "-" — означает, что ничего не эмитится в этом кванте времени
- "d" — означает, что эмитится событие d. Вторым аргументом прикладывается мапа, где за индексом d стоит объект PointerEvent.
- "m", "u", "e" — это такой же эмит событий, но других по смыслу.
drag$ — это поток, созданный нашей функцией, expectedDrag$ — это те значения, которые после отработки потоков должен заэмитеть drag$.
Соответственно строка:
запускает проверку в тесте.
Универсальный React hook для добавления "перемещаемости" HTML-элементам
На вход хук принимает ref, в который будет положена ссылка на HTML-элемент, которому мы добавляем возможность двигаться.
Поскольку поток нажатия на элемент down$ можно получить только, когда react-отрисует все html-элементы (componentDidMount, или функция в хуках useEffect, useLayoutEffect), то мы воспользуется useRef для создания мутабельного контейнера, в который запишем поток жеста drag.
Этот RefObject мы и возвращаем из хука.
Использование хука в компоненте
Код компонента, в котором все это используем, выглядит так:
Мы создали контейнер (ref-объект), в который react положит ссылку на отрендеренный HTML-элемент — draggableDivRef. Этот объект отдали в качестве аргумента в наш хук — useDraggable.
В хуке useLayoutEffect описали логику реакции на события — мы обновляем положение элемента по оси Y путем задания стиля:
А также не забываем отписаться от всех событий:
И это на самом деле очень важная часть нашего решения на базе RxJS — мы отписались от потока события drag$, но на самом деле, поскольку он состоит из комбинации других трех потоков, произошла отписка и от этих трех потоков (напомню, это up$, down$, move$). И это один из ключевых selling point решений на базе RxJS по сравнению с работой с традиционными Event Emitter — в Event Emitter нет каскадной отписки от событий и приходится самим в коде это обрабатывать и нередко за этим сложно уследить.
Вторым ключевым преимуществом RxJS над обычным Event Emitter является возможность тестировать все составные элементы решения: начало подписки на события, последовательность событий между несколькими потоками, значения которые в тот или иной момент эмитят потоки и конец подписки на события.
Как еще можно улучшить решение
Добавить поддержку событий pointercancel и других, чтобы отменять жест не только поднятием указателя, но и входящим звонком, например. Подробнее про работу с PointerEvents и в целом с жестами можно послушать в лекции, которую я подготовил для Школы Разработки Интерфейсов в Яндексе — https://www.youtube.com/watch?v=VZAcd2svW7w
Также стоит написать тесты, которые учитывают не только порядок событий, но и конкретные значения перемещения. То есть протестировать, что если было два pointermove-события со смещением по 10px, то итоговое смещение будет 20px.
Напутствие
Да, для решения не самой сложной задачи мы затронули так много тем: react, hook, refs, useEffect, rxjs, marbles, jest и много других. Кто-то скажет, что это over-engineering (то есть слишком сложное решение несложной проблемы) и может быть прав, всё дело в контексте!
Если вам нужен жест перемещения объекта, то можно воспользоваться одной из десяток библиотек, но, как правило в довесок прилетит 90% кода, который вы не будете использовать. Как правило, в них не бывает тестов. Однако если у вас стартап, то это вполне рабочий вариант.
Можно было бы не использовать RxJS, но я не представляю решения, которое читалось бы и понималось бы быстрее, было бы изолированнее и для него можно было бы проще написать тесты. Если ты, дорогой читатель, знаешь о таком и можешь показать — искренне желаю его увидеть! Пишите об этом в комментариях.
Ресурсы для изучения RxJS
- "Первый" сайт по технологии Rx со сборником основных реализаций и описанием подхода Не очень понятный, но всеобъемлющий.
- Отличный и понятный справочник по многим операторам RxJS. Открываю его очень часто.
- Введение в RxJS через написание собственной реализации подхода на обычном JS На английском, но несложном. Примеры на JS понятные и простые.
- Блог Ben Lash — разработчик в Netflix, основной меинтейнер Github репо RxJS.
———————————————————————————————————————
PS: хотел бы получить больше обратной связи про сам формат статьи. Может это стоило бы разбить на несколько статей? Больше картинок и диаграмм? Еще что-то? Ожидаемый ли контент для вас в Дзене? Жду ваших предложений и замечаний в комментах.