Хабрахабр

ReactiveX Redux

Все, кто работает с Redux, рано или поздно сталкиваются с проблемой асинхронных действий. Но современное приложение разработать без них невозможно. Это и http-запросы к бэкенду, и всевозможные таймеры/задержки. Сами создатели Redux говорят однозначно — по умолчанию поддерживается только синхронный data-flow, все асинхронные действия необходимо размещать в middleware.

На помощь всегда приходят библиотеки и фреймворки, такие как Thunk, Saga и им подобные. Конечно, это слишком многословно и неудобно, поэтому тяжело найти разработчика, который пользуется одними только “нативными” middleware.

Но что если нужна чуть более сложная логика, чем отправить один запрос или сделать один таймер? Для большинства задач их вполне хватает. Вот небольшой пример:

async dispatch => ); }); } catch (error) { dispatch({ type: 'FAILED', error }); } }, 2000);
}

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

ОК, перепишем на саги: Меня зовут Дмитрий Самохвалов, и в этом посте я расскажу, что такое концепция Observable и как применять её на практике в связке с Redux, а еще сравню всё это с возможностями Redux-Saga.
Как правило, в таких случаях берут redux-saga.

try { yield call(delay, 2000); const [respOne, respTwo] = yield [ call(fetchOne), call(fetchTwo) ]; yield put({ type: 'SUCCESS', respOne, respTwo });
} catch (error) { yield put({ type: 'FAILED', error }); }

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

Это именно подход, а не просто очередная библиотека для написания асинхронного кода. Есть и другой подход. Воспользуемся им и перепишем пример на Observable: Он называется Rx (они же Observables, Reactive Streams и т.п.).

action$ .delay(2000) .switchMap(() => Observable.merge(fetchOne, fetchTwo) .map(([respOne, respTwo]) => ({ type: 'SUCCESS', respOne, respTwo })) .catch(error => ({ type: 'FAILED', error }))

Код не просто стал плоским и уменьшился в объеме, изменился сам принцип описания асинхронных действий. Теперь мы не работаем непосредственно с запросами, а выполняем операции над специальными объектами под названием Observable.

У Observable есть три основных состояния — next (“отдай следующее значение”), error (“произошла ошибка”) и complete (“значения закончились, отдавать больше нечего”). Observable удобно представлять как функцию, которая отдает поток (последовательность) значений. Обернуть в Observable можно все что угодно — таймауты, http-запросы, DOM-события, просто js объекты. В этом плане он немного напоминает Promise, но отличается тем, что по этим значениям можно итерироваться (и в этом одна из суперспособностей Observable).

Оператор — это функция, которая принимает и возвращает Observable, но производит какие-то действия над потоком значений. Второй суперсилой Observable являются операторы. Ближайшая аналогия — map и filter из javascript (кстати, такие операторы есть в Rx).

На их примере легче всего объяснить работу операторов. Наиболее полезными лично для меня были операторы zip, forkJoin и flatMap.

Оператор zip работает очень просто — он принимает на вход несколько Observable (не более 9) и возвращает в виде массива значения, которые они испускают.

const first = fromEvent("mousedown");
const second = fromEvent("mouseup"); zip(first, second) .subscribe(e => console.log(`${e[0].x} ${e[1].x}`)); //output
[119,120]
[120,233]

В общем виде работу zip можно представить схемой:

Он очень полезен при работе с DOM-событиями. Zip используется, если у вас есть несколько Observable и вам необходимо согласованно получать от них значения (при том, что они могут испускаться с разными интервалами, синхронно или нет).

Оператор forkJoin похож на zip за одним исключением — он возвращает только последние значения от каждого Observable.

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

Еще нагляднее в коде:

const observable = of("Hello"); const promise = value => new Promise(resolve => resolve(`${value} World`); observable .flatMap(value => promise(value)) .subscribe(result => console.log(result)); //output "Hello World"

Наиболее часто flatMap используется в запросах к бэкенду, наряду со switchMap и concatMap.
Каким же образом можно использовать Rx в Redux? Для этого есть замечательная библиотека redux-observable. Ее архитектура выглядит так:

Каждый epic принимает на вход action, оборачивает его в Observable и должен вернуть action, также в виде Observable. Все Observable, операторы и действия над ними оформляются в виде специального middleware, который называется epic. Напишем небольшой epic, который делает запрос к апи. Возвращать обычный action нельзя, это создает бесконечный цикл.

const fetchEpic = action$ => action$ .ofType('FETCH_INFO') .map(() => ({ type: 'FETCH_START' })) .flatMap(() => Observable .from(apiRequest) .map(data => ({ type: 'FETCH_SUCCESS', data })) .catch(error => ({ type: 'FETCH_ERROR', error })) )

Невозможно обойтись без сравнения redux-observable и redux-saga. Многим кажется, что они близки по функциональности и возможностям, но это совсем не так. Саги — целиком императивный инструмент, по сути набор методов для работы с сайд-эффектами. Observable это принципиально другой стиль написания асинхронного кода, если хотите, другая философия.

Я написал несколько примеров для иллюстрации возможностей и подхода к решению задач.

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

while(true) { const timer = yield race({ stopped: take('STOP'), tick: call(wait, 1000) }) if (!timer.stopped) { yield put(actions.tick()) } else { break }
}

Теперь используем Rx:

interval(1000) .takeUntil(action$.ofType('STOP'))

Допустим, есть задача реализовать запрос с отменой на сагах:

function* fetchSaga() { yield call(fetchUser);
} while (yield take('FETCH')) { const fetchSaga = yield fork(fetchSaga); yield take('FETCH_CANCEL'); yield cancel(fetchSaga);
}

На Rx все проще:

switchMap(() => fetchUser()) .takeUntil(action$.ofType('FETCH_CANCEL'))

Напоследок мое любимое. Реализовать запрос к апи, в случае неудачи сделать не более 5 повторных запросов с задержкой в 2 секунды. Вот что имеем на сагах:

for (let i = 0; i < 5; i++) { try { const apiResponse = yield call(apiRequest); return apiResponse; } catch (err) { if(i < 4) { yield delay(2000); } } } throw new Error(); }

Что получится на Rx:

.retryWhen(errors => errors .delay(1000) .take(5))

Если суммировать плюсы и минусы саги, получится такая картина:

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

Совсем другая ситуация у Rx:

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

Иначе можно наткнуться на неочевидные ошибки или неопределенное поведение. Кроме того, при работе с Observable особенно важно быть внимательным и всегда хорошо понимать, что происходит.

action$ .ofType('DELETE') .switchMap(() => Observable .fromPromise(deleteRequest) .map(() => ({ type: 'DELETE_SUCCESS'})))

Однажды я написал epic, который делал довольно простую работу — при каждом action с типом ‘DELETE’ вызывался метод API, который производил удаление элемента. Однако при тестировании возникли проблемы. Тестировщик жаловался на странное поведение — иногда при нажатии на кнопку удаления не происходило ничего. Оказалось, что оператор switchMap поддерживает выполнение только одного Observable в момент времени, своего рода защита от race condition.

В качестве итога приведу несколько рекомендаций, которым следую сам и призываю следовать всем, кто начинает работу с Rx:

  • Будьте внимательны.
  • Изучайте документацию.
  • Проверяйте в sandbox.
  • Пишите тесты.
  • Не стреляйте из пушки по воробьям.
Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть