Станислав Быков
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, состоящий из двух методов:
Метод принимает id пользователя (userId) и возвращает массив id библиотек (libraryIds), в которые он записан.
Метод принимает массив 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:
Модульное тестирование (Unit Testing). Saga — это функция-генератор, где все сайд-эффекты запускаются библиотекой и выполняются «под капотом», а каждый запуск возвращает эффект (call, put и т.д.). Поэтому в тестах нам надо только «идти» по саге шаг за шагом и сравнивать полученный эффект с ожидаемым. Главным недостатком такого подхода является то, что мы зависим от реализации, и при каком-либо изменении в коде тесты перестанут работать.
Интеграционное тестирование (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, какие задачи можно решать с её помощью и как легко покрывать код саг тестами.
Надеюсь, что данной информации для вас будет достаточно для принятия решения о том, будет ли полезной эта библиотека на своем проекте или наоборот, излишней, а возможно и недостаточной.