Станислав Быков

Frontend разработчик в компании Usetech

Сегодня я бы хотел рассказать о библиотеке redux-saga. Она уже достаточно давно используется во frontend-программировании, но не является интуитивно понятной, что может помешать начинающим разработчикам освоить её быстро и начать применять в своих проектах. В данной статье я максимально просто постараюсь объяснить максимально основные принципы этой технологии и некоторые полезные возможности. Намеренно отказываюсь от сравнительного анализа в пользу одних либо других технологий, т.к. выбор — это личное дело каждого, но чтобы его сделать, необходимо обладать определёнными знаниями.

В статье используются специализированные термины, поэтому предполагается, что вы имеете общее представление о React, Redux, генераторах и итераторах из ES6.

Из официальной документации следует, что redux-saga — это библиотека, которая ориентирована на упрощение и улучшение работы с сайд-эффектами (side-effects, любыми взаимодействиями с внешней средой, например, запрос на сервер) и облегчение их тестирования. В redux сага — это middleware (слой, работающий с момента диспатча (dispatch) экшена (action) и до обработки его редьюсером (reducer)), который может запускаться, останавливаться и отменяться из основного приложения с помощью обычных действий redux. Библиотека использует такое понятие ES6 как генераторы (Generators), и благодаря этому наши асинхронные потоки выглядят как обычный синхронный код.

Концепция Redux-saga

Технология redux-saga основана на использовании двух типов саг — наблюдатель (watcher saga) и исполнитель (worker saga).

  • watcher saga — «прослушивает» задиспатченные экшены, при появлении необходимого запускает обработчик этого экшена (worker saga);

  • worker saga — непосредственно выполняет нужный код.

Считается, что самое подходящее место для сайд-эффектов — это action. Рассмотрим простой пример слоя action для отправки запроса на сервер с помощью redux-thunk:

const actionRequestStarted = () => ({
  type: 'REQUEST_DATA_STARTED',
});
const actionRequestSuccess = (data) => ({
  type: 'REQUEST_DATA_SUCCESS',
  data,
});
const actionRequestFailed = (error) => ({
  type: 'REQUEST_DATA_FAILED',
  error,
});
const fetchData = (url) => {
  (dispatch) => {
    dispatch(actionRequestStarted());
    fetch(url)
      .then((response) => response.json())
      .then((data) => dispatch(actionRequestSuccess(data)))
      .catch((error) => dispatch(actionRequestFailed(error)))
  };
};

Данный код не сложный как для понимания, так и для тестирования (один запрос замокать не проблема). Но что будет, если сайд-эффектов будет больше? Их все необходимо будет «затыкать» подставными данными, что не очень удобно.

Библиотека Redux-saga предлагает перенести всю бизнес-логику и сайд-эффекты в слой саг. Посмотрим как fetchData будет выглядеть, если переписать его на саги — теперь это обычный синхронный action creator:

const actionRequestData = (url) => ({
  type: 'REQUEST_DATA',
  url,
});

В слое саг определим watcher saga, которая будет наблюдать за отправленными экшенами:

function* fetchDataWatcherSaga() {
  yield takeEvery('REQUEST_DATA', fetchDataWorkerSaga);
}

Мы использовали вспомогательную функцию takeEvery, которая на каждый задиспатченный экшен 'REQUEST_DATA' запускает обработчик fetchDataWorkerSaga. Если будет не один одновременный запуск, то они все запустятся параллельно. У библиотеки есть ещё несколько основных вспомогательных функций: takeLeading и takeLatest, которые обрабатывают только один экшен — первый и последний задиспатченный, соответственно. Один из них мы рассмотрим позже.

Worker saga будет выглядеть так:

function* fetchDataWorkerSaga(action) {
  yield put(actionRequestStarted());
  try {
    const data = yield call(fetch, action.url);
    yield put(actionRequestSuccess(data));
  } catch (error) {
    yield put(actionRequestFailed(error));
  }
}

В качестве аргумента в сагу передается объект экшена, из которого мы можем извлечь необходимые нам данные — url. Т.к. сага — это генератор, то она может себя приостанавливать. Данный код при запуске дойдёт до первого ключевого слова yield и выполнит put(actionRequestStarted()), далее дойдёт до следующего yield, запустит выполнение call и приостановится. Как только call выполнится, то сага вернёт это значение. Далее, если запрос завершится удачно, то сага дойдёт до следующего yield и выполнится put(actionRequestSuccess(data)). Если во время запроса произойдут ошибки, то будет выполнен put(actionRequestFailed(error)). Call и put — это ещё одни вспомогательные функции библиотеки redux-saga. Рассмотрим их подробнее:

  • call(fn, …args) — функция вызова других функций (fn) с аргументами args. В качестве fn могут быть как синхронные, так и асинхронные, возвращающие Promise, а также являющиеся функцией-генератором. Call является блокирующей функцией, т.е. она приостанавливает сагу до своего завершения.

  • fork(fn, …args) — так же как и call является функцией вызова функции, но с тем отличием, что она не является блокирующей. Т.е. после запуска fork(...) сразу выполняется следующая строка кода, а не дожидается результата её выполнения.

  • put(action) — функция отправки action, её можно воспринимать как dispatch(action). Является блокирующей.

  • select(selector, …args) — функция получения актуального значения из state. Selector — функция, которая принимает актуальный state, а возвращает нужную его часть. Вызов select без аргументов (yield select()) можно воспринимать как store.getState().

  • take(pattern) — приостанавливает выполнение саги и прослушивает выполняемые actions до тех пор, пока action, соответствующий pattern, не будет задиспатчен. После этого возобновляет выполнение саги. Является блокирующей.

Передача выполнения в другую сагу

Разберём небольшой практический пример — у нас есть сервис городских библиотек (LibraryAPI), который предоставляет API, состоящий из двух методов:

  1. Метод принимает id пользователя (userId) и возвращает массив id библиотек (libraryIds), в которые он записан.

  2. Метод принимает массив id библиотек (libraryIds), а также жанр произведения (genre) и возвращает массив книг нужного жанра, которые доступны для чтения в данных библиотеках.

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

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

/* booksActions.js */
const actionRequestBooks = (libraryIds, genre) => ({
  type: 'REQUEST_BOOKS',
  libraryIds,
  genre
});
const actionRequestBooksComplete = (books) => ({
  type: 'REQUEST_BOOKS_COMPLETE',
  books,
});
/* booksSaga.js */
function* fetchBooksWatcherSaga() {
  yield takeEvery('REQUEST_BOOKS', fetchBooksWorkerSaga);
}
function* fetchBooksWorkerSaga(action) {
  /* Считаем что обработка ошибок осуществляется внутри LibraryAPI */
  const data = yield call(LibraryAPI.fetchBooks, action.libraryIds, action.genre);
  yield put(actionRequestBooksComplete(data));
}

Теперь перейдём к описанию основной саги:

/* librariesActions.js */
const actionRequestData = (userId, genre) => ({
  type: 'REQUEST_DATA',
  userId,
  genre,
});
/* librariesSaga.js */
function* fetchDataWatcherSaga() {
  yield takeEvery('REQUEST_DATA', fetchDataWorkerSaga);
}

function* fetchDataWorkerSaga(action) {
  const librariesIds = yield call(LibraryAPI.fetchLibraries, action.userId); /* (1) */
  yield put(actionRequestBooks(librariesIds, action.genre)); /* (2) */
  const booksAction = yield take('REQUEST_BOOKS_COMPLETE'); /* (3) */
  yield fork(LogData, formatLoggedData(action.userId, action.genre, librariesIds, booksAction.books)); /* (4) */
}

В worker saga первым действием мы получаем массив id всех доступных библиотек, далее (2) мы диспатчим экшен, который запускает выполнение booksSaga. Наша сага в это время приостанавливается, дожидаясь, пока не задиспатчится экшен 'REQUEST_BOOKS_COMPLETE'. Далее работает сага получения списка книг, и после её завершения выполнение возвращается в нашу сагу (3). Последним действием (4) мы запускаем метод логирования (LogData), в который передаём отформатированные хелпером formatLoggedData данные.

Выполнение первого action из всех

Рассмотрим другой пример из практики — у нас есть дашборд (dashboard), на котором отображается множество элементов меню. Часть из них должны быть доступны для пользователей с определёнными правами (у пользователя может быть несколько разных прав).

Для реализации этой задачи обернём такие элементы меню в компонент RightRequiredWrap. В него передаётся children и right (тип прав, для которых необходимо отображать children). Список прав пользователя хранится в сторе (store) и, если на данный момент права еще не загружены, то необходимо их запрашивать.

Реализация этого компонента-обёртки может выглядеть так:

import { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
/* функция-хелпер, проверяющая наличие значения в массиве */
import { isValueInArray } from '/helpers';
export const RightRequiredWrap = ({ right, children }) => {
  const { userRights, loadingStatus } = useSelector((store) => store.rights);
  const userId = useSelector((store) => store.userId);
  const dispatch = useDispatch();
  useEffect(
    () => {
      if (loadingStatus === PENDING && !userRights.length) {
        dispatch(actionRequestUserRights(userId));
      }
    },
    [loadingStatus, userRights.length, userId, dispatch],
  );
  if (!isValueInArray(right, userRights)) {
    return null;
  }
  return children;
};

Если использовать стандартную реализацию (на каждый задиспатченный экшен срабатывает обработчик), мы получим множество одинаковых запросов на сервер для получения прав пользователя. Чтобы это избежать мы можем воспользоваться вспомогательной функцией takeLeading.

function* fetchUserRightsWatcherSaga() {
  yield takeLeading('REQUEST_USER_RIGHTS', fetchUserRightsWorkerSaga);
}

После получения первого экшена сага запускает выполнение fetchUserRightsWorkerSaga и игнорирует остальные до окончания своего выполнения (до загрузки прав пользователя). Запрос на сервер будет только один. Другими словами, сага прослушивает экшены, пока она не выполняется.

Тестирование redux-saga

Рассмотрим 2 пути тестирование redux-saga:

  1. Модульное тестирование (Unit Testing). Saga — это функция-генератор, где все сайд-эффекты запускаются библиотекой и выполняются «под капотом», а каждый запуск возвращает эффект (call, put и т.д.). Поэтому в тестах нам надо только «идти» по саге шаг за шагом и сравнивать полученный эффект с ожидаемым. Главным недостатком такого подхода является то, что мы зависим от реализации, и при каком-либо изменении в коде тесты перестанут работать. 

  2. Интеграционное тестирование (Integration Testing). В данном случае мы запускаем выполнение саги и сравниваем полученные данные с ожидаемыми. Нас не интересует реализация, какой точный порядок эффектов. Нас интересует только конечный результат.

В документации к redux-saga разработчики рекомендуют использовать некоторые библиотеки для тестирования. Возьмём одну из предложенных — redux-saga-test-plan. В качестве тестируемой саги будем использовать fetchDataWorkerSaga первого примера:

function* fetchDataWorkerSaga(action) {
  yield put(actionRequestStarted());
  try {
    const data = yield call(fetch, action.url);
    yield put(actionRequestSuccess(data));
  } catch (error) {
    yield put(actionRequestFailed(error));
  }
}

Пройдем по саге по порядку выполнения эффектов с успешным выполнением fetch:

it ('unit test with success fetch', () => {
  testSaga(fetchWorkerSaga, { type: 'REQUEST_DATA', url: 'testUrl' })
  .next()
  .put({ type: 'REQUEST_DATA_STARTED' })
  .next()
  .call(fetch, 'testUrl')
  .next('testData')
  .put({ type: 'REQUEST_DATA_SUCCESS', data: 'testData' })
  .next()
  .isDone();
});

С помощью функции testSaga мы проходим по саге и проверяем порядок эффектов. В качестве аргумента передаем action, который запускает выполнение саги. Используя метод .next() мы переходим к следующему эффекту и можем как получать значение из саги, так и передавать в неё (.next('testData')).

Таким же образом мы пройдём по саге, но уже смоделируем ситуацию, когда при выполнении fetch возникает ошибка:

it ('unit test with error fetch', () => {
  const error = new Error('testError');

  testSaga(fetchWorkerSaga, { type: 'REQUEST_DATA', url: 'testUrl' })
  .next()
  .put({ type: 'REQUEST_DATA_STARTED' })
  .next()
  .call(fetch, 'testUrl')
  .next()
  .throw(error)
  .put({ type: 'REQUEST_DATA_FAILED', error })
  .next()
  .isDone();
});

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

Перейдём к интеграционному тестированию. Здесь нам не важен ни порядок эффектов, ни внутренняя реализация — только конечный результат. В нашем случае — это значение в state после выполнения саги. Добавим небольшой reducer:

/* reducer.js */
const initialState = {
  data: null,
  error: null,
  loadingStatus: null,
};
export const reducer = (state = initialState, action) => {
  switch(action.type) {
    case 'REQUEST_DATA_STARTED':
      return { ...state, loadingStatus: 'LOADING' }
    case 'REQUEST_DATA_SUCCESS':
      return { ...state, data: action.data, loadingStatus: 'LOADED' }
    case 'REQUEST_DATA_FAILED':
      return { ...state, error: action.error, loadingStatus: 'FAILED' }
    default: return state
  }
};

В рамках интеграционного тестирования также рассмотрим 2 варианта — успешное выполнение fetch и с появлением ошибки:

it('integration test with success fetch', async () => {
  const testSaga = expectSaga(fetchWorkerSaga, { type: 'REQUEST_DATA', url: 'testUrl' })
    .provide([
      [call(fetch, 'testUrl'), 'testData']
    ])
    .withReducer(reducer);
  const result = await testSaga.run();
  expect(result.storeState).toEqual({ data: 'testData', error: null, loadingStatus: 'LOADED' });
});

Методом expectSaga мы конструируем нашу сагу. Передадим в него тестируемую сагу (fetchWorkerSaga) и action, который запускает её выполнение ({ type: 'REQUEST_DATA', url: 'testUrl' }). С помощью .provide мы мокаем (mock) выполнение эффекта call (предоставляется библиотекой тестирования, не одно и то же что и call из redux-saga) с параметрами fetch и 'testUrl'. .withReducer добавляет использование reducer, вторым аргументом можно передать свое значение initialState. Далее мы запускаем сагу, дожидаемся результата, который хранит в себе значение state, и сравниваем его с ожидаемым.

Вариант с возникновением ошибки делаем аналогично:

it('integration test with error fetch', async () => {
  const error = new Error('testError');

  const testSaga = expectSaga(fetchWorkerSaga, { type: 'REQUEST_DATA', url: 'testUrl' })
    .provide([
      [call(fetch, 'testUrl'), throwError(error)]
    ])
    .withReducer(reducer);
  
  const result = await testSaga.run();
  expect(result.storeState).toEqual({ data: null, error, loadingStatus: 'FAILED' });
});

Итог

В данной статье я постарался максимально просто объяснить, что такое redux-saga, какие задачи можно решать с её помощью и как легко покрывать код саг тестами.

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

Комментарии (0)