Spec-Zone.ru › Redux 3

Асинхронные действия

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

В этом руководстве мы создадим другое, асинхронное приложение. Оно будет использовать API Reddit, чтобы отобразить текущие заголовки для выбранного подсайта. Как асинхронность вписывается в поток Redux?

Действия

Когда вы вызываете асинхронный API, есть два важных момента во времени: момент начала вызова и момент получения ответа (или таймаут).

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

  • Действие, информирующее редукторы о начале запроса.

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

  • Действие, информирующее редукторы об успешном завершении запроса.

    Редукторы могут обработать это действие, объединив новые данные в состояние, которое они обрабатывают, и сбросив isFetching. Пользовательский интерфейс скрыл бы индикатор загрузки и отобразил полученные данные.

  • Действие, информирующее редукторы об ошибке запроса.

    Редукторы могут обработать это действие, сбросив isFetching. Кроме того, некоторые редукторы могут захотеть сохранить сообщение об ошибке, чтобы пользовательский интерфейс мог его отобразить.

Вы можете использовать специальное поле 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.

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

Синхронные создатели действий

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

actions.js

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
  }
}

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

Когда придёт время получить посты для какого-либо подсайта, мы отправим действие REQUEST_POSTS:

export const REQUEST_POSTS = 'REQUEST_POSTS'

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

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

Наконец, когда запрос к сети завершится, мы отправим 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»:

{
  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'
        }
      ]
    }
  }
}

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

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

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

Примечание по вложенным сущностям

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

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

{
  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. Оно поставляется в отдельном пакете под названием redux-thunk. Мы объясним, как работают средства в общем позже; пока есть только одна важная вещь, которую вам нужно знать: используя это конкретное средство, создатель действия может возвращать функцию вместо объекта действия. Таким образом, создатель действия становится thunk.

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

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

actions.js

import fetch from 'isomorphic-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()
  }
}

// Meet our first thunk action creator!
// Though its insides are different, you would use it just like any other action creator:
// store.dispatch(fetchPosts('reactjs'))

export function fetchPosts(subreddit) {
  // Thunk middleware knows how to handle functions.
  // It passes the dispatch method as an argument to the function,
  // thus making it able to dispatch actions itself.

  return function (dispatch) {
    // First dispatch: the app state is updated to inform
    // that the API call is starting.

    dispatch(requestPosts(subreddit))

    // The function called by the thunk middleware can return a value,
    // that is passed on as the return value of the dispatch method.

    // In this case, we return a promise to wait for.
    // This is not required by thunk middleware, but it is convenient for us.

    return fetch(`https://www.reddit.com/r/${subreddit}.json`)
      .then(
        response => response.json(),
        // Do not use catch, because that will also catch
        // any errors in the dispatch and resulting render,
        // causing an loop of 'Unexpected batch number' errors.
        // https://github.com/facebook/react/issues/6895
        error => console.log('An error occured.', error)
      )
      .then(json =>
        // We can dispatch many times!
        // Here, we update the app state with the results of the API call.

        dispatch(receivePosts(subreddit, json))
      )
  }
}
Примечание по fetch

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

// Do this in every file where you use `fetch`
import fetch from 'isomorphic-fetch'

Внутри она использует whatwg-fetch polyfill на клиенте и node-fetch на сервере, так что вам не придётся изменять вызовы API, если вы измените своё приложение на универсальное.

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

// Do this once before any other code in your app
import 'babel-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, // lets us dispatch() functions
    loggerMiddleware // neat middleware that logs actions
  )
)

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

Приятно, что thunks могут отправлять результаты друг друга:

actions.js

import fetch from 'isomorphic-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()
  }
}

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) {
  // Note that the function also receives getState()
  // which lets you choose what to dispatch next.

  // This is useful for avoiding a network request if
  // a cached value is already available.

  return (dispatch, getState) => {
    if (shouldFetchPosts(getState(), subreddit)) {
      // Dispatch a thunk from thunk!
      return dispatch(fetchPosts(subreddit))
    } else {
      // Let the calling code know there's nothing to wait for.
      return Promise.resolve()
    }
  }
}

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

index.js

store
  .dispatch(fetchPostsIfNeeded('reactjs'))
  .then(() => console.log(store.getState()))
Примечание о рендеринге на сервере

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

Средства миддлвары Thunk не единственный способ организовать асинхронные действия в Redux:

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

Вам решать, какие варианты попробовать, выбрать удобную конвенцию и следовать ей, с использованием или без миддлвары.

Подключение к пользовательскому интерфейсу

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

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

Прочитайте Асинхронный поток, чтобы напомнить, как асинхронные действия вписываются в поток Redux.

© 2015–2017 Dan Abramov
Licensed under the MIT License.
http://redux.js.org/docs/advanced/AsyncActions.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API