Найти в Дзене
IT FROM BIT

Пошаговая инструкция, как сделать React Hook для перемещения элементов веб-страницы на базе RxJS

Существует ряд задач, для решения которых RxJS подходит лучше всего. Одна из таких задач — это комбинация нескольких "потоков" событий с целью создания какого-либо жеста. В этой статье шаг за шагом напишем универсальный hook для React, который позволит подключить жест передвижения к любому HTML-элементу Почему решение именно с этими подходами Эту логику также можно было бы оформить как HoC (higher-order component), но hook лучше подходит, т.к. проще типизируется и не создает лишних уровней вложенности в react-дереве. Можно не использовать RxJS, чтобы сочетать 3 event emitter (на события pointerdown, pointermove, pointerup), но надеюсь ты, о любознательный читатель, по ходу повествования оценишь компактность и изящность решения с помощью RxJS. Помимо эстетических ощущений, есть и объективная причина — на "чистые" event emitter сложно-невозможно написать тесты. Вместо MouseEvents используется относительно новый стандарт PointerEvents, который позволяет не писать специфичный код для моби
Оглавление

Существует ряд задач, для решения которых 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-диаграмма, или диаграмма на шариках (бусинках). Типичная диаграмма может выглядеть так:

Диаграмма объясняет принцип работы оператора takeUntill.
Диаграмма объясняет принцип работы оператора takeUntill.

В этом примере первая нить — это поток каких-то событий (в нашем случае это поток move$ — перемещения указателя), второй поток — это аргумент оператора takeUntil, эмит события в этом потоке "останавливает" эмит событий в результирующем потоке (в нашем случае эмит события "указатель поднят" в потоке up$ останавливает слушание событий перемещения указателя).

Аналогично читается наш тест на rxjs-marbles:

  • "-" — означает, что ничего не эмитится в этом кванте времени
  • "d" — означает, что эмитится событие d. Вторым аргументом прикладывается мапа, где за индексом d стоит объект PointerEvent.
  • "m", "u", "e" — это такой же эмит событий, но других по смыслу.
-4

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

Соответственно строка:

-5

запускает проверку в тесте.

Универсальный React hook для добавления "перемещаемости" HTML-элементам

Код хука:

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

Поскольку поток нажатия на элемент down$ можно получить только, когда react-отрисует все html-элементы (componentDidMount, или функция в хуках useEffect, useLayoutEffect), то мы воспользуется useRef для создания мутабельного контейнера, в который запишем поток жеста drag.

Этот RefObject мы и возвращаем из хука.

Использование хука в компоненте

Код компонента, в котором все это используем, выглядит так:

Мы создали контейнер (ref-объект), в который react положит ссылку на отрендеренный HTML-элемент — draggableDivRef. Этот объект отдали в качестве аргумента в наш хук — useDraggable.

В хуке useLayoutEffect описали логику реакции на события — мы обновляем положение элемента по оси Y путем задания стиля:

-6

А также не забываем отписаться от всех событий:

-7

И это на самом деле очень важная часть нашего решения на базе 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

———————————————————————————————————————

PS: хотел бы получить больше обратной связи про сам формат статьи. Может это стоило бы разбить на несколько статей? Больше картинок и диаграмм? Еще что-то? Ожидаемый ли контент для вас в Дзене? Жду ваших предложений и замечаний в комментах.