Действия в очереди в Redux

У меня в настоящее время ситуация, при которой мне нужно, чтобы действия Redux выполнялись последовательно. Я взглянул на различные посредники, такие обезьяны-редуксы, которые кажутся прекрасными, если вы знаете, что последующие действия находятся в точке запуска корня (из-за отсутствия лучшего термина).

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

Я попытаюсь дать некоторый контекст в случае использования и реализации.

Использовать регистр

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

http://my.api.com/v1.0/lists/           // POST returns some id
http://my.api.com/v1.0/lists/<id>/items // API end points include id

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

+-------------+----------+
|  List Name  | Actions  |
+-------------+----------+
| My New List | Add Item |
+-------------+----------+

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

Потенциальное решение

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

Функциональность редуктора для создания списка может выглядеть примерно так:

case ADD_LIST:
  return {
    id: undefined, // To be filled on server response
    name: action.payload.name,
    actionQueue: []
  }

Затем, в создателе действия, мы вызываем действие вместо прямого его запуска:

export const createListItem = (name) => {
    return (dispatch) => {
        dispatch(addList(name));  // Optimistic action
        dispatch(enqueueListAction(name, backendCreateListAction(name));
    }
}

Для краткости предположим, что функция backendCreateListAction вызывает API-интерфейс fetch, который отправляет сообщения для удаления из списка с успехом/сбой.

Проблема

Что меня беспокоит, так это реализация метода enqueueListAction. Здесь я получаю доступ к состоянию, чтобы управлять продвижением очереди. Это выглядит примерно так (игнорируйте это соответствие по имени - на самом деле это использует clientId, но я стараюсь, чтобы этот пример был прост):

const enqueueListAction = (name, asyncAction) => {
    return (dispatch, getState) => {
        const state = getState();

        dispatch(enqueue(name, asyncAction));{

        const thisList = state.lists.find((l) => {
            return l.name == name;
        });

        // If there nothing in the queue then process immediately
        if (thisList.actionQueue.length === 0) {
            asyncAction(dispatch);
        } 
    }
}

Здесь предположим, что метод enqueue возвращает простое действие, которое вставляет действие async в список actionQueue.

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

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

const dequeueListAction = (name) => {
    return (dispatch, getState) => {
        dispatch(dequeue(name));

        const state = getState();
        const thisList = state.lists.find((l) => {
            return l.name === name;
        });

        // Process next action if exists.
        if (thisList.actionQueue.length > 0) {
            thisList.actionQueue[0].asyncAction(dispatch);
    }
}

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

Любая помощь приветствуется.

Ответы

Ответ 1

Посмотрите на это: https://github.com/gaearon/redux-thunk

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

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

Более подробный анализ можно найти здесь: http://redux.js.org/docs/advanced/AsyncActions.html

Весь кредит Дэну Абрамову

Ответ 2

У меня есть идеальный инструмент для того, что вы ищете. Когда вам нужен большой контроль над redux (особенно что-то асинхронное), и вам нужно, чтобы действия decux выполнялись последовательно, нет лучшего инструмента, чем Redux Sagas. Он построен на основе генераторов es6, что дает вам большой контроль, поскольку вы можете в некотором смысле приостановить свой код в определенных точках.

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

Поскольку Sagas использует генераторы, вы также можете с уверенностью убедиться, что ваши рассылки происходят в определенном порядке и происходят только при определенных условиях. Вот пример из их документации, и я проведу вас через него, чтобы проиллюстрировать, что я имею в виду:

function* loginFlow() {
  while (true) {
    const {user, password} = yield take('LOGIN_REQUEST')
    const token = yield call(authorize, user, password)
    if (token) {
      yield call(Api.storeItem, {token})
      yield take('LOGOUT')
      yield call(Api.clearItem, 'token')
    }
  }
}

Хорошо, сначала это выглядит немного запутанным, но эта сага определяет точный порядок последовательности входа в систему. Бесконечный цикл разрешен из-за природы генераторов. Когда ваш код достигнет yield, он остановится на этой строке и будет ждать. Это не будет продолжаться до следующего, пока вы не скажете об этом. Посмотрите, где написано yield take('LOGIN_REQUEST'). Сага будет ждать или ждать в этот момент, пока вы не отправите "LOGIN_REQUEST", после чего сага вызовет метод авторизации и перейдет до следующего урока. Следующий метод является асинхронным yield call(Api.storeItem, {token}), поэтому он не перейдет к следующей строке, пока этот код не будет разрешен.

Теперь, когда происходит волшебство. Сага снова остановится на yield take('LOGOUT'), пока вы не отправите файл LOGOUT в свое приложение. Это имеет решающее значение, поскольку, если вы снова должны отправить LOGIN_REQUEST перед LOGOUT, процесс входа в систему не будет вызван. Теперь, если вы отправите LOGOUT, он вернется к первому выходному сигналу и дождитесь, пока приложение снова отправит LOGIN_REQUEST.

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

Ответ 3

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

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

Что-то вроде этого, может быть? (не проверяйте, но вы получите идентификатор):

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

/* REDUCERS & ACTIONS */

// this "thunk" action creator is responsible for :
//   - creating the temporary list item in the store with some 
//     generated unique id
//   - dispatching the action to tell the store that a temporary list
//     has been created (optimistic update)
//   - triggering a POST request to save the list in the database
//   - dispatching an action to tell the store the list is correctly
//     saved
//   - triggering a POST request for saving items related to the old
//     list id and triggering the correspondant receiveCreatedItem
//     action
const createList = (name) => {

  const tempList = {
    id: uniqueId(),
    name
  }

  return (dispatch, getState) => {
    dispatch(tempListCreated(tempList))
    FakeListAPI
      .post(tempList)
      .then(list => {
        dispatch(receiveCreatedList(tempList.id, list))

        // when the list is saved we can now safely
        // save the related items since the API
        // certainly need a real list ID to correctly
        // save an item
        const itemsToSave = getState().items.filter(item => item.listId === tempList.id)
        for (let tempItem of itemsToSave) {
          FakeListItemAPI
            .post(tempItem)
            .then(item => dispatch(receiveCreatedItem(tempItem.id, item)))
        }
      )
  }

}

const tempListCreated = (list) => ({
  type: 'TEMP_LIST_CREATED',
  payload: {
    list
  }
})

const receiveCreatedList = (oldId, list) => ({
  type: 'RECEIVE_CREATED_LIST',
  payload: {
    list
  },
  meta: {
    oldId
  }
})


const createItem = (name, listId) => {

  const tempItem = {
    id: uniqueId(),
    name,
    listId
  }

  return (dispatch) => {
    dispatch(tempItemCreated(tempItem))
  }

}

const tempItemCreated = (item) => ({
  type: 'TEMP_ITEM_CREATED',
  payload: {
    item
  }
})

const receiveCreatedItem = (oldId, item) => ({
  type: 'RECEIVE_CREATED_ITEM',
  payload: {
    item
  },
  meta: {
    oldId
  }
})

/* given this state shape :
state = {
  lists: {
    ids: [ 'list1ID', 'list2ID' ],
    byId: {
      'list1ID': {
        id: 'list1ID',
        name: 'list1'
      },
      'list2ID': {
        id: 'list2ID',
        name: 'list2'
      },
    }
    ...
  },
  items: {
    ids: [ 'item1ID','item2ID' ],
    byId: {
      'item1ID': {
        id: 'item1ID',
        name: 'item1',
        listID: 'list1ID'
      },
      'item2ID': {
        id: 'item2ID',
        name: 'item2',
        listID: 'list2ID'
      }
    }
  }
}
*/

// Here i'm using a immediately invoked function just 
// to isolate ids and byId variable to avoid duplicate
// declaration issue since we need them for both
// lists and items reducers
const lists = (() => {
  const ids = (ids = [], action = {}) => ({
    switch (action.type) {
      // when receiving the temporary list
      // we need to add the temporary id 
      // in the ids list
      case 'TEMP_LIST_CREATED':
        return [...ids, action.payload.list.id]

      // when receiving the real list
      // we need to remove the old temporary id
      // and add the real id instead
      case 'RECEIVE_CREATED_LIST':
        return ids
          .filter(id => id !== action.meta.oldId)
          .concat([action.payload.list.id])
      default:
        return ids
    }
  })

  const byId = (byId = {}, action = {}) => ({
    switch (action.type) {
      // same as above, when the the temp list
      // gets created we store it indexed by
      // its temp id
      case 'TEMP_LIST_CREATED':
        return {
          ...byId,
          [action.payload.list.id]: action.payload.list
        }

      // when we receive the real list we first
      // need to remove the old one before
      // adding the real list
      case 'RECEIVE_CREATED_LIST': {
        const {
          [action.meta.oldId]: oldList,
          ...otherLists
        } = byId
        return {
          ...otherLists,
          [action.payload.list.id]: action.payload.list
        }
      }

    }
  })

  return combineReducers({
    ids,
    byId
  })
})()

const items = (() => {
  const ids = (ids = [], action = {}) => ({
    switch (action.type) {
      case 'TEMP_ITEM_CREATED':
        return [...ids, action.payload.item.id]
      case 'RECEIVE_CREATED_ITEM':
        return ids
          .filter(id => id !== action.meta.oldId)
          .concat([action.payload.item.id])
      default:
        return ids
    }
  })

  const byId = (byId = {}, action = {}) => ({
    switch (action.type) {
      case 'TEMP_ITEM_CREATED':
        return {
          ...byId,
          [action.payload.item.id]: action.payload.item
        }
      case 'RECEIVE_CREATED_ITEM': {
        const {
          [action.meta.oldId]: oldList,
          ...otherItems
        } = byId
        return {
          ...otherItems,
          [action.payload.item.id]: action.payload.item
        }
      }

      // when we receive a real list
      // we need to reappropriate all
      // the items that are referring to
      // the old listId to the new one
      case 'RECEIVE_CREATED_LIST': {
        const oldListId = action.meta.oldId
        const newListId = action.payload.list.id
        const _byId = {}
        for (let id of Object.keys(byId)) {
          let item = byId[id]
          _byId[id] = {
            ...item,
            listId: item.listId === oldListId ? newListId : item.listId
          }
        }
        return _byId
      }

    }
  })

  return combineReducers({
    ids,
    byId
  })
})()

const reducer = combineReducers({
  lists,
  items
})

/* REDUCERS & ACTIONS */

Ответ 4

Вот как я мог бы решить эту проблему:

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

В создании списка добавьте обещание идентификатора бэкэнд в кэш

CreatedListIdPromiseCache[localListId] = createBackendList({...}).then(list => list.id);

В элементе add, попробуйте получить идентификатор бэкэнда из магазина Redux. Если он не существует, попробуйте получить его от CreatedListIdCache. Возвращаемый идентификатор должен быть асинхронным, поскольку CreatedListIdCache возвращает обещание.

const getListIdPromise = (localListId,state) => {
  // Get id from already created list
  if ( state.lists[localListId] ) {
    return Promise.resolve(state.lists[localListId].id)
  }
  // Get id from pending list creations
  else if ( CreatedListIdPromiseCache[localListId] ) {
    return CreatedListIdPromiseCache[localListId];
  }
  // Unexpected error
  else {
    return Promise.reject(new Error("Unable to find backend list id for list with local id = " + localListId));
  }
}

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

// Create item, but do not attempt creation until we are sure to get a backend id
const backendListItemPromise = getListIdPromise(localListId,reduxState).then(backendListId => {
  return createBackendListItem(backendListId, itemData);
})

// Provide user optimistic feedback even if the item is not yet added to the list
dispatch(addListItemOptimistic());
backendListItemPromise.then(
  backendListItem => dispatch(addListItemCommit()),
  error => dispatch(addListItemRollback())
);

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


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

Ответ 5

У меня была аналогичная проблема с твоей. Мне нужна была очередь, чтобы гарантировать, что оптимистические действия были совершены или в конечном итоге совершены (в случае сетевых проблем) удаленному серверу в том же порядке, что и они были созданы, или откат, если это невозможно. Я обнаружил, что с Redux только для этого не подходит, в основном потому, что я считаю, что он не был разработан для этого, и сделать это с помощью promises самостоятельно может быть действительно сложной проблемой, поскольку, помимо того, что вам нужно управлять состоянием очереди как-то... ИМХО.

Я думаю, что предложение @Pcriulan об использовании редукс-саги было хорошим. На первый взгляд, redux-saga не предоставляет ничего, чтобы помочь вам, пока вы не дойдете до channels. Это открывает вам дверь для работы с concurrency другими способами, другими словами, CSP (см. Go или Clojure async, например), благодаря генераторам JS. Есть даже вопросы о том, почему названо в честь шаблона Saga, а не CSP haha ​​... в любом случае.

Итак, вот как сага может помочь вам в вашей очереди:

export default function* watchRequests() {
  while (true) {
    // 1- Create a channel for request actions
    const requestChan = yield actionChannel('ASYNC_ACTION');
    let resetChannel = false;

    while (!resetChannel) {
      // 2- take from the channel
      const action = yield take(requestChan);
      // 3- Note that we're using a blocking call
      resetChannel = yield call(handleRequest, action);
    }
  }
}

function* handleRequest({ asyncAction, payload }) {
  while (true) {
    try {
      // Perform action
      yield call(asyncAction, payload);
      return false;
    } catch(e) {

      if(e instanceof ConflictError) {
        // Could be a rollback or syncing again with server?
        yield put({ type: 'ROLLBACK', payload });
        // Store is out of consistency so
        // don't let waiting actions come through
        return true;
      } else if(e instanceof ConnectionError) {
        // try again
        yield call(delay, 2000);
      }

    }
  }
}

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

Надеюсь, что это поможет!