Разбираемся в redux-saga: От генераторов действий к сагам

Original author: Esteban Herrera
  • Translation
  • Tutorial


Любой 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 наблюдателях, но дающий свободу писать вашу логику в других стилях).

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 21

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

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

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

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

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

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

              +1

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

                0

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

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


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

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


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

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

                0
                Ошибся веткой при ответе :)
            +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")
              }
            }
            


              0
              Как звучит красиво, какие заумные слова: сага, функция-генератор… В итоге вы получаете огромный и сложно поддерживаемый код, добавьте к этому и flow-types. Чтобы сделать обычный запрос и получить обычный ответ, вам надо создавать 5 файлов и везде писать много кода. Вместе того, чтобы делать само приложение, вы будете только и разгребать весь этот мусор. Я не понимаю, кто это придумал: делать кучу телодвижений внутри самого приложения (dispatches, actions, actions-types, reducers, sagas) вместо того, чтобы сделать один простой запрос к серверу. Один простой запрос! Я когда-то читал все эти статьи с заумными заголовками, делал по всем этим стандартам. Думал, всё продумано и это хорошо работает. Но оказалось, что это пустой, бессмысленный и беспощадный хайп. Поэтому перехожу на graphql и apollo.
                0
                И в чем будет выгода? Вы сделаете все тоже самое для gql запроса.
                  0
                  Нет, там пишешь запрос и тут же обрабатываешь loading, error и data. И всё. Просто всё. Без всяких диспатч-шмитспатч, редюсер… Но, правда, там есть другая проблема — нет никакой инвалидации кэша на клиенте. Вообще никак это не предусмотрено. Просто убило меня это.
                  0
                  Я новичок в JS, может у меня глупый вопрос.
                  Какая связь между отказом от саг и переходом на GraphQL?
                    +1
                    Apollo GraphQL решает проблемы сайд эффектов, которые в большинстве проектах решает redux-saga.
                      0
                      Сайд-эффект по отношению к стейту?
                  0
                  Друзья, коллеги, доброго времени суток!

                  Объясните мне пожалуйста, почему автор утверждает, что конструкции:
                  fetch(url).then(val => {
                      console.log(val)
                  })
                  

                  и
                  var val = yield fetch(url);
                  console.log(val);
                  

                  идентичны?

                  Ведь в первом варианте перменная val будет содержать результат запроса, который был передан в resolve функцию. А во втором случае val будет содержать Promise, состояние которого нам ещё нужно обработать.

                  Может быть автор хотел сказать, что «с помощью саги» (а не генератора) это будут идентичные выражения. Но, насколько я понимаю, это тоже неверно: чтобы эти варианты были едентичны, необходимо во втором случае fetch обернуть в saga/effects метод, например call, чтобы middleware распаковал значение промиса и вернул его в value.

                  Что-то вроде этого:
                  var val = yield call(fetch, url);
                  console.log(val);
                  

                  Так, как это описано в статье — работать не должно. Или я чего-то не догоняю?
                    0

                    Да, имелся в виду генератор, который запускается middleware'ой.


                    Но, насколько я понимаю, это тоже неверно

                    yield'ить можно и промисы, middleware его подождет и без оборачивания в call. Но таким образом теряется декларативность и по возможности лучше так не делать :)

                      0
                      Понял, спасибо! Пока только разбираюсь с сагами, не знал что она умеет корректно обработать промис и без «эффектов».

                  Only users with full accounts can post comments. Log in, please.