Redux: отправляем асинхронность туда, где ей самое место

в 12:26, , рубрики: javascript, redux, redux middleware

Redux — технология относительно молодая. Чётких правил что, как и где использовать нет.
Есть рекомендации, но и их не все читают. Очень многие вообще используют Redux исключительно потому что «все так делают», что зачастую сводит его полезность к нулю, или вообще просто бессмысленно усложняет приложение и добавляет ему лишних ошибок.
Возможно, то что я здесь пишу, покажется для очень многих очевидным, но лично для меня таковым это не было — до всего пришлось доходить изрядно потоптавшись по граблям. И, как я сейчас замечаю, не только для меня: в последние полгода мне пришлось доделывать или модифицировать несколько начатых другими людьми проектов на React и ReactNative, которые использовали Redux.

И, так как заботливо разложенные грабли, которые пришлось вычищать из этих проектов, были практически те же самые, мне захотелось рассказать об этом.

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

Синхронный action:

export const DISABLE_WELCOME = 'DISABLE_WELCOME';
export function disableWelcome() {
  return {
    type: DISABLE_WELCOME,
    payload: {}
  }
}

и reducer для него:

export default function (state = INITIAL_STATE, action) {
   switch (action.type) {
     // много разных других reducer'ов
  case DISABLE_WELCOME:
    return {
       ...state, auth: {
         loggedIn: true,
         errorMessage: null,
         welcome: false
      }
   };
   default:
      return state;
  }
}

В общем, всё просто и очевидно, никаких подводных камней особо нет.

Теперь посмотрим на асинхронный вызов из того же проекта, в административной части в таблицу добавляется новый вид некой «подписки»:

export function createSubscription(props) {   // Create Subscription
  const request = axios.post(`${ROOT_URL}/subscription`, props,
    {
      headers: { Authorization: getAuthToken() }
    });
    return {
      type: CREATE_SUBSCRIPTION,
      payload: request
  }
}

и простенький reducer

  case CREATE_SUBSCRIPTION:
    return {...state, subscription:action.payload};

Всё сделано совершенно по инструкции. В качестве action передаётся promise, при инициализации redux'a в него добавляется библиотечное middleware redux-promise, reducer вызывается после получения ответа от сервера, вроде как всё нормально.

Но сразу бросаются в глаза две проблемы:

  1. Reducer вызывается только после получения ответа от сервера, а хорошо бы звать его два раза, в начале запроса и в конце (например чтобы сразу добавить в таблицу значение, которое создал пользователь, а не ждать ответа от сервера и только после этого обновлять UI — это чисто внешне выглядит значительно лучше)
  2. Те же самые строки с функцией getAuthToken() повторяются в том же файле actions.js около 40 раз.

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

Варварство? Варварство.
Работает? Работает.

Более правильный и чаще встречающийся подход предполагает использование асинхронного middleware которое бы вызывало бы reducer два раза, при вызове action и после получения ответа:

Длинный скучный код для async middleware практически из примера

export default function promiseMiddleware({ getState }) {
  return next => action => {
    if (isPromise(action.payload)) {

      const { type, payload, meta = {} } = action;
      next({ 
        ...action,
        payload,
        meta: {
          ...meta,
          sequence: 'begin' 
        }});

      payload.then(
        result => next({ 
            ...action,
            payload: result,
            meta: {
              ...meta,
              sequence: 'complete' 
            }}
          ))
        .catch(err => next({ 
            ...action, 
            payload: err,
            meta: {
              ...action.meta, 
              sequence: 'error' 
          }}
        ));

    } else {
      next(action);
    }
  };
}

То есть что мы здесь получаем: reducer вызывается два раза, один раз вначале (meta.sequence === 'begin') и в конце при успешном завершении запроса (sequence: 'complete') или при ошибке (sequence: 'error').

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

  const token = getState().getIn(['session', 'token']);
  if (token && typeof action.payload.set !== 'undefined') {
     action.payload.set('Authorization', token);
 };

Понятно, что выглядит не очень аппетитно — привязано к конкретному месту хранения токена в конкретном store (в данном случае это ещё и Immutable), привязано к конкретной библиотеке для http запросов, если в качестве action будет передана какая-нибудь другая функция с методом set (а название метода, признаемся, не самое редко встречающееся), последствия могут быть самыми непредсказуемыми, ну и так далее. Однако, тоже работает.

И, к сожалению, стандартные и везде описанные возможности для работы с асинхронными вызовами на этом заканчиваются. Дальше начинается кто во что горазд.

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

Другая проблема, когда у функции не один callback, а несколько. Всё, промизом здесь уже не обойдёшься. В результате я вижу dispatch, передающийся в функцию createAction чтобы создавать при поступлении нового callback'a ещё больше action'ов. Или вижу как весь кусок с websocket'ами, например, переезжает в какой-нибудь реактовский компонент из action'ов, в результате чего впоследствии несколько дней тратится на то чтобы понять, почему вдруг всё иногда перестаёт работать (а всего лишь компонент решил перемонтироваться).

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

Что делать если нужно много callback'ов...

const disconnect = createAction('DISCONNECT_MESSENGER',
    () => {
      client.unsubscribe('/messenger')
      client.disconnect();
    });

const wasDisconnected = createAction('WAS_DISCONNECTED_MESSENGER',
  () => client.unsubscribe('/messenger'));

const receive = createAction('RECEIVE_MESSAGE');

const connect = createAction('CONNECT_MESSENGER',
    ( token, dispatch ) => client.connectAsync({ auth: { headers: { Authorization: token } } })
       .then(() => {
          client.onDisconnect((willReconnect, log ) => dispatch(wasDisconnected({willReconnect, log})))
          return client.subscribeAsync('/messenger', message => dispatch(receive(message)))
      }),
    ( payload ) => payload );

Хендлер внутри реактовского компонета. Смотреть осторожно, может быть причинён вред здоровью

    dispatch(editStory(formData, id)).then((results) => {

      if (results.payload.data.error === true) {
        alert.error('Error', results.payload.data.message)
      }
      else if (results.payload.data.error === false) {

        this.setState({ loadingClassName: '' });
        this.close();
        this.props.fetchStory();
        dispatch(push('/story'));
        alert.success('Success', results.payload.data.message)
      }
    }).catch((err) => {
      dispatch(push('/story'));
      alert.error('Error', 'Oooops! Looks like something went wrong. Please try again after sometime.');
    })

Да, .then() от dispatch(). Оно работает, кстати…

Да, есть варианты использовать что-нибудь для создания последовательности action'ов или Thunk, но почему-то в реальности каждый раз я вижу именно такое.

Что делать

Отправляем всё в middleware. Целиком.

Action — это действие. Действие — это отправить данные на сервер или получить данные с сервера, а не «создать promise» — promise, как и библиотека для асинхронных запросов, будь это хоть fetch, хоть axios, хоть superagent — это всё внутренняя кухня, давайте уберём её из логики:

const requestMiddleware = 
  ({ getToken } = {}) => // Давайте передадим тут функцию для получения токена, всего один раз
  ({ getState }) => // И у нас здесь есть полный доступ к state!
  next => 
  action => {
         // обозначим то, что мы хотим обратиться именно к нашему middleware
         // с указав в payload use: 'request'. Можно придумать что-нибудь получше
    if (action.payload && action.payload.use === 'request') { 
      const { 
        payload, 
        type,
        meta = {} 
      } = action;

      const {
        url,
        method,
        data,
        query      
      } = payload;

      // И нам без разницы какая библиотека!
      // Заменим здесь - заменится для всех запросов.

      const myRequest = request(method, url);  

      if (typeof getToken === 'function' ) {
        // токен прикладываем тоже здесь. Так как нам удобно. 
        // А для его получения используем внешнюю функцию, чтоб особо не хардкодить
        myRequest.set('Authorization', getToken(getState()));
      }

         // Выставляем всё что надо

      if (query) {
        myRequest.query(query);
      }

      if (data) {
        requestObject.send(data);
      }
      

      myRequest.then(response => next({  // Зовём reducer если всё успешно
          ...action,
          payload: response.body,
          meta: {
            ...meta,
            sequence: 'complete' 
          } 
        })          
      ).catch(err => next({ // Зовём reducer если всё плохо
          ...action,
          payload: err, 
          meta: {
            ...action.meta, 
            sequence: 'error' 
        }}
      ));

      next({ // Зовём reducer в самом начале
        ...action,
        meta: {
          ...meta,
          sequence: 'begin'
        }});

    } else {
      next(action); // Не забываем об остальных
    }
  }

Теперь добавим наш middleware:

const options = {
  // наша функция для получения токена
  getToken = store => store.getIn(['session', 'token'])
}
const store = applyMiddleware(
  requestMiddleware(options)
)(createStore)(reducer);

И всё. Можно закрывать этот файл навсегда и пользоваться:

const myAction = ({ 
  type: 'GET_REMOTE_URL',
  payload: {
      use: 'request',
      method: 'GET',
      url: 'https://your.site/api'      
    }
});

Не сомневаюсь, что всё можно сделать и изящнее, и удобнее.

Но это самая простая часть, а если нам надо добавить ещё какие-то callback'и? Например, мы загружаем длинный файл и нам нужно обновлять store в зависимости от прогресса загрузки?

Да не вопрос:

const handleProgress = progress => next({ 
  ...action,
  request,
  payload: {
    progress: progress.percent
  },
  meta: {
    ...action.meta, 
    sequence: 'progress' 
  }
})

requestObject.on('progress', progress => handleProgress(progress))

Всё. Готово. Проверяйте в своём reducer'e sequence === 'progress'

Нужно добавить socket.io или что-нибудь подобное? Пожалуйста, точно так же.
Создайте, на все события вызывайте next() с нужными вам параметрами, и всё.

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

Очень удобно сделать отдельный middleware для задержек и таймеров — всё-таки всякие setInterval() в аккуратном коде аккуратного компонента смотрятся довольно чужеродно.

В ReactNative вы можете отправить туда Alert или вызов какого-нибудь нативного компонента типа react-native-image-picker — и если вдруг вам срочно придётся заменять его на какой-нибудь react-native-image-crop-picker (а подобные необходимости обычно возникают чуть чаще, чем хотелось бы...) — вы замените его всего в одном месте, а не в десятке.

Сделайте разные middleware для LocalStorage для веб-страницы и для AsyncStorage в ReactNative, но сделайте способ обращения к ним одинаковым — и вы сможете использовать одни и те же action и для сайта и для приложения.

В общем, в action оставляем только логику. Чем меньше там конкретной реализации — тем лучше. Оставим всю грязную работу для middleware.

Я собрал несколько своих middleware, которыми я пользуюсь постоянно, в библиотечку Redux Kittens, может быть кому-нибудь тоже пригодится.

Автор: Андрей Коринский

Источник


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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js