Sagи из жизни

    Доброго времени суток.


    У вас тоже есть знакомый react-разработчик, который рассказывает восхитительные истории о сайд-эффектах в redux? Нет?! Могу я стать этим человеком?



    Автор взял на себя смелость не писать вводную часть о том, что же из себя представляет библиотека redux saga. Он надеется, что в случае недостаточности данных великодушный читатель воспользуется поиском хабра или официальным туториалом. Приводимые примеры во многом упрощены для передачи сути.


    Итак, для чего же я вас всех собрал. Речь пойдёт о применении redux saga на просторах боевых клиентов. А конкретнее о случаях более сложных и интересных чем "принять action => послать API запрос => создать новый action". 


    Надеюсь простимулировать более глубокое изучение данной библиотеки согражданами, а также поделиться удовольствием от того, как мудрёные асинхронные вещи становятся понятней и выразительней.


    WebSockets


    Use case: получение обновлений списка доступных вакансий от сервера в режиме реального времени по модели push.


    Речь идёт конечно же об использовании веб-сокетов. Для примера возьмём socket.io, но по факту API сокетов здесь не имеет значения.


    В сагах есть такое понятие как канал. Это шина сообщений через которую источник событий может общаться с их потребителем. Основное назначение каналов — общение между сагами и преобразование потока асинхронных событий в удобную для работы структуру. 


    По-умолчанию, store является основным каналом событий для redux saga. События приходят в виде action. Для работы с событиями не из store используются каналы.


    Получается канал как раз то, что нужно для работы с асинхронным потоком сообщений из сокета. Давайте же создадим канал как можно скорее!


    Но сперва создадим сокет:


    import io from 'socket.io-client';
    
    export const socket = io.connect('/');

    А теперь объявим скромный список событий:


    export const SocketEvents = {
      jobsFresh: 'jobs+fresh',
    };

    Далее — фабричный метод по созданию канала. Код создаёт метод для подписки на интересующие нас события из сокета, метод для отписки и, непосредственно, сам канал событий:


    import { eventChannel } from 'redux-saga';
    
    import { socket } from '../apis/socket';
    import { SocketEvents } from '../constants/socket-events';
    
    export function createFreshJobsChannel() {
      const subscribe = emitter => {    
        socket.on(SocketEvents.jobsFresh, emitter);
    
        return () => socket.removeListener(SocketEvents.jobsFresh, emitter);
      };
    
      return eventChannel(subscribe);
    }

    Напишем достаточно простую сагу, ждущую обновлений из сокета и преобразующую их в соответствующий action:


    import { take, call, put } from 'redux-saga/effects';
    
    import { createFreshJobsChannel } from '../channels/fresh-jobs';
    import { JobsActions } from '../actions/jobs';
    
    export function * freshJobsSaga() {
      const channel = yield call(createFreshJobsChannel);
    
      while (true) {
        const jobs = yield take(channel);
    
        const action = JobsActions.fresh(jobs);
    
        yield put(action);
      }
    }

    Осталось только привязать её к корневой саге:


    import { fork } from 'redux-saga/effects';
    
    import { freshJobsSaga } from './fresh-jobs';
    
    export function * sagas() {
      yield fork(freshJobsSaga);
    }

    Google Places Autocomplete


    Use case: показ подсказок при вводе пользователем географической локации для последующего поиска недвижимости поблизости.


    По сути нам нужны координаты, а пользователю человекочитаемое название желаемого района.


    Казалось бы, чем эта задача отличается от скучного "action => API => action"? В случае с автодополнением мы хотим делать как можно меньше бесполезных вызовов к внешним ресурсам, а также показывать пользователю только актуальные подсказки.


    Для начала напишем метод API утилизирующий Google Places Autocomplete Service. Из интересного здесь — ограничение подсказок в рамках заданной страны:


    export function getPlaceSuggestions(autocompleteService, countryCode, query) {
      return new Promise(resolve => {
    
        autocompleteService.getPlacePredictions({
          componentRestrictions: { country: countryCode },
          input: query,
        }, resolve);
    
      });
    }

    Есть метод API, который будем дёргать, можно приступать к написанию саги. Настало время пояснить за бесполезные запросы.


    Реализация в лоб, когда пользователь набирает текст, а мы на каждое изменение, читай — на каждый символ, отправляем запрос к API — ведёт в никуда. Пока пользователь печатает ему не нужны подсказки. А вот когда он останавливается — самое время ему услужить.


    Также мы бы не хотели оказаться в ситуация, когда у нас пользователь что-то напечатал, остановился, запрос к API ушёл, пользователь что-то допечатал, ещё один запрос ушёл.


    Таким образом у нас создалась гонка между двумя запросами. События могут развиваться двумя вариантами и оба не очень приятные.


    Например, неактуальный запрос завершиться раньше актуального и на какой-то миг пользователь увидит нерелевантные подсказки. Неприятно, но не критично.


    Или актуальный запрос завершиться раньше неактуального и после мигания пользователь останется всё с теми же нерелевантными подсказками. Вот это уже критично.


    Конечно же мы не первые кто сталкивается с такой проблемой и поможет нам техника известная как debounce  —  исполнение задачи не чаще чем в N единиц времени. Вот небольшой материал по этому поводу.


    В redux saga эта техника реализуется с помощью двух эффектов  — delay и takeLatest. Первый откладывает выполнение саги на указанное количество миллисекунд. Второй обрывает выполнение уже работающей саги при поступлении нового события.


    Зная всё это напишем сагу:


    import { delay } from 'redux-saga';
    import { put, call, select } from 'redux-saga/effects';
    
    import { PlaceActions } from '../actions/place';
    import { MapsActions } from '../actions/maps';
    
    import { getPlaceSuggestions } from '../api/get-place-suggestions';
    
    export function placeSuggestionsSaga * ({ payload: query }) {
      const { maps: { isApiLoaded } } = yield select();
    
      // если API гугл карт не загрузилось, 
      // то ждём события его загрузки
      if (!isApiLoaded) {
          yield take(MapsActions.apiLoaded);
      }
    
      // получаем код страны и Google Places Autocomplete из store
      const { maps: { autocompleteService }, countryCode } = yield select();
    
      // если пользователь всё стёр, 
      // то удаляем подсказки и выбранное ранее значение
      if (query) {
        yield put(PlaceActions.suggestions([]));
        yield put(PlaceActions.select(null));
        return;
      }
    
      // даём 250мс на допечатку запроса
      yield call(delay, 250);
    
      // вызываем API метод
      const suggestions = yield call(
        getPlaceSuggestions,
        autocompleteService,
        countryCode,
        query,
      );
    
      // создаём action с подсказками 
      const action = PlacesActions.suggestions(suggestions || []);
    
      // и посылаем его в store
      yield put(action);
    };

    Как и в прошлом примере осталось только привязать её к корневой саге:


    import { takeLatest } from 'redux-saga/effects';
    
    import { PlaceActions } from '../actions/place';
    import { placeSuggestionsSaga } from './place-suggestions';
    
    export function * sagas() {
      yield takeLatest(
        PlaceActions.changeQuery,
        placeSuggestionsSaga,
      );
    }


    Use case: закрытие самописных выпадающих списков при клике за территорией элемента управления.


    По факту, это эмуляция поведения встроенного в браузер select. Причины, по которым может понадобиться написанный на div’ах выпадающий список, оставлю для фантазии читателя.


    Ключевой особенностью решаемой задачи является прохождение события за пределами элемента управления, например, при клике вне списка.


    Догадались? Да, здесь нам тоже помогут каналы. С помощью них будем превращать события клика, всплывающие на самый верх, в соответствующий action.


    Хорошо бы иметь фабричный метод, создающий каналы для произвольного события window. А вот и он:


    import { eventChannel } from 'redux-saga';
    
    export function createWindowEventChannel(eventName) {
      const subscribe = emitter => {
        window.addEventListener(eventName, emitter);
    
        return () => window.removeEventListener(eventName, emitter);
      };
    
      return eventChannel(subscribe);
    }

    Создаём очень похожую на первый пример сагу (при желании можно создать фабричный метод для них):


    import { take, put, call } from 'redux-saga/effects';
    
    import { createWindowEventChannel } from '../channels/window-event';
    
    import { DropdownActions } from '../actions/dropdown';
    
    export function * closeDropdownsSaga() {
      const channel = yield call(createWindowEventChannel, 'onClick');
    
      while (true) {
        const event = yield take(channel);
    
        const action = DropdownActions.closeAll(event);
    
        yield put(action(event));
      }
    }

    Заинтересованные reducer’ы переведут элемент управление в закрытое состояние:


    import { handleActions } from 'redux-actions';
    
    import { DropdownActions } from '../actions/dropdown';
    
    export const priceReducer = handleActions({
      ...,
    
      [DropdownActions.closeAll]: state => ({ ...state, isOpen: false}), 
    }, {});

    Сам выпадающий список должен делать остановку распространения события клика на каких-либо внутренних частях и самостоятельно посылать событие закрытия в store. Например, при клике для открытия:


    // components/dropdown.js
    import React from 'react';
    
    export class Dropdown extends React.Component {
      ...
    
      __open(event) {
        event.stopPropagation();
    
        this.props.open();
      }
    }
    
    // dispatchers/open-price-dropdown.js
    import { DropdownActions } from '../actions/dropdown';
    import { PriceActions } from '../actions/price';
    
    export const openPriceDropdownDispatcher = dispatch => () => {
      dispatch( DropdownActions.closeAll() );
      dispatch( PriceActions.open() );
    };

    Иначе список просто не откроется. Тоже касается клика при выборе опции.


    El clasico, монтируем сагу:


    import { fork } from 'redux-saga/effects';
    
    import { closeDropdownsSaga } from './close-dropdowns';
    
    export function * sagas() {
      yield fork(closeDropdownsSaga);
    }

    Notifications


    Use case: показ браузерных уведомлений о доступности новых вакансий, в случае, если вкладка находится в фоне.


    В активной вкладке пользователь увидит изменение в специальном элементе управления и поэтому уведомления не уместны. А вот для фоновой вкладки может пригодиться. Разумеется, с разрешения пользователя!


    Ещё хотелось бы при клике на уведомление переходить во вкладку и показывать новые вакансии. Если пользователь не реагирует, то закрываем уведомление. Для этого нам понадобиться ещё один полезный эффект — race. Он позволяет устроить гонку между несколькими другими эффектами. В большинстве случаев race используется для обеспечения таймаута какой-либо операции.


    Опустим код, отслеживания активности вкладки, по причине идентичности с кодом перехвата клика из предыдущего примера.


    Напишем фабричный метод, который будет создавать канал для запроса одобрения от пользователя на получение уведомлений:


    import { eventChannel, END } from 'redux-saga';
    
    export function createRequestNotificationPermissionChannel() {
      const subscribe = emitter => {
        Notification.requestPermission(permission => {
          emitter(permission);
          emitter(END);
        });
    
        return () => {};
      };
    
      return eventChannel(subscribe);
    }

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


    import { eventChannel, END } from 'redux-saga';
    
    export function createNotificationClickChannel(notification) {
      const subscribe = emitter => {
        notification.onclick = event => { 
          emitter(event);
          emitter(END);
        };
    
        return () => notification.onclick = null;
      };
    
      return eventChannel(subscribe);
    }

    Оба канала одноразовые и максимум выстреливают одним событием, после чего закрываются.


    Осталось ключевое — сага с логикой. Проверяем активна ли вкладка, запрашиваем разрешение, создаём уведомление, ждём клика или таймаута, показываем новые вакансии, делаем вкладку активной, после чего закрываем уведомление:


    import { delay } from 'redux-saga';
    import { call, select, race, take } from 'redux-saga/effects';
    
    import { createRequestNotificationPermissionChannel } from '../channels/request-notification-permission';
    import { createNotificationClickChannel } from '../channels/notification-click';
    
    import { JobsActions } from '../actions/jobs';
    
    export function * notificationsSaga(action) {
      const { inFocus } = yield select();
    
      if (inFocus) return;
    
      const permissionChannel = yield call(createRequestNotificationPermissionChannel);
      const permission = yield take(permissionChannel);
    
      if (permission !== 'granted') return;
    
      const notification = new Notification(
        `You have ${action.payload.jobs.length} new job posts`,
        { icon: 'assets/new-jobs.png' }
      );
    
      const clickChannel = yield call(createNotificationClickChannel, notification);
    
      const { click, timeout } = yield race({
        click: take(clickChannel),
        timeout: call(delay, 5000),
      });
    
      if (click) {
        yield put(JobsActions.show());
    
        window.focus();
        window.scrollTo(0, 0);
      }
    
      notification.close();
    }

    Монтируем сагу перед этим сделав feature-detection:


    import { takeEvery } from 'redux-saga/effects';
    
    import { JobsActions } from '../actions/jobs';
    
    import { notificationsSaga } from './notifications';
    
    export default function * sagas() {
      if ( 'Notification' in window && Notification.permission !== 'denied' ) {
        yield takeEvery(JobsActions.fresh, notificationsSaga);
      }
    }

    Global Event Bus


    Use case: передача заданной категории событий между redux-сторами.


    Необходима такая шина в случае присутствия на странице нескольких приложений с общими данными. При этом приложения могут внедряться независимо друг от друга.


    Например, поисковая строка с фильтрами и результаты поиска в виде отдельных react-приложений. При изменении фильтров хочется, чтобы приложение результатов узнало об этом, если оно тоже находится на странице.


    Воспользуемся стандартным эмиттером событий:


    import EventEmmiter from 'events';
    
    if (!window.GlobalEventBus) {
      window.GlobalEventBus = new EventEmmiter();
    }
    
    export const globalEventBus = window.GlobalEventBus;

    Уже любимый eventChannel превращает стандартный эмиттер в канал:


    import { eventChannel } from 'redux-saga';
    import { globalEventBus as bus } from '../utils/global-event-bus';
    
    exports function createGlobalEventBusChannel() {
      const subscribe = emitter => {
        const handler = event => emitter({ ...event, external: true });
    
        bus.on('global.event', handler);
    
        return bus.removeListener('global.event', handler);
      };
    
      return eventChannel(subscribe);
    }

    Сага получается достаточно простая  — создаём канал и бесконечно принимаем события, либо внутренние, либо внешние. Если получили внутреннее событие, то отправляем в шину, если внешнее — в store:


    import { take, put, race, call } from 'redux-saga/effects';
    
    import { globalEventBus as bus } from '../utils/global-event-bus';
    
    import { createGlobalEventBusChannel } from '../channels/global-event-bus';
    
    export function * globalEventBusSaga(allowedActions) {
      allowedActions = allowedActions.map(x => x.toString());
    
      const channel = yield call(createGlobalEventBusChannel);
    
      while (true) {
        const { local, external } = yield race({
          local: take(),
          external: take(channel),
        });
    
        if (
          external 
          && allowedActions.some(action => action === external.type)
        ) {
          yield put(external);
        }
    
        if (
          local 
          && !local.external 
          && allowedActions.some(action => action === local.type)
        ) {
          bus.emit('global.event', local);
        }
      }
    };

    И финальное — монтирование саги с нужными событиями:


    import { fork } from 'redux-saga/effects';
    
    import { globalEventBusSaga } from './global-event-bus';
    
    import { DropdownsActions } from '../actions/dropdowns';
    import { AreasActions } from '../actions/areas';
    
    export function * sagas() {
      yield fork(globalEventBusSaga, [
        DropdownsActions.closeAll,
        AreasActions.add,
        AreasActions.remove,
        ...
      ]);
    }



    Надеюсь удалось показать, что саги делают описание комплексных сайд-эффектов проще. Исследуйте API библиотеки, перекладывайте на свои кейсы, сочиняйте сложные паттерны ожидания событий и будьте счастливы. Увидимся на JS просторах!

    • +16
    • 4,4k
    • 9
    Поделиться публикацией

    Комментарии 9

    • НЛО прилетело и опубликовало эту надпись здесь
        +2

        А каков ваш рецепт для эффективного фронта если серьёзно?

          0
          Reselect, recompose, redux-thunk, normalizr, а вот сага мне не нравится, ну не читабельны эти генераторы.
            +2
            Когда нужно сделать сложный, динамичный, юзабельный UI используют эти инструменты, которые позволяют сделать разработку проще и потом проще модифицировать. Хотя могут выглядеть сложно.
            +4

            Спасибо за интересный набор примеров! Правда, по некоторым из них остались вопросы:


            WebSockets

            Сейчас подписка на сокет активируется при инициализации стора. А что если на текущей странице никто эту подписку не использует? Как оформить сагу так, чтобы веб-сокет открывался по требованию?


            Google Places Autocomplete

            Сейчас есть безусловная задержка перед запросом. Если в приложении появится возможность изменять query программно (например, кликом по специальным кнопкам), то задержка после клика сделает UI менее отзывчивым.
            Как мне кажется, debounce должен жить на уровне конкретного UI-компонента, в котором известно, что события поступают чаще, чем нам нужно. На глобальном уровне это будет только мешать.


            Dropdowns Closer

            Здесь даже само название как бы намекает, что это должно быть частью UI. В чем смысл держать состояния открытости dropdown в глобальном сторе?


            Notifications, Global Event Bus

            А в этих примерах вроде все смотрится уместно, вопросов нет.

              +1
              Спасибо за вопросы.

              WebSockets
              В таком случае мы можем сделать монтирование саг динамическим с помощью эффектов fork и cancel. Поэтому можно перенести инициализацию сокета в фабричный метод по созданию канала (`createFreshJobsChannel`). Там уже по обстоятельствам — нам может понадобиться сокет как синглтон с ленивой инициализацией, либо уничтожение сокета при уничтожении канала.

              Google Places Autocomplete
              Вы правы. В таком случае я бы добавил какой-нибудь параметр в action и обернул в условие delay. Можно логику debounce написать в UI, я бы воспользовался HOC или новыми хуками для переиспользования кода. takeLatest оставить всё равно нужно.

              Dropdowns Closer
              Хотелось что бы это было частью UI. Только не могу придумать как эффективно и в переиспользуемой манере уведомлять все выпадающие списки о том, что им нужно закрыться. Без отдельной подписки на события window от каждого списка. Может у вас есть какой-нибудь способ на примете?
                +4
                А почему бы и не подписаться на window или document в момент открытия списка?
                  0
                  Звучит действительно хорошо. В скором времени планируем рефакторинг связанного участка кода — обязательно попробую. Спасибо!
              0
              ошибся тредом

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое