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

5 June 2019

Существует ряд задач, для решения которых 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/s/react-usedraggable-hook-on-rxjs-ildrn

Серый 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" — это такой же эмит событий, но других по смыслу.

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

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

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