Sagи из жизни +15


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


У вас тоже есть знакомый 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 просторах!




К сожалению, не доступен сервер mySQL