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

в 7:15, , рубрики: javascript, ReactJS, redux-saga, Разработка веб-сайтов

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

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

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

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

Взаимодействие с API довольно частое требование в приложениях. Представьте, что нам необходимо показывать случайную картинку собаки, когда мы нажимаем на кнопку.
Разбираемся в redux-saga: От генераторов действий к сагам - 2
мы можем использовать 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-saga: От генераторов действий к сагам - 3
С redux-thunk
Разбираемся в redux-saga: От генераторов действий к сагам - 4

Преимуществом использования 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 генераторов
Разбираемся в redux-saga: От генераторов действий к сагам - 5
Генераторы (Generators) это функции которые могут быть остановлены и продолжены, вместо выполнения всех выражений в один проход.

Когда вы вызываете функцию-генератор, она возвращает объект-итератор. И с каждым вызовом метода итератора next() тело функции-генератора будет выполняться до следующего yield выражения и затем останавливаться.
Разбираемся в redux-saga: От генераторов действий к сагам - 6
Это делает асинхронный код проще для написания и понимания.
Для примера вместо следующего выражения:
Разбираемся в redux-saga: От генераторов действий к сагам - 7
С генераторами мы бы написали так:
Разбираемся в redux-saga: От генераторов действий к сагам - 8
Возвращаясь к redux-saga, если говорить в общем, мы имеем сагу чья работа это следить за отправленными действиями (dispatched actions).
Разбираемся в redux-saga: От генераторов действий к сагам - 9
Для координирования логики, которую мы хотим реализовать внутри саги, мы можем использовать вспомогательную функцию takeEvery для создания новой саги для выполнения операции.
Разбираемся в redux-saga: От генераторов действий к сагам - 10
Если есть несколько запросов, takeEvery стартует несколько экземпляров саги-рабочего (worker saga). Иными словами реализует конкурентность(concurrency) для вас.

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

Теперь мы можем реализовать fetchDogAsync() функцию (мы полагаем, что у нас есть доступ к методу dispatch)
Разбираемся в redux-saga: От генераторов действий к сагам - 11
Но redux-saga позволяет нам получить объект, который декларирует наше намерение произвести операцию, вместо результата выполнения самой операции. Иными словами, пример выше реализуется в redux-saga следующим образом:
Разбираемся в redux-saga: От генераторов действий к сагам - 12
(Прим. переводчика: автор забыл заменить самый первый вызов dispatch)
Вместо вызова асинхронного реквеста напрямую, метод call вернет только объект описывающий эту операцию и redux-saga сможет позаботиться о вызове и возвращении результатов в функцию-генератор.
Тоже самое касается и метода put. Вместо отправления действий (dispatch action) внутри функции-генератора, put возвращает объект с инструкциями для промежуточного слоя (middleware) — отправить действие.

Эти возвращаемые объекты называются Эффекты (Effects). Ниже пример эффекта возвращаемого методом call:
Разбираемся в redux-saga: От генераторов действий к сагам - 13
Работая с Эффектами, redux-saga делает саги скорее Декларативными, чем Императивными.

Декларативное программирование это стиль программирования, который пытается минимизировать или устранить сайд-эффекты, описанием что программа должна делать, вместо описания как она должна это делать.
Преимущество, которое это дает, и о чем говорят большинство людей, то что функцию, которая возвращает простой объект, гораздо проще тестировать, чем функцию, которая делает асинхронный вызов. Для тестирования, вам не нужно использовать реальное АПИ, делать фейки или мокать.
Для тестирования, вы просто итерируете функцию-генератор делая assert и сравниваете полученные значения.
Разбираемся в redux-saga: От генераторов действий к сагам - 14
Еще одно дополнительное преимущество это возможность легко объединять разные эффекты в сложный 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 наблюдателях, но дающий свободу писать вашу логику в других стилях)

Автор: HUJG

Источник

Поделиться

* - обязательные к заполнению поля