[JavaScript, ReactJS] Как мы победили попапы в мессенджере Gem4Me

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
14-Июл-2020 12:32


Привет, Хабр!
Сегодня я, лид Web-разработки мессенджера Gem4Me, и мое альтер эго “АйТи Синяк” хотим поделиться своей историей борьбы с модальными окнами.
В каждом проекте, где я успел поработать, модальные окна были устроены максимально примитивно. Везде примерно одна и та же картина: один глобальный менеджер попапов на весь проект. И реализован он с помощью switch-case блока, который отвечает за то, какой именно сейчас попап отобразить. На мой взгляд, это никуда не годится.
Выглядит это обычно примерно следующим образом:
const { type, data } = this.props;
switch (type) {
  case ModalsConstants.type.AlertModal:
    return <Alert key={type} data={data}/>;
  case ModalsConstants.type.GroupMembersModal:
    return <GroupMembers key={type} data={data}/>;
  case ModalsConstants.type.ContactsModal:
    return <ContactsView key={type} data={data}/>;
  case ModalsConstants.type.UserSettingsModal:
    return <UserSettingsView key={type} data={data}/>;
  case ModalsConstants.type.UserProfileModal:
    return <UserProfileModal key={type} data={data}/>;
  ...
  default:
    return null;
}

В данной статье попап / модальное окно / диалог и другие схожие слова — просто синонимы, не ищите потайного смысла.

Недостатки глобального менеджера попапов
Их очень много, я выделил несколько основных.
Взаимодействие с контролами браузера
Первая и основная, на мой взгляд, проблема — это взаимодействие с навигацией в браузере. Я готов поспорить, если вы откроете попап на вашем текущем проекте и нажмете стрелку назад в браузере — попап даже не закроется, а лишь изменится страница, которая под ним находится.
Пользователи явно на это не рассчитывали. Особенно пользователи андроид-смартфонов, которым отобразили попап на весь экран, а они пытаются вернуться на предыдущий экран с помощью стрелки назад в телефоне. Они не понимают, что надо сначала закрыть попап, а уже потом пользоваться стрелкой назад в телефоне тупицы.
Шаринг ссылок на модальные окна
Вторая проблема, или, правильнее, недостаток — нельзя поделиться ссылкой именно на попап с другим пользователем. А ведь иногда нужно именно это.
Эта штука неплохо работает в web-версии Твиттера. Вы входите и видите смешную картинку, она как раз открывается в попапе. Если вы скопируете URL-адрес и поделитесь им, то получатель увидит и у кого вы взяли картинку, и в рамках какого твита, и саму картинку — все восстановится, как было, и это действительно удобно.
Обновление страницы
Третья ситуация аналогична второй. У всех проблемы с восстановлением попапа после перезагрузки страницы. Это решаемая проблема, мы можем сохранить стек попапов в LocalStorage или SessionStorage. Но кому нужны дополнительные заботы, если есть решения без этих хлопот?
Коммуникация с попапом
Четвертая сложность — это общение между попапом, который лежит внутри попап-менеджера, и непосредственной страницей. К примеру, вы решили изменить банковскую карту, привязанную к аккаунту мобильного приложения. При нажатии отправки формы появляется диалог, который просит вас ввести пароль от аккаунта, и только после ввода пароля вся форма отправляется на сервер, где включены все данные — как введенные на странице, так и пароль из попала.
Я такие попапы начал называть контекстными, поскольку они не имеют смысла существования без определенного контекста, в нашем случае — страницы ввода банковских данных. А это значит, что попапу ввода пароля нужно общаться со страницей, но как сделать это удобно, если страница хранится в одном месте, а модальное окно — где-то в глобальном менеджере попапов? В таком случае нужно передать либо какой-то callback в ваш стор, или хранить какие-то флаги, отображающие текущую информацию, в том же сторе. Опять сложности.
Отображение одновременно двух попапов
Switch-case конструкция позволяет отобразить лишь один попап одновременно. Но что, если вам нужно в первом попапе совершить какое-то действие, которое требует подтверждения — вроде "Вы уверены, что хотите удалить пользователя из группы?". Такого рода подтверждения чаще всего также реализуются через попап, похожий на системный confirm-диалог. В итоге, если вы используете switch-case конструкцию, когда хотите удалить пользователя из группы, у вас "размонтируется" первый попап и будет виден только диалог с подтверждением, что и визуально выглядит крайне странно, и технически напряжно, ведь после согласия вам надо восстановить предыдущее состояние первого попапа. Снова приходится изворачиваться.
Конечно же, это не все сложности...
Есть и другие проблемы. К примеру, если использовать в качестве поддержки анимации закрытия попапа switch-case конструкцию, она мгновенно "вымаунтит" попап при изменении флага, поэтому надо что-то доизобретать, чтобы сначала произошла анимация закрытия, а уже после диалог "размаунтился". Или — а что, если вам надо, чтобы в URL-адресе попап никак не отображался, а стрелка назад в браузере могла с ним взаимодействовать?
Обо всем этом мы сегодня и поговорим.
Короткий спойлер
Мое альтер-эго создало короткую видео-выжимку с решениями, которые будут описаны в этой статье: "Управление попапами с помощью React-router", "Как управлять двумя и более попапами одновременно с помощью react-router". Сама статья покрывает более широкий спектр проблем, но если вам проще с видео — заходите, не стесняйтесь. А также я подготовил демку и GitHub, чтобы вы могли сами все потрогать и понять, как это работает на самом деле.
Gem4Me: начало
На проекте Gem4Me мы начинали борьбу примерно с той же исходной точки, а именно — со switch-case конструкции. Партией было принято решение для начала переписать попап-галерею медиа-файлов.
Что есть галерея
Как и у любого другого мессенджера, у нас есть чат с нашими сообщеньками (открывается по URL /chats/:id). В этих сообщеньках, кроме текстовых, часто мелькают разные медиа-файлы: мемасики, гифки, видео и др. Для быстрого поиска мемасика мы можем открыть в отдельном попапе только медиа-файлы, сгрупированные по дате и динамически подгружаемые по скролу

Этот попап для нас является контекстным, т.к. он не имеет смысла без экрана чата. Поэтому мы решили использовать роутер для отображения этого попапа, а именно следующий URL /chats/:id/gallery. Таким образом, попап будет отображаться только если перед ним находится /chats/:id, а это гарантирует наличие открытого чата.
Имплементация галереии
У нас есть глобальный роутер, в котором есть чат:
<Route path="/chats/:id">
  <Chat />
</Route>

Далее внутри компонента чата мы можем вставить непосредственно сам попап:
...
const Chat = ({ ... }) => {
  return (
    <>
      ...
      <Route path="/chat/:id/gallery">
        <OverlayPopup isOpened onClose={onClose}>
          <Gallery />
        </OverlayPopup>
      </Route>
    </>
  )
}

Это и значит контекстный попап — когда сам попап является частью той страницы, которой он принадлежит. React-router уже начиная с версии 4 позволяет делать вложенные роуты, что для нас очень удобно. Таким образом, когда срабатывает /chats/25565465, открывается определенный чат, а чтобы открыть попап, нам нужно лишь создать ссылку:
<Link to={`/chat/${id}/gallery`}>
  Shared Media
</Link>

При нажатии на ссылку URL снова меняется, и теперь срабатывает как родительский Route, который отображает чат, так и вложенный роут, который отображает галерею.
Особенности переменной match в react-router
Хочу обратить внимание на интересный момент с переменной match. Она отвечает за совпадение атрибута path ближайшего родителя Route. К примеру, у нас текущий url-адрес /chats/25565465/gallery. Если мы сделаем console.log(match.url) именно внутри компонента чата, то результат будет /chats/25565465, т.к. path ближайшего родительского Route равняется path="/chats/:id". С другой стороны, если вы добавите лог console.log(match.url) внутри галереи при том же url-адресе, результат будет /chats/25565465/gallery, т.к. ближайший path является path="/chats/:id/gallery".
Данное свойство очень интересно и его можно использовать для построения относительных путей. К примеру, для генерации ссылки:
<Link to={`${match.url}/gallery`}>
  Shared Media
</Link>

Или для создания вложенных роутов, что улучшит переиспользуемость вложенного роута:
...
const Chat = ({ ... }) => {
  return (
    <>
      ...
      <Route path={`${match.url}/gallery`}>
        <OverlayPopup isOpened onClose={onClose}>
          <Gallery />
        </OverlayPopup>
      </Route>
    </>
  )
}

У такого подхода есть как преимущества, так и недостатки. Из недостатков — теперь /:id/ больше не приходит как параметр, т.к. там вписана конкретная цифра. С другой стороны, у нас есть не только чаты, но и каналы, соответственно мы можем использовать при таком подходе этот компонент как для /chats/2544346/gallery, так и для /channels/3245353/gallery. Что также является для нас преимуществом, т.к. в данный момент чат и канал используют один и тот же компонент из-за своей схожести.
Но вообще react-router команда обещала в 6 версии библиотеки предоставить более удобные вложенные роуты. Что, возможно, поможет устранить все недостатки. А пока радуемся тому, что уже можно использовать.
Закрытие попапа
Мы научились открывать попап с помощью перехода по ссылке, но как его закрыть? Первая мысль — использовать функцию history.goBack(), которая просто вернет Вас на предыдущий URL-адрес и попап закроется, среагировав на изменения в URL. Но у данного подхода есть неприятный кейс. Если вы скопировали ссылку на попап и открыли в новой вкладке или сбросили товарищу, то вы не сможете закрыть попап, оставшись на сайте, так как history.goBack привязывается именно к истории таба браузера, т.е. вместо закрытия попапа по нажатию на крестик вы вернетесь на предыдущий экран, к примеру — на домашнюю страницу вашего браузера.
Это не значит, что нет вариантов, как использовать history.goBack для закрытия попапа. АйТи Синяк создал целое отдельное видео "Как закрыть попап используя history.goBack" посвященное разным стратегиям при закрытии попапа.
Стратегия, которую используем мы, самая простая для реализации. Обертываем крестик ссылкой:
<Link to={match.url.replace('/gallery', '')}>
  <CrossIcon />
</Link>

Да, это, конечно, упрощенная реализация, но идея максимально приближена к боевой. В текущей переменной match содержится /chats/23445345/gallery. Все, что нам нужно — удалить gallery в конце URL-адреса, и такая реализация позволяет не заботиться о том, что сейчас открыто у пользователя — чат или канал.
Промежуточные итоги галереи
Давайте подытожим, что у нас получилось:
  • Попап галереи открывается и закрывается в зависимости от URL-адреса, следовательно, стрелка назад в браузере, а также на андроид-смартфоне, действительно будет закрывать попап;
  • Решены проблемы с перезагрузкой страницы и шаринг ссылки, т.к. все восстановится по URL-адресу, без каких-либо дополнительных инструментов;
  • Из-за того, что попап находится непосредственно внутри компонента чата, мы можем в виде привычных нам props передавать абсолютно любые данные и колбеки, что избавляет от изобретения велосипедов;
  • Также мы обезопасили себя от ситуации, в которой компонент Chat по каким-либо причинам размонтирован, а следовательно, об отображении попапа галереи не может быть и речи;
  • В случае удаления чата или канала мы с компонентом сразу же удалим и попап, который лежит внутри папки компонента. Ведь часто бывает, что страницу удалили, а попап, принадлежащий этой странице, так и остался в менеджере.

Сон 2-го уровня
Итак, мы решили первую задачку, но тут же прилетела вторая. По нажатию на любую картинку в галерее должна открыться галерея с просмотром изображения на весь экран. Выглядит это примерно так:

URL, которому соответствует галерея — /chats/:id/gallery/:fileId. А это значит, что у нас опять полное взаимодействие с браузерными контролами, и все так же восстановится после перезагрузки страницы.
Еще один интересный нюанс: несмотря на то, что галерея не прозрачна, под ней все еще находятся сам чат и попап галереи. Это удобно для нас, т.к. нам не нужно восстанавливать состояние того же попапа галереи. Только представьте ситуацию: вы долго скролили вниз, картинки подгружались, и тут вы случайно вы нажали на ненужную картинку, она открылась на весь экран, а после ее закрытия весь ваш прогресс исчез. В такой момент хочется перестать пользоваться таким мессенджером. Но эта история — не про нас! :)
По вложенности, как вы уже догадались, попап изображения на весь экран находится прямо внутри попапа галереи. Это гарантирует их существование только в дуэте.
Глобальные попапы
С контекстными попапами, принадлежащими конкретным страницам или компонентам, идея, думаю, стала понятной. Но есть попапы, которые могут быть показаны на любой странице. Мы их называем глобальными. Ярким примером для многих проектов может служить попап Login. В нашем случае это, к примеру, попап личных настроек, вот так он выглядит:

В итоге мы не можем использовать pathname в URL-адресе, т.к. страница может быть любой. Поэтому мы решили использовать GET-параметры. К примеру, URL-адрес при открытии попапа выглядит следующим образом: /chats/34454356?popup=user-settings.
Как вы уже поняли, основная схема заключается в том, что GET-параметр popup будет принимать значение, какой именно попап сейчас отобразить. В данном случае это user-settings.
Имплементация Менеджера Попапов
Идея такого менеджера попапов очень похожа на классическую, только нужно следить за GET-параметром, а не за значением в вашем Redux сторе или где-то еще. Поэтому для начала напишем custom hook для извлечения значения из GET параметров по имени:
/* global URLSearchParams */
import { useLocation } from 'react-router-dom';
export default (name) => {
    const { search } = useLocation();
    const query = new URLSearchParams(search);
    return query.get(name);
};

Для этого используем custom hook от react-router useLocation. В итоге мы можем получить search, который выглядит примерно следующим образом: ?popup=user-settings. Чтобы не парсить руками строку, используем браузерное API URLSearchParams.
У нас уже есть имя попапа, нужно по нему получить компонент. На самом деле, можно использовать даже ту же switch case конструкцию. Но мы используем немного более современную конструкцию:
const popups = {
    'user-settings': UserSettingsPopup,
    'create-group' CreateGroup,
    'bots-catalog', BotsCatalog,
    ...
}
const PopupManager = () => {
    const popupName = useGetParameter('popup');
    const Component = popups[popupName];
    if (!Component) {
        return null;
    }
    return <Component />;
};

Реализация ничем особо не отличается от обычного switch. Но более читабельна, на мой взгляд.
Анимация закрытия
И этот менеджер на самом деле можно еще улучшать. К примеру, нам нужно поддерживать анимацию закрытия попапа. Тогда стоит просто немного доработать:
const PopupManager = () => {
    const { popupName, isOpened } = useGetPopupState();
    const Component = popups[popupName];
    if (!Component) {
        return null;
    }
    return <Component isOpened={isOpened} />;
};

Все, что нам нужно — из одного параметра, отвечающего за отображение попапов, сделать два. Поэтому создадим свой hook, который будет возвращать 2 параметра: isOpened будет отвечать за то, отображается ли попап пользователю, а popupName будет отвечать, пора ли "размонтировать" попап. Т.е. по факту мы должны сохранить значение попапа в useState и изменять его с задержкой на время анимации закрытия попапа.
Реализацию этого хука с setTimeout уже оставлю на самостоятельные эксперименты, если совсем уж тяжко — можете подсмотреть тут.
2 попапа одновременно
Можно возвращать и 2 попапа, у нас есть такой случай: в каталоге ботов, который на весь экран, можно открыть еще один попап — с конкретным ботом. Немного поколдуем над менеджером:
const PopupManager = () => {
  const { mountedPopups, popups } = useGetPopupsState();
  return mountedPopups.map((mountedPopup) => {
    const Component = mappedPopups[mountedPopup];
    if (!Component) {
      return null;
    }
    return (
      <Component key={mountedPopup} isOpened={popups.includes(mountedPopup)} />
    );
  });
};

По факту ничего не изменится, только придется работать с массивами, а не с конкретными значениями. Сейчас у нас один массив отвечает за то, должен ли попап отображаться пользователю, а второй массив — за то, какие из попапов нужно вмонтировать в наше приложение. Думаю, идею вы уловили. Опять же, если нужны детали, обратитесь сюда.
Заключение
Конечно, это не все трюки с попапами. Есть еще попапы, которые хотелось бы контролировать с помощью роутера, но не хотелось бы, чтобы на них можно было сбросить ссылку товарищу, но при этом неплохо было бы, чтобы они восстанавливались при обновлении страницы. Да, это возможно, и react-router с этой задачей прекрасно справился! Есть и другие интересные эксперименты, если интересно — могу написать еще одну статью о том, какие еще странные случае приходится обрабатывать.
А сейчас обращаюсь к тебе, читатель. Присоединяйся к борьбе! Прикрути хороший качественный попап на сайте, не пожалей на это свое время, уговори менеджера, что это действительно важно! Пользователи скажут тебе спасибо. Нам уже сказали ;-)
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_reactjs, #_reactjs, #_popup, #_modal_dialog, #_modal, #_reactrouter, #_dialog, #_javascript, #_reactjs
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 13:57
Часовой пояс: UTC + 5