Любой redux разработчик расскажет вам, что одной из самых тяжелейших частей разработки приложений являются асинхронные вызовы — как вы будете обрабатывать реквесты, таймауты и другие коллбэки без усложнения redux действий(actions) и редьюсеров(reducers).

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

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

Генераторы действий (Action creators)


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



мы можем использовать Dog CEO API и что-то довольно простое вроде вызова fetch внутри генератора действия (action creator).

const {Provider, connect} = ReactRedux;
const createStore = Redux.createStore

// Reducer
const initialState = {
  url: '',
  loading: false,
  error: false,
};
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'REQUESTED_DOG':
      return {
        url: '',
        loading: true,
        error: false,
      };
    case 'REQUESTED_DOG_SUCCEEDED':
      return {
        url: action.url,
        loading: false,
        error: false,
      };
    case 'REQUESTED_DOG_FAILED':
      return {
        url: '',
        loading: false,
        error: true,
      };
    default:
      return state;
  }
};

// Action Creators
const requestDog = () => {
  return { type: 'REQUESTED_DOG' }
};

const requestDogSuccess = (data) => {
  return { type: 'REQUESTED_DOG_SUCCEEDED', url: data.message }
};

const requestDogaError = () => {
  return { type: 'REQUESTED_DOG_FAILED' }
};

const fetchDog = (dispatch) => {
  dispatch(requestDog());
  return fetch('https://dog.ceo/api/breeds/image/random')
    .then(res => res.json())
    .then(
      data => dispatch(requestDogSuccess(data)),
      err => dispatch(requestDogError())
    );
};

// Component
class App extends React.Component {
  render () {
    return (
      <div>
        <button onClick={() => fetchDog(this.props.dispatch)}>Show Dog</button>
          {this.props.loading 
            ? <p>Loading...</p> 
            : this.props.error
                ? <p>Error, try again</p>
                : <p><img src={this.props.url}/></p>}
      </div>
    )
  }
}

// Store
const store = createStore(reducer);

const ConnectedApp = connect((state) => {
  console.log(state);
  return state;
})(App);

// Container component
ReactDOM.render(
  <Provider store={store}>
    <ConnectedApp />
  </Provider>,
  document.getElementById('root')
);

jsfiddle.net/eh3rrera/utwt4dr8

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

Однако, использование только Redux не дает нам достаточно гибкости. Ядро Redux это контейнер состояния (state container), который поддерживает только синхронные потоки данных.

На каждое действие, в хранилище (store) посылается объект, описывающий что произошло, затем вызывается редюсер (reducer) и состояние (state) сразу обновляется.

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

Для этого Redux использует промежуточные слои (middlewares). Промежуточный слой это кусок кода, который выполняется после отправки действия, но перед вызовом редюсера.
Промежуточные слои могут соединяться в цепочку вызовов для различной обработки действия (action), но на выходе обязательно должен быть простой объект (действие)

Для асинхронных операций, Redux предлагает использовать redux-thunk промежуточный слой.

Redux-thunk


Redux-thunk является стандартным путем выполнения асинхронных операций в Redux.
Для нашей цели, redux-thunk вводит понятие преобразователь(thunk), что является функцией, которая предоставляет отложенное выполнение, по необходимости.

Возьмем пример из redux-thunk документации

let x = 1 + 2;

Значение 3 сразу присваивается переменной x.

Однако, если у нас есть выражение наподобие
let foo = () => 1 + 2;

То суммирование выполняется не сразу, а только при вызове функции foo(). Это делает функцию foo преобразователем(thunk).

Redux-thunk позволяет генератору действия (action creator) отправлять функцию в дополнении к объекту, конвертируя таким образом генератор действия в преобразователь.

Ниже, мы перепишем предыдущий пример используя redux-thunk

const {Provider, connect} = ReactRedux;
const {createStore, applyMiddleware} = Redux;
const thunk = ReduxThunk.default;

// Reducer
const initialState = {
  url: '',
  loading: false,
  error: false,
};
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'REQUESTED_DOG':
      return {
        url: '',
        loading: true,
        error: false,
      };
    case 'REQUESTED_DOG_SUCCEEDED':
      return {
        url: action.url,
        loading: false,
        error: false,
      };
    case 'REQUESTED_DOG_FAILED':
      return {
        url: '',
        loading: false,
        error: true,
      };
    default:
      return state;
  }
};

// Action Creators
const requestDog = () => {
  return { type: 'REQUESTED_DOG' }
};

const requestDogSuccess = (data) => {
  return { type: 'REQUESTED_DOG_SUCCEEDED', url: data.message }
};

const requestDogError = () => {
  return { type: 'REQUESTED_DOG_FAILED' }
};

const fetchDog = () => {
  return (dispatch) => {
    dispatch(requestDog());
    fetch('https://dog.ceo/api/breeds/image/random')
      .then(res => res.json())
      .then(
        data => dispatch(requestDogSuccess(data)),
        err => dispatch(requestDogError())
      );
  }
};

// Component
class App extends React.Component {
  render () {
    return (
      <div>
        <button onClick={() => this.props.dispatch(fetchDog())}>Show Dog</button>
          {this.props.loading 
            ? <p>Loading...</p> 
            : this.props.error
                ? <p>Error, try again</p>
                : <p><img src={this.props.url}/></p>}
      </div>
    )
  }
}

// Store
const store = createStore(
  reducer,
  applyMiddleware(thunk)
);

const ConnectedApp = connect((state) => {
  console.log(state);
  return state;
})(App);

// Container component
ReactDOM.render(
  <Provider store={store}>
    <ConnectedApp />
  </Provider>,
  document.getElementById('root')
);

jsfiddle.net/eh3rrera/0s7b54n4

На первый взгляд он не сильно отличается от предыдущей версии.

Без redux-thunk



С redux-thunk



Преимуществом использования redux-thunk является то, что компонент не знает, что выполняется асинхронное действие.

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

Таким образом, с помощью механизма промежуточных слоев, мы добавили неявный слой (a layer of indirection), который дал нам больше гибкости.

Поскольку redux-thunk передает в возвращаемые функции методы dispatch и getState из хранилища (store) как параметры, то вы можете отсылать другие действия и использовать состояние (state) для реализации дополнительной логики и workflow.

Но что если у нас есть что-то более сложное, чтобы быть выраженным с помощью преобразователя (thunk), без изменения react компонента. В этом случае мы можем попробовать использовать другую библиотеку промежуточных слоев (middleware library) и получить больше контроля.

Давайте посмотрим как заменить redux-thunk на библиотеку, что может дать нам больше контроля — redux-saga.

Redux-saga


Redux-saga это библиотека нацеленная делать сайд-эффекты проще и лучше путем работы с сагами.

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

Чтобы узнать больше о сагах можно начать с просмотра Применения паттерна Сага от Caitie McCaffrey, ну а если вы амбициозны, то здесь Статья, которая первая описывает саги в отношении распределенных систем.

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

Redux-saga делает это с помощью ES6 генераторов



Генераторы (Generators) это функции которые могут быть остановлены и продолжены, вместо выполнения всех выражений в один проход.

Когда вы вызываете функцию-генератор, она возвращает объект-итератор. И с каждым вызовом метода итератора next() тело функции-генератора будет выполняться до следующего yield выражения и затем останавливаться.



Это делает асинхронный код проще для написания и понимания.
Для примера вместо следующего выражения:



С генераторами мы бы написали так:



Возвращаясь к redux-saga, если говорить в общем, мы имеем сагу чья работа это следить за отправленными действиями (dispatched actions).



Для координирования логики, которую мы хотим реализовать внутри саги, мы можем использовать вспомогательную функцию takeEvery для создания новой саги для выполнения операции.



Если есть несколько запросов, takeEvery стартует несколько экземпляров саги-рабочего (worker saga). Иными словами реализует конкурентность(concurrency) для вас.

Надо отметить, что сага-наблюдатель (watcher saga) является другим неявным слоем (layer of indirection), который дает больше гибкости для реализации сложной логики (но это может быть лишним для простых приложений).

Теперь мы можем реализовать fetchDogAsync() функцию (мы полагаем, что у нас есть доступ к методу dispatch)



Но redux-saga позволяет нам получить объект, который декларирует наше намерение произвести операцию, вместо результата выполнения самой операции. Иными словами, пример выше реализуется в redux-saga следующим образом:



(Прим. переводчика: автор забыл заменить самый первый вызов dispatch)
Вместо вызова асинхронного реквеста напрямую, метод call вернет только объект описывающий эту операцию и redux-saga сможет позаботиться о вызове и возвращении результатов в функцию-генератор.

Тоже самое касается и метода put. Вместо отправления действий (dispatch action) внутри функции-генератора, put возвращает объект с инструкциями для промежуточного слоя (middleware) — отправить действие.

Эти возвращаемые объекты называются Эффекты (Effects). Ниже пример эффекта возвращаемого методом call:



Работая с Эффектами, redux-saga делает саги скорее Декларативными, чем Императивными.

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

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

Для тестирования, вы просто итерируете функцию-генератор делая assert и сравниваете полученные значения.


Еще одно дополнительное преимущество это возможность легко объединять разные эффекты в сложный workflow.

В дополнении к takeEvery, call, put, redux-saga предлагает множество методов-создателей эффектов (Effects creators) для задержки, получения текущего состояния, запуска параллельных задач, и отмены задач. Просто отметим несколько возможностей.

Возвращаясь к нашему простому примеру, ниже полная реализация в redux-saga:

const {Provider, connect} = ReactRedux;
const {createStore, applyMiddleware} = Redux;
const createSagaMiddleware = ReduxSaga.default;
const {takeEvery} = ReduxSaga;
const {put, call} = ReduxSaga.effects;

// Reducer
const initialState = {
  url: '',
  loading: false,
  error: false,
};
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'REQUESTED_DOG':
      return {
        url: '',
        loading: true,
        error: false,
      };
    case 'REQUESTED_DOG_SUCCEEDED':
      return {
        url: action.url,
        loading: false,
        error: false,
      };
    case 'REQUESTED_DOG_FAILED':
      return {
        url: '',
        loading: false,
        error: true,
      };
    default:
      return state;
  }
};

// Action Creators
const requestDog = () => {
  return { type: 'REQUESTED_DOG' }
};

const requestDogSuccess = (data) => {
  return { type: 'REQUESTED_DOG_SUCCEEDED', url: data.message }
};

const requestDogError = () => {
  return { type: 'REQUESTED_DOG_FAILED' }
};

const fetchDog = () => {
  return { type: 'FETCHED_DOG' }
};

// Sagas
function* watchFetchDog() {
  yield takeEvery('FETCHED_DOG', fetchDogAsync);
}

function* fetchDogAsync() {
  try {
    yield put(requestDog());
    const data = yield call(() => {
      return fetch('https://dog.ceo/api/breeds/image/random')
              .then(res => res.json())
      }
    );
    yield put(requestDogSuccess(data));
  } catch (error) {
    yield put(requestDogError());
  }
}

// Component
class App extends React.Component {
  render () {
    return (
      <div>
        <button onClick={() => this.props.dispatch(fetchDog())}>Show Dog</button>
          {this.props.loading 
            ? <p>Loading...</p> 
            : this.props.error
                ? <p>Error, try again</p>
                : <p><img src={this.props.url}/></p>}
      </div>
    )
  }
}

// Store
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
  reducer,
  applyMiddleware(sagaMiddleware)
);
sagaMiddleware.run(watchFetchDog);

const ConnectedApp = connect((state) => {
  console.log(state);
  return state;
})(App);

// Container component
ReactDOM.render(
  <Provider store={store}>
    <ConnectedApp />
  </Provider>,
  document.getElementById('root')
);

jsfiddle.net/eh3rrera/qu42h5ee

Когда вы нажимаете на кнопку, вот что происходит:

1. Отправляется действие FETCHED_DOG
2. Сага-наблюдатель (watcher saga) watchFetchDog получает это действие и вызывает сагу-рабочего (worker saga) fetchDogAsync.
3. Отправляется действие по отображению индикатора загрузки.
4. Происходит вызов API метода.
5. Отправляется действие по обновлению состояния (успех или провал)

Если вы считаете, что несколько неявных слоев и чуть-чуть дополнительной работы стоят этого, то redux-saga может дать вам больше контроля для обработки сайд-эффектов функциональным способом.

Заключение


Эта статья показала как реализовать асинхронные операции в Redux с помощью генераторов действий (action creators), преобразователей (thunks), и саг (sagas), идя от простого подхода к более сложному.

Redux не предписывает решение для обработки сайд-эффектов. Когда вы будете решать какому подходу следовать, вам необходимо учитывать сложность вашего приложения. Моя рекомендация — начинать с простого решения.

Также есть альтернативы redux-saga, которые стоит попробовать. Две самых популярных это redux-observable (который базируется на RxJS) и redux-logic (также базирующийся на RxJS наблюдателях, но дающий свободу писать вашу логику в других стилях).

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


  1. AlexRAV
    15.03.2018 17:37

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


    1. TheShock
      16.03.2018 06:39

      Это и правда неюзабельно. Но еще подождите людей со стокгольмским синдромом)


    1. defint
      16.03.2018 10:40

      Мы используем подход оборачивания в функции. Подробнее можно почитать тут: medium.com/@defint/redux-actions-async-1b874630ce09


  1. faiwer
    15.03.2018 17:52

    Вопрос к тем, кто использует саги вместо thunk: а что именно в saga-ах вам понравилось больше, чем практически точно такой же async-await код + redux-thunk? Из статьи не совсем ясно. Пассаж про декларативность вызывает лишь улыбку (не ерунда же). У этого подхода (который, как мне показалось, в основном заключается в написании yield вместо await) есть какие-то более конкретные преимущества?


    1. HUJG Автор
      15.03.2018 18:10

      А почему декларативность вызывает улыбку? Как мне показалось основной фишкой саг является именно попытка перевести разработку на декларативные рельсы с помощью эффектов-объектов (call, put & etc). А yield это всего лишь инструмент для этого.


      1. faiwer
        15.03.2018 18:19

        Ну просто это скорее декорация декларативности, нежели какая-то настоящая декоративность. Чем-то напоминает карго-культ. Каждая стадия конечного автомата генератора императивна. Да и генератор всё равно будет раскручен полностью. Async функции работают точно по тому же принципу, но запускаются JS-движком напрямую, а не через redux-saga. Какой-то существенной разницы я не вижу. Но звёздочки короче, тут спору нет :)


        1. amakhrov
          16.03.2018 01:38

          Это все же не так. Саги действительно декларативны — в том смысле, что итерация по генератору не вызывает выполнение сайд эффектов. Вместо этого генератор возвращает описание сайд эффектов в виде объекта-обертки (из примера в статье — вместо прямого вызова fetch() и возврата результата промиса вызывается эффект call(), который, вопреки названию, НЕ вызывает обернутую функцию :) ). Это позволяет более просто тестировать сагу (например, как здесь)


          1. faiwer
            16.03.2018 07:20

            О. Спасибо за пример. Так намного понятнее. Async-и так не потестишь, и правда :)


    1. Slowz
      16.03.2018 06:20

      tl;dr: Используйте saga если у вас сложное приложение, используйте thunk если вас все
      устраивает.


      Лично я пользовался и тем, и другим. Разница между ними довольна большая.
      redux-thunk удобный, простой как 5 копеек. Сайд эффекты описываются непосредственно в самих экшенах и вызываются как обычные.
      redux-saga чуть более сложный и гибкий инструмент. Использует систему подписок — т.е. сага может быть вызвана на любой угодный вам экшен. Под гибкостью я имею ввиду более развитую систему эффектов — можно устраивать гонки эффектов, можно отменять действия и т.п.
      Сравним на примере простой игры: надо нажать на кнопку и за 5 секунд сделать какое-то определенное действие, иначе проигрыш. В саге такое будет сделано за несколько строчек — гонка таймера и ожидание нового экшена. В redux-thunk сделать схожее уже на порядок сложнее.
      Нельзя не отметить тестирование саг — очень простое и приятное занятие :)
      Если у вас нет каких-то сложных действий используйте thunk, но если вы хотите больший контроль над потоком данных, удобное тестирование то saga ваш выбор.


      1. faiwer
        16.03.2018 07:23

        Спасибо за разъяснение.


        надо нажать на кнопку и за 5 секунд сделать какое-то определенное действие, иначе проигрыш. В саге такое будет сделано за несколько строчек — гонка таймера и ожидание нового экшена

        Впечатляет. А можно, если не сложно, пример такой саги. Это одна сага или это две саги которые друг о друге что-то знают?


        1. Slowz
          16.03.2018 10:29

          Ошибся веткой при ответе :)


  1. Slowz
    16.03.2018 10:22
    +2

    Получается только одна сага. Вот пример:

    export function* startGame() {
      const { winCondition, timeout } = yield race({
        winCondition: take('WIN'),
        timeout: call(delay, 5000)
      })
      if (winCondition) {
        console.log("Yeee, you've just won!")
      } else {
        console.log("Oh, nooo! Time out")
      }
    }