Действия в очереди в 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);
}
}
}
}
Итак, интересная часть здесь заключается в том, как канал действует как буфер (очередь), который сохраняет "прослушивание" входящих действий, но не будет продолжать действия в будущем до тех пор, пока он не завершится с текущим. Возможно, вам придется пересмотреть свою документацию, чтобы лучше понять код, но я думаю, что это того стоит. Часть возвращаемого канала может работать или не работать для ваших нужд: мышление:
Надеюсь, что это поможет!