понедельник, 31 июля 2017 г.

Асинхронные операции с использованием redux-saga


Несколько дней назад мой коллега заговорил об управлении асинхронными операциями. Он использовал несколько инструментов для расширения возможностей redux. Наслушавшись его я поехал домой реально усташим от JavaScript.

Посмотрим правде в глаза: если вы привыкли использовать в своей работе технологии, основанные на ваших потребностях, - не просто ради самой технологии — то создание экосистемы React может оказаться расстраивающим и трудоемким.

Я провел последние два года, работая над проектами Angular и наслаждаясь современным уровнем развития Модели-Вида-Контроллера (MVC). И я должен сказать, что даже если кривая обучения была с проблеммами пришедшими из Backbone.js - обучение Angular действительно оправдалось. Я получил лучшую работу, и у меня также появилась возможность принять участие в интересных проектах. Я многому научился у сообщества поддерживающего Angular.

Это были действительно классные дни, но, ну, «Усталость должна продолжаться» (копирайт), и двигаюсь с модой: React, Redux и Sagas. Несколько лет назад я наткнулся на статью под названием «Сглаживание цепочек промисов» Томаса Бурлесона. Я многое узнал чтения прочитав это. Даже через два года я все еще вспоминаю множество этих идей.

В эти дни я перешел на React, и я нашел мощь Redux и использовал саги для управления асинхронными операциями. Поэтому я пишу это, чтобы позаимствовать пост Томаса и создать аналогичный подход, используя redux-saga. Надеюсь здесь вселенная будет благосклонна и поможет некоторым людям понять, как работают эти важные технологии. Отступление: я буду работать с одним и тем же сценарием и расширять его, надеюсь (если повезет), чтобы создать обсуждение обоих подходов. Я предполагаю, что у читателя есть некоторое базовое понимание Promise, React, Redux и ... JavaScript.

Сначала первичные вещи.

Согласно Yassine Elouafi, создателю redux-saga. Redux-saga - это библиотека, которая направлена на упрощение и улучшение сайд-эффектов (например, асинхронные вещи, такие как извлечение данных и нечистые вещи, такие как доступ к кешу браузера) в приложениях React / Redux.

В основном это вспомогательная библиотека, которая позволяет нам организовывать все асинхронные и распределенные операции генераторов функций на основе Sagas и ES6. Если вы хотите больше узнать о самом шаблоне саги, у Caitie McCaffrey отлично поработано в этом видео и больше рассказано о генераторах функций. Взгляните на это бесплатное видео Egghead (по крайней мере, это было бесплатно, когда я опубликовал эту статью).

Вариант панели управления полетами.

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

Как мы видим последовательность вызовов API следующая: getDeparture → getFlight →getForecast (Получить отправляемого→Получить рейс→Получить прогноз), примерно так выглядит наш сервисный класс для этого:

class TravelServiceApi {
 static getUser() {
   return new Promise((resolve) => {
     setTimeout(() => {
       resolve({
            email : "somemockemail@email.com",
            repository: "http://github.com/username"
       });
     }, 3000);
   });
 }
 static getDeparture(user) {
  return new Promise((resolve) => {
   setTimeout(() => {
    resolve({
      userID : user.email,
      flightID : “AR1973”,
      date : “10/27/2016 16:00PM”
     });
    }, 2500);
   });
 }
 static getForecast(date) {
  return new Promise((resolve) => {
      setTimeout(() => {
        resolve({
            date: date,
            forecast: "rain"
        });
      }, 2000);
   });
  }
}

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

Компоненты React можно найти здесь. Они представляют собой три разных компонента с представлением в магазине redux, которое дается тремя редюсерами, которые выглядят следующим образом:

const dashboard = (state = {}, action) => {
 switch(action.type) {
  case ‘FETCH_DASHBOARD_SUCCESS’:
  return Object.assign({}, state, action.payload);
  default :
  return state;
 }
};

Мы используем другой редюсер для каждой панели с тремя различными сценариями, которые дают компоненту доступ к части пользователя с помощью функции redux-а StateToProps:

const mapStateToProps =(state) => ({
 user : state.user,
 dashboard : state.dashboard
});

После того, как все настроено (да, я знаю, что я не объяснял много чего, но я хочу сосредоточиться только на сагах ...), мы готовы играть!

Покажите мне Саги

Уильям Деминг сказал однажды: Если вы не можете описать, что вы делаете как процесс, то вы не знаете, что делаете. Хорошо, давайте создадим пошаговый процесс работы с Redux Saga.

1. Регистрация Саги

Я буду использовать свое собственное слово, чтобы описать какие методы API открытые. Если вам нужна дополнительная техническая информация, не стесняйтесь обращаться к документации здесь.

Сначала нам нужно создать наш генератор саги и зарегистрировать их:

function* rootSaga() {
  yield[
    fork(loadUser),
    takeLatest('LOAD_DASHBOARD', loadDashboardSequenced)
  ];
}

Saga Redux выделяет несколько методов под названием Effects, мы собираемся определить несколько из них:


Fork - выполняет неблокирующую операцию над переданной функцией.
Take - пауза до получения экшена.
Race - запускает эффекты одновременно, а затем отменяет их всех кроме той, которая первая завершится.
Call - выполняет функцию. Если он вернет обещание, приостанавливает сагу до тех пор, пока обещание не вызовет resolve.
Put — диспатчит экшн.
Select - запуск функции выбора для получения данных из state.
takeLatest - означает, что мы будем вызывать несколько call, а затем возвращать только результаты последнего вызова call.
takeEvery - вернет результаты для каждого вызванного call.

Мы просто зарегистрировали две разные саги. Мы собираемся определить их позже. До сих пор мы использовали одну сагу для пользователя, использующую fork и другую использующую takeLatest, которая будет ждать выполнения действия LOAD_DASHBOARD. Дополнительная информация на шаге 3.

2. Инжектирование Saga Middleware в хранилище Redux.

Когда мы определяем хранилище Redux и инициализируем его, большую часть времени он будет выглядеть так:

const sagaMiddleware = createSagaMiddleware();
const store = createStore(rootReducer, [], compose(
      applyMiddleware(sagaMiddleware)  
);

sagaMiddleware.run(rootSaga); /* inject our sagas into the middleware*/

3. Создание саги.

Во-первых, мы собираемся определить последовательность саги loadUser:

function* loadUser() {
  try {
   //1st step
    const user = yield call(getUser);
   //2nd step
    yield put({type: 'FETCH_USER_SUCCESS', payload: user});
  } catch(error) {
    yield put({type: 'FETCH_FAILED', error});
  }
}

Мы можем прочитать это так:

Во-первых вызываем функцию, называемую getUser, и присваиваем результат в const user. Далее отправляем экшен под названием FETCH_USER_SUCCESS и передаем значение user, которое будет потребляться хранилищем.

Если что-то пойдет не так, отправьте экшн под названием FETCH_FAILED.

Как вы можете видеть, действительно здорово, что мы можем положить результат операции yield в переменную.

Теперь мы собираемся создать последовательность в саге:

function* loadDashboardSequenced() {
 try {
  
  yield take(‘FETCH_USER_SUCCESS’);
  const user = yield select(state => state.user);
  
  const departure = yield call(loadDeparture, user);
  const flight = yield call(loadFlight, departure.flightID);
  const forecast = yield call(loadForecast, departure.date);
  yield put({type: ‘FETCH_DASHBOARD_SUCCESS’, payload: {forecast,  flight, departure} });
  } catch(error) {
    yield put({type: ‘FETCH_FAILED’, error: error.message});
  }
}

Мы можем прочитать сагу следующим образом: Ожидаем, пока прийдет экшн FETCH_USER_SUCCESS. Выполнение саги в основном будет приостановлено до тех пор, пока триггер этого события не вызовет его. Для этого мы используем take - эффект.

Мы берем значение из хранилища. Select - эффект получает функцию, которая имеет доступ к хранилищу. Мы присваиваем информацию о юзере в константу const user.

Мы выполняем асинхронную операцию для загрузки информации о выезжающем и передаем пользователю в качестве параметра, используя call - эффект.

После того, как loadDeparture закончен, мы выполняем loadFlight с объектом отправляемого, выбранным в предыдущей операции.

То же самое будет с прогнозом, нам нужно подождать, пока рейс будет загружен, чтобы выполнить следующий call - эффект.

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

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

Довольно аккуратно, а?

Теперь давайте проверим другой случай. Рассмотрим, что getFlight и getForecast могут запускаться одновременно. Им не нужно ждать завершения друг друга, чтобы запуститься самому, поэтому мы можем создать разные панели для этого случая.

Не блокирующая сага

Чтобы выполнить две неблокирующие операции, нам нужно внести небольшую правку в нашу предыдущую сагу:

function* loadDashboardNonSequenced() {
  try {
    //Wait for the user to be loaded
    yield take('FETCH_USER_SUCCESS');
    //Take the user info from the store
    const user = yield select(getUserFromState);
    //Get Departure information
    const departure = yield call(loadDeparture, user);
    //Here is when the magic happens
    const [flight, forecast] = yield [call(loadFlight, departure.flightID), call(loadForecast, departure.date)];
    //Tell the store we are ready to be displayed
    yield put({type: 'FETCH_DASHBOARD2_SUCCESS', payload: {departure, flight, forecast}});
} catch(error) {
    yield put({type: 'FETCH_FAILED', error: error.message});
  }
}

Мы должны регистрировать yield генератор как массив:

const [flight, forecast] = yield [call(loadFlight, departure.flightID), call(loadForecast, departure.date)];

Таким образом, обе операции вызываются параллельно, но в конце дня мы сможем дождаться завершения обоих и обновить интерфейса.

Затем нам нужно зарегистрировать сагу в rootSaga:

function* rootSaga() {
  yield[
    fork(loadUser),
    takeLatest('LOAD_DASHBOARD', loadDashboardSequenced),
    takeLatest('LOAD_DASHBOARD2' loadDashboardNonSequenced)
  ];
}

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

Не последовательные и не блокирующие саги

Мы также можем изолировать наши саги друг от друга и объединить их, то есть они могут работать независимо. Это именно то, что нам нужно. Давайте взглянем.

Шаг №1: Мы изолируем Саги Прогноза и Полета. Обе они зависят от отъезда.

/* **************Flight Saga************** */
function* isolatedFlight() {
  try {
    /* departure will take the value of the object passed by the put*/
    const departure = yield take('FETCH_DEPARTURE3_SUCCESS');
 
    const flight = yield call(loadFlight, departure.flightID);
 
    yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: {flight}});
  } catch (error) {
    yield put({type: 'FETCH_FAILED', error: error.message});
  }
}

/* **************Forecast Saga************** */
function* isolatedForecast() {
    try {
      /* departure will take the value of the object passed by the put*/
     const departure = yield take('FETCH_DEPARTURE3_SUCCESS');
     const forecast = yield call(loadForecast, departure.date);
     
     yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: { forecast, }});
} catch(error) {
      yield put({type: 'FETCH_FAILED', error: error.message});
    }
}

Обратили внимание на что-то очень важное здесь? Это то как мы спроектировали наши саги:

  • Они оба ждут того же Action Event (FETCH_DEPARTURE3_SUCCESS), чтобы начать.
  • Они получат значение при срабатывании этого события. Подробнее об этом на следующем шаге.
  • Они будут выполнять свою асинхронную операцию с использованием call - эффекта, и оба будут запускать одно и то же событие после завершения. Но они оба отправляют разные данные в хранилище. Благодаря силе Redux мы можем сделать это без каких-либо изменений в нашем редьюсере.

Шаг №2: Давайте сделаем изменения в последовательности отправляемого и убедимся, что он шлет значение отправляемого в обе саги:

function* loadDashboardNonSequencedNonBlocking() {
  try {
    //Wait for the action to start
    yield take('FETCH_USER_SUCCESS');
    //Take the user info from the store
    const user = yield select(getUserFromState);
    //Get Departure information
    const departure = yield call(loadDeparture, user);
    //Update the store so the UI get updated
    yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: { departure, }});
    //trigger actions for Forecast and Flight to start...
    //We can pass and object into the put statement
    yield put({type: 'FETCH_DEPARTURE3_SUCCESS', departure});
  } catch(error) {
    yield put({type: 'FETCH_FAILED', error: error.message});
  }
}

Здесь ничего особенного, пока мы не доберемся до put - эффекта. Мы можем передать объект экшенам, и он будет помещен из генератора yield в константу const departure, которая будет передана в саги рейса и прогноза . Мне это нравится.

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

В продакшене я бы, наверное, делал что-то совсем другое. Я просто хотел указать, что вы можете передавать значения при использовании put – эффекта.

Что на счет тестирования?

Вы ведь тестируете свой код...правда?

Саги легко тестируются, но они сочетаются с вашими шагами, устанавливаются в последовательности из-за природы генераторов. Давайте посмотрим пример. (И не стесняйтесь проверить все тесты в папке sagas репозитория ):

describe('Sequenced Saga', () => {
  const saga = loadDashboardSequenced();
  let output = null;
it('should take fetch users success', () => {
      output = saga.next().value;
      let expected = take('FETCH_USER_SUCCESS');
      expect(output).toEqual(expected);
  });
it('should select the state from store', () => {
      output = saga.next().value;
      let expected = select(getUserFromState);
      expect(output).toEqual(expected);
  });
it('should call LoadDeparture with the user obj', (done) => {
    output = saga.next(user).value;
    let expected = call(loadDeparture, user);
    done();
    expect(output).toEqual(expected);
  });
it('should Load the flight with the flightId', (done) => {
    let output = saga.next(departure).value;
    let expected = call(loadFlight, departure.flightID);
    done();
    expect(output).toEqual(expected);
  });
it('should load the forecast with the departure date', (done) => {
      output = saga.next(flight).value;
      let expected = call(loadForecast, departure.date);
      done();
      expect(output).toEqual(expected);
    });
it('should put Fetch dashboard success', (done) => {
       output = saga.next(forecast, departure, flight ).value;
       let expected = put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {forecast, flight, departure}});
       const finished = saga.next().done;
       done();
       expect(finished).toEqual(true);
       expect(output).toEqual(expected);
    });
});

Убедитесь, что вы импортируете все хелперы эффектов и функций, которые вы собираетесь тестировать.

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

За сценой каждый генератор переходит к следующей строке после выхода, когда вызывается следующий метод. Вот почему мы используем значение saga.next().

Эта последовательность задана жестко. Если вы измените шаги в саге, тест не пройдет.

Вывод.

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

Я нашел thunks более легким для внедрения и поддержки. Но для более сложной работы Redux-Saga выполняет отличную работу.

Еще раз, я благодарю Томаса за вдохновение для этого поста. Надеюсь, кто-то найдет столько вдохновения в этом посте, сколько нашел я :).

Если у вас есть вопросы, не стесняйтесь твитнуть мне. Я рад помочь.

Материал взят с Async operations using redux-saga

Комментариев нет:

Отправить комментарий