Перейти к содержанию

Асинхронные экшены

В базовом руководстве мы построили простое приложение — список дел. Оно было совершенно синхронным. Каждый раз, когда экшен был отправлен, состояние было немедленно обновлено.

В этом руководстве мы будем строить асинхронное приложение, которое будет использовать API Reddit'а для отображения текущих заголовков для выбранного subreddit'a. Так как же асинхронность вписывается в концепцию Redux?

Экшены

Когда Вы вызываете асинхронное API, есть два ключевых момента времени: момент непосредственного вызова и момент получения ответа или timeout'a.

Для каждого из этих моментов обычно может требоваться изменение состояния приложения (application state). Для изменения состояния вы должны запустить (dispatch) нормальные экшены, которые будут обработаны редьюсером синхронно. Обычно для любого API запроса вам понадобится запустить (dispatch) по крайней мере три разных вида экшенов?

Экшен, информирующий редьюсер о том, что запрос начался.
Редьюсер может обрабатывать такой вид экшена, переключая флаг isFetching в состоянии приложения. Именно так UI понимает, что самое время показать лоадер/спиннер.
Экшен, информирующий редьюсер о том, что запрос успешно завершился.
Редьюсер может обрабатывать такой вид экшена, объединяя полученные из запроса данные с состоянием, которым управляет этот редьюсер и сбрасывая флаг isFetching.
Экшен, информирующий редьюсер о том, что запрос завершился неудачей.
Редьюсер может обрабатывать такой вид экшенов, сбрасывая флаг isFetching. Также некоторые редьюсеры могут захотеть сохранить сообщение об ошибке, чтобы UI мог его отобразить.

Для всего этого вы можете использовать поле status в ваших экшенах:

{ type: 'FETCH_POSTS' }
{ type: 'FETCH_POSTS', status: 'error', error: 'Oops' }
{ type: 'FETCH_POSTS', status: 'success', response: { ... } }

Или вы можете объявить отдельные типы для таких экшенов:

{ type: 'FETCH_POSTS_REQUEST' }
{ type: 'FETCH_POSTS_FAILURE', error: 'Oops' }
{ type: 'FETCH_POSTS_SUCCESS', response: { ... } }

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

Использование нескольких типов экшенов уменьшают вероятность для ошибки, но это не проблема, если вы генерируете экшены и редьюсеры с помощью таких библиотек, как redux-actions.

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

В этом руководстве мы будем использовать несколько отдельных типов экшенов.

Синхронные генераторы экшенов

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

actions.js (Synchronous)

//тип экшена
export const SELECT_SUBREDDIT = 'SELECT_SUBREDDIT';

//генератор экшена
export function selectSubreddit(subreddit) {
  return {
    type: SELECT_SUBREDDIT,
    subreddit,
  };
}

Также пользователь может нажать кнопку "обновить" для обновления:

export const INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT';

export function invalidateSubreddit(subreddit) {
  return {
    type: INVALIDATE_SUBREDDIT,
    subreddit,
  };
}

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

Когда нужно будет фетчить посты для какого-нибудь subreddit'a мы будем посылать экшен REQUEST_POSTS:

export const REQUEST_POSTS = 'REQUEST_POSTS';

function requestPosts(subreddit) {
  return {
    type: REQUEST_POSTS,
    subreddit,
  };
}

Важно чтобы этот экшен был отделен от SELECT_SUBREDDIT и INVALIDATE_SUBREDDIT. Хотя они, экшены, могут происходить одно за другим, но, с возрастанием сложности приложения вам может понадобится фетчить какие-то данные независимо от действий пользователя, например, для предзагрузки самых популярных subreddit'ов или для того, чтобы изредка обновлять устаревшие данные. Вы можете также захотеть фетчить данные в ответ на смену роута, так что не очень мудро связывать обновление данных с каким-то определенным UI-событием.

Наконец, когда сетевой запрос будет осуществлен, мы отправим экшен RECEIVE_POSTS:

export const RECEIVE_POSTS = 'RECEIVE_POSTS';

function receivePosts(subreddit, json) {
  return {
    type: RECEIVE_POSTS,
    subreddit,
    posts: json.data.children.map((child) => child.data),
    receivedAt: Date.now(),
  };
}

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

Обратите внимание на обработчик ошибки

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

Разработка структуры стора

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

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

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

Вот как может выглядеть структура состояния для нашего приложения “Reddit headlines”:

{
  "selectedSubreddit": "frontend",
  "postsBySubreddit": {
    "frontend": {
      "isFetching": true,
      "didInvalidate": false,
      "items": []
    },
    "reactjs": {
      "isFetching": false,
      "didInvalidate": false,
      "lastUpdated": 1439478405547,
      "items": [
        {
          "id": 42,
          "title": "Confusion about Flux and Relay"
        },
        {
          "id": 500,
          "title": "Creating a Simple Application Using React JS and Flux Architecture"
        }
      ]
    }
  }
}

тут есть несколько важных моментов:

  • Мы храним информацию о каждом subreddit’е отдельно, следовательно мы можем кешировать любой subreddit. Когда пользователь переключается между ними во второй раз, обновление UI будет мгновенным и мы сможем не перезагружать данные, если мы этого не хотим. Не переживайте о том, что все эти элементы (subreddit'ы, а их может быть очень много) будут находиться в памяти: Вам не понадобятся никакие чистки памяти, если только вы и ваш пользователь не имеете дело с десятками тысяч элементов и при этом пользователь очень редко закрывает вкладку браузера.

  • Для каждого списка элементов вы захотите хранить isFetching для показа спиннера, didInvalidate, который вы потом сможете изменить, если данные устареют, lastUpdated для того чтобы знать, когда данные были обновлены в последний раз, и собственно items. В реальном приложении вы также захотите хранить состояние страничной навигации: fetchedPageCount и nextPageUrl.

Обратите внимание на вложенные сущности

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

Если у вас есть вложенные сущности или если вы даете возможность редактировать загруженные элементы, то вы должны хранить их отдельно в состоянии, как если бы оно (состояние) было базой данных. И в информации о постраничной навигации вы можете ссылаться на такие элементы только по их ID. Это позволит вам всегда держать их в актуальном состоянии. С таким подходом ваше состояние (state) может выглядеть вот так:

{
selectedSubreddit: 'frontend',
entities: {
    users: {
    2: {
        id: 2,
        name: 'Andrew'
    }
    },
    posts: {
    42: {
        id: 42,
        title: 'Confusion about Flux and Relay',
        author: 2
    },
    100: {
        id: 100,
        title: 'Creating a Simple Application Using React JS and Flux Architecture',
        author: 2
    }
    }
},
postsBySubreddit: {
    frontend: {
    isFetching: true,
    didInvalidate: false,
    items: []
    },
    reactjs: {
    isFetching: false,
    didInvalidate: false,
    lastUpdated: 1439478405547,
    items: [ 42, 100 ]
    }
}
}

В этом руководстве мы не будем нормализовывать сущности, но это то, о чем вам стоит задуматься для более динамичного приложения.

Обработка экшенов

Перед тем как переходить к деталям отправки экшенов вместе с сетевыми запросами, мы напишем редьюсеры для экшенов, которые определили выше.

Обратите внимание на композицию редьюсеров

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

reducers.js

import { combineReducers } from 'redux';
import {
  SELECT_SUBREDDIT,
  INVALIDATE_SUBREDDIT,
  REQUEST_POSTS,
  RECEIVE_POSTS,
} from '../actions';

function selectedSubreddit(state = 'reactjs', action) {
  switch (action.type) {
    case SELECT_SUBREDDIT:
      return action.subreddit;
    default:
      return state;
  }
}

function posts(
  state = {
    isFetching: false,
    didInvalidate: false,
    items: [],
  },
  action
) {
  switch (action.type) {
    case INVALIDATE_SUBREDDIT:
      return Object.assign({}, state, {
        didInvalidate: true,
      });
    case REQUEST_POSTS:
      return Object.assign({}, state, {
        isFetching: true,
        didInvalidate: false,
      });
    case RECEIVE_POSTS:
      return Object.assign({}, state, {
        isFetching: false,
        didInvalidate: false,
        items: action.posts,
        lastUpdated: action.receivedAt,
      });
    default:
      return state;
  }
}

function postsBySubreddit(state = {}, action) {
  switch (action.type) {
    case INVALIDATE_SUBREDDIT:
    case RECEIVE_POSTS:
    case REQUEST_POSTS:
      return Object.assign({}, state, {
        [action.subreddit]: posts(
          state[action.subreddit],
          action
        ),
      });
    default:
      return state;
  }
}

const rootReducer = combineReducers({
  postsBySubreddit,
  selectedSubreddit,
});

export default rootReducer;

Две части этого кода вызывают особый интерес:

  • Мы используем ES6-синтаксис вычисляемого свойства, т. е. мы можем обновить state[action.subreddit] с помощью Object.assign() с использованием меньшего количества строк кода. Вот это:
return Object.assign({}, state, {
  [action.subreddit]: posts(
    state[action.subreddit],
    action
  ),
});

эквивалентно этому:

let nextState = {};
nextState[action.subreddit] = posts(
  state[action.subreddit],
  action
);
return Object.assign({}, state, nextState);
  • Мы извлекли posts(state, action), который управляет состоянием конкретного списка постов. Это просто композиция редьюсеров! Нам выбирать, как разбить/разделить редьюсер на более мелкие редьюсеры и в этом случае, мы доверяем обновление элементов внутри объекта функции-редьюсеру posts.

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

Асинхронные генераторы экшенов

Наконец, как мы используем синхронные генераторы экшенов, созданные нами ранее вместе с сетевыми запросами? Стандартный для Redux путь — это использование Redux Thunk middleware. Этот мидлвар (middleware) содержится в отдельном пакете, который называется redux-thunk. Мы поясним, как работают мидлвары позже. Сейчас есть одна важная вещь, о которой вам нужно знать: при использовании конкретно этого мидлвара, генератор экшенов может вернуть функцию, вместо объекта экшена. Таким образом, генератор экшена превращается в преобразователь (Thunk)

Когда генератор экшена вернет функцию, эта функция будет вызвана мидлваром Redux Thunk. Этой функции не обязательно быть чистой. Таким образом, в ней разрешается инициировать побочные эффекты (side effects), в том числе и асинхронные вызовы API. Также эти функции могут вызывать экшены, такие же синхронные экшены, которые мы отправляли ранее.

Мы все еще можем определить эти специальные thunk-генераторы экшенов внутри нашего actions.js файла:

actions.js (Asynchronous)

import fetch from 'cross-fetch';

export const REQUEST_POSTS = 'REQUEST_POSTS';
function requestPosts(subreddit) {
  return {
    type: REQUEST_POSTS,
    subreddit,
  };
}

export const RECEIVE_POSTS = 'RECEIVE_POSTS';
function receivePosts(subreddit, json) {
  return {
    type: RECEIVE_POSTS,
    subreddit,
    posts: json.data.children.map((child) => child.data),
    receivedAt: Date.now(),
  };
}

export const INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT';
export function invalidateSubreddit(subreddit) {
  return {
    type: INVALIDATE_SUBREDDIT,
    subreddit,
  };
}

// Тут мы встречаемся с нашим первым thunk-генератором экшена!
// Хотя его содержимое отличается, вы должны использовать его,
// как и любой другой генератор экшенов:
// store.dispatch(fetchPosts('reactjs'))

export function fetchPosts(subreddit) {
  // Thunk middleware знает, как обращаться с функциями.
  // Он передает метод dispatch в качестве аргумента функции,
  // т.к. это позволяет отправить экшен самостоятельно.

  return function (dispatch) {
    // Первая отправка: состояние приложения обновлено,
    // чтобы сообщить, что запускается вызов API.

    dispatch(requestPosts(subreddit));

    // Функция, вызываемая Thunk middleware, может возвращать значение,
    // которое передается как возвращаемое значение метода dispatch.

    // В этом случае мы возвращаем promise.
    // Thunk middleware не требует этого, но это удобно для нас.

    return fetch(
      `https://www.reddit.com/r/${subreddit}.json`
    )
      .then(
        (response) => response.json(),
        // Не используйте catch, потому что это также
        // перехватит любые ошибки в диспетчеризации и
        // в результате рендеринга, что приведет к
        // циклу ошибок «Unexpected batch number».
        // https://github.com/facebook/react/issues/6895
        (error) => console.log('An error occurred.', error)
      )
      .then((json) =>
        // Мы можем вызывать dispatch много раз!
        // Здесь мы обновляем состояние приложения с результатами вызова API.
        dispatch(receivePosts(subreddit, json))
      );
  };
}

Примечание по fetch

Мы используем fetch API в примерах. Это новое API для создания тевых запросов, которое заменяет XMLHttpRequest в большинстве стандартных случаев. Поскольку большинство браузеров до сих р не поддерживают его нативно, мы полагаем, что вы для этого используете библиотеку cross-fetch:

// Добавьте это в каждый файл, где вы используете `fetch`
import fetch from 'cross-fetch';

Внутри она использует полифил whatwg-fetch на клиенте и node-fetch на сервере, поэтому вам не понадобится менять вызовы API, если вы захотите сделать ваше приложение универсальным.

Помните, что любой полифил fetch предполагает, что полифил Promise уже присутствует. Самый простой способ убедиться, что вы подключили Promise-полифил — это подключить ES6-полифил Babel во входной точке, прежде чем любой другой код запустится:

// Добавьте это в самом начале вашего приложения
import 'babel-core/polyfill';

Как мы добавляем мидлвар Redux Thunk в механизм диспетчера? Для этого мы используем метод applyMiddleware() из Redux, как показано ниже:

index.js

import thunkMiddleware from 'redux-thunk';
import { createLogger } from 'redux-logger';
import { createStore, applyMiddleware } from 'redux';
import { selectSubreddit, fetchPosts } from './actions';
import rootReducer from './reducers';

const loggerMiddleware = createLogger();

const store = createStore(
  rootReducer,
  applyMiddleware(
    thunkMiddleware, // позволяет нам отправлять функции
    loggerMiddleware // аккуратно логируем экшены
  )
);

store.dispatch(selectSubreddit('reactjs'));
store
  .dispatch(fetchPosts('reactjs'))
  .then(() => console.log(store.getState()));

Хорошая новость о преобразователях: они могут направлять результаты друг другу.

actions.jsfetch)

import fetch from 'cross-fetch';

export const REQUEST_POSTS = 'REQUEST_POSTS';
function requestPosts(subreddit) {
  return {
    type: REQUEST_POSTS,
    subreddit,
  };
}

export const RECEIVE_POSTS = 'RECEIVE_POSTS';
function receivePosts(subreddit, json) {
  return {
    type: RECEIVE_POSTS,
    subreddit,
    posts: json.data.children.map((child) => child.data),
    receivedAt: Date.now(),
  };
}

export const INVALIDATE_SUBREDDIT = 'INVALIDATE_SUBREDDIT';
export function invalidateSubreddit(subreddit) {
  return {
    type: INVALIDATE_SUBREDDIT,
    subreddit,
  };
}

function fetchPosts(subreddit) {
  return (dispatch) => {
    dispatch(requestPosts(subreddit));
    return fetch(
      `https://www.reddit.com/r/${subreddit}.json`
    )
      .then((response) => response.json())
      .then((json) =>
        dispatch(receivePosts(subreddit, json))
      );
  };
}

function shouldFetchPosts(state, subreddit) {
  const posts = state.postsBySubreddit[subreddit];
  if (!posts) {
    return true;
  } else if (posts.isFetching) {
    return false;
  } else {
    return posts.didInvalidate;
  }
}

export function fetchPostsIfNeeded(subreddit) {
  // Помните, что функция также получает getState(),
  // который позволяет вам выбрать, что отправить дальше.

  // Это полезно для того, чтобы избежать сетевого запроса,
  // если доступно закешированное значение.

  return (dispatch, getState) => {
    if (shouldFetchPosts(getState(), subreddit)) {
      // Диспатчим thunk из thunk!
      return dispatch(fetchPosts(subreddit));
    } else {
      // Дадим вызвавшему коду знать, что ждать нечего.
      return Promise.resolve();
    }
  };
}

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

index.js

store
  .dispatch(fetchPostsIfNeeded('reactjs'))
  .then(() => console.log(store.getState()));

Примечание о серверном рендеринге

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

Thunk middleware — это не единственный путь управления асинхронными экшенами в Redux.

  • Вы можете использовать redux-promise или redux-promise-middleware для отправки Promises вместо функций.
  • Вы можете использовать redux-observable для отправки Observables
  • Вы можете использовать мидлвар redux-saga для создания более комплексных асинхронных экшенов
  • Вы можете использовать мидлвар redux-pack для отправки асинхронных экшенов, базирующихся на промисах
  • Вы даже можете писать собственные мидлвары, для описания вызовов вашего API.

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

Подключение к UI

Отправка асинхронных экшенов не отличается от отправки синхронных экшенов, поэтому мы не будем обсуждать это в деталях. Почитайте использование в связке с React для знакомства с использованием Redux с React-компонентами. С полным исходным кодом, обсуждаемым в этом примере, вы можете ознакомится в примере Reddit API

Следующие шаги

Прочтите Асинхронный поток для резюмирования того, как асинхронные экшены вписываются в поток Redux.