webentwicklung-frage-antwort-db.com.de

Warteschlangenaktionen in Redux

Ich habe momentan eine Situation, in der ich Redux Actions nacheinander ausführen muss. Ich habe mir verschiedene Middlewares angeschaut, wie ein Redux-Versprechen, das in Ordnung zu sein scheint wenn Sie wissen, welche aufeinanderfolgenden Aktionen sich an der Wurzel befinden (aus Mangel an einem besseren Begriff), wird eine Aktion ausgelöst .

Im Wesentlichen möchte ich eine Warteschlange mit Aktionen aufrechterhalten, die jederzeit hinzugefügt werden kann. Jedes Objekt verfügt über eine Instanz dieser Warteschlange, und abhängige Aktionen können entsprechend in die Warteschlange eingereiht, verarbeitet und zurückgenommen werden. Ich habe eine Implementierung, aber ich greife auf Status in meinen Action-Erstellern zu, was sich wie ein Anti-Pattern anfühlt.

Ich werde versuchen, einen Überblick über den Anwendungsfall und die Implementierung zu geben.

Anwendungsfall

Angenommen, Sie möchten einige Listen erstellen und auf einem Server beibehalten. Bei der Listenerstellung antwortet der Server mit einer ID für diese Liste, die in nachfolgenden API-Endpunkten verwendet wird, die sich auf die Liste beziehen:

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

Stellen Sie sich vor, der Kunde möchte optimistische Aktualisierungen dieser API-Punkte durchführen, um die UX zu verbessern - niemand mag Spinner. Wenn Sie also eine Liste erstellen, wird Ihre neue Liste sofort mit einer Option zum Hinzufügen von Elementen angezeigt:

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

Angenommen, jemand versucht, ein Element hinzuzufügen, bevor die Antwort des ersten Aufrufs zum Erstellen zurückgegeben wurde. Die Artikel-API hängt von der ID ab. Daher wissen wir, dass wir sie nicht aufrufen können, solange wir nicht über diese Daten verfügen. Möglicherweise möchten wir das neue Element jedoch optimistisch anzeigen und einen Aufruf an die Element-API auslösen, so dass es ausgelöst wird, sobald der Aufruf zum Erstellen erfolgt ist.

Eine mögliche Lösung

Um dies zu umgehen, verwende ich derzeit eine Methode, in der Sie jeder Liste eine Aktionswarteschlange geben, d. H. Eine Liste von Redux-Aktionen, die nacheinander ausgelöst werden.

Die Reduktionsfunktion für eine Listenerstellung könnte in etwa wie folgt aussehen:

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

In einem Aktionsersteller würden wir dann eine Aktion in die Warteschlange stellen, anstatt sie direkt auszulösen:

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

Der Kürze halber wird davon ausgegangen, dass die Funktion backendCreateListAction eine Abruf-API aufruft, die bei Erfolg/Misserfolg Nachrichten aus der Liste auslöst.

Das Problem

Was mir hier Sorgen bereitet, ist die Implementierung der Methode enqueueListAction. Hier greife ich auf den Staat zu, um die Weiterentwicklung der Warteschlange zu steuern. Es sieht ungefähr so ​​aus (ignorieren Sie diese Übereinstimmung mit dem Namen - diese verwendet in Wirklichkeit eine clientId, aber ich versuche, das Beispiel einfach zu halten):

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's nothing in the queue then process immediately
        if (thisList.actionQueue.length === 0) {
            asyncAction(dispatch);
        } 
    }
}

Angenommen, die Enqueue-Methode gibt eine einfache Aktion zurück, die eine asynchrone Aktion in die liste actionQueue einfügt. 

Das Ganze fühlt sich ein bisschen gegen den Strich an, aber ich bin mir nicht sicher, ob es einen anderen Weg gibt. Da ich in asyncActions versenden muss, muss ich außerdem die Versandmethode an sie weitergeben.

Es gibt einen ähnlichen Code in der Methode, um aus der Liste zu entschlüsseln, der die nächste Aktion auslöst, falls eine vorhanden ist:

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

Im Allgemeinen kann ich damit leben, aber ich bin besorgt, dass dies ein Anti-Muster ist und es in Redux vielleicht eine prägnantere, idiomatischere Methode gibt.

Jede Hilfe wird geschätzt.

31
MrHutch

Ich habe das perfekte Werkzeug für das, was Sie suchen. Wenn Sie eine starke Kontrolle über Redux benötigen (vor allem asynchron) und Redux-Aktionen benötigen, um nacheinander ablaufen zu können, gibt es kein besseres Werkzeug als Redux Sagas . Es ist auf Es6-Generatoren aufgebaut, die Ihnen viel Kontrolle bieten, da Sie Ihren Code gewissermaßen an bestimmten Stellen anhalten können. 

Die action-Warteschlange, die Sie beschreiben, wird als saga bezeichnet. Da es nun für die Verwendung mit Redux erstellt wurde, können diese Sagen ausgelöst werden, indem sie in Ihre Komponenten einplanen.

Da Sagas Generatoren verwenden, können Sie auch mit Sicherheit sicherstellen, dass Ihre Sendungen in einer bestimmten Reihenfolge auftreten und nur unter bestimmten Bedingungen erfolgen. Hier ist ein Beispiel aus ihrer Dokumentation, und ich werde Sie durchgehen, um zu veranschaulichen, was ich meine:

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

Okay, es sieht auf den ersten Blick etwas verwirrend aus, aber diese Saga definiert die genaue Reihenfolge, in der eine Login-Sequenz stattfinden muss. Die Endlosschleife ist aufgrund der Art der Generatoren zulässig. Wenn Ihr Code eine yield erreicht, bleibt er an dieser Zeile stehen und wartet. Es wird nicht bis zur nächsten Zeile fortgesetzt, bis Sie es anweisen. Schauen Sie also, wo yield take('LOGIN_REQUEST') steht. Die Saga gibt an diesem Punkt nach oder wartet, bis Sie 'LOGIN_REQUEST' absenden, woraufhin die Saga die Autorisierungsmethode aufruft und bis zur nächsten Abgabe weitergeht. Die nächste Methode ist eine asynchrone yield call(Api.storeItem, {token}), so dass die nächste Zeile erst angezeigt wird, wenn der Code aufgelöst wird. 

Nun, hier passiert die Magie. Die Saga endet wieder bei yield take('LOGOUT'), bis Sie LOGOUT in Ihrer Anwendung absenden. Dies ist von entscheidender Bedeutung, denn wenn Sie LOGIN_REQUEST vor LOGOUT erneut abschicken würden, würde der Anmeldeprozess nicht aufgerufen.Wenn Sie nun LOGOUT abschicken, wird eine Schleife zum ersten Ergebnis ausgeführt, und es wird erwartet, dass die Anwendung LOGIN_REQUEST abschickt nochmal.

Redux Sagas sind bei weitem eines meiner Lieblingswerkzeuge für Redux. Es gibt Ihnen so viel Kontrolle über Ihre Anwendung, und jeder, der Ihren Code liest, wird es Ihnen danken, da jetzt immer nur eine Zeile gelesen wird.

2
EJ Mason

Schauen Sie sich das an: https://github.com/gaearon/redux-thunk

Das Es alleine sollte nicht durch den Reduzierer gehen. Rufen Sie in Ihrem Aktionsersteller (Thunk) zuerst die Listen-ID ab, und then () führt einen zweiten Aufruf aus, um das Element der Liste hinzuzufügen. Danach können Sie verschiedene Aktionen ausführen, je nachdem, ob der Zusatz erfolgreich war oder nicht. 

Sie können dabei mehrere Aktionen auslösen, um zu berichten, wann die Server-Interaktion gestartet und abgeschlossen ist. Auf diese Weise können Sie eine Nachricht oder einen Spinner anzeigen, falls die Operation schwer ist und eine Weile dauern kann.

Eine ausführlichere Analyse finden Sie hier: http://redux.js.org/docs/advanced/AsyncActions.html

Alles Gute an Dan Abramov

1
Anthony De Smet

Ich hatte ein ähnliches Problem wie Sie. Ich brauchte eine Warteschlange, um zu gewährleisten, dass optimistische Aktionen (bei Netzwerkproblemen) in der gleichen Reihenfolge, in der sie erstellt wurden, an den Remote-Server übergeben werden, oder ein Rollback, falls dies nicht möglich ist. Ich habe festgestellt, dass mit Redux nur der Grund dafür liegt, im Grunde weil ich glaube, es war nicht dafür gedacht, und wenn man es allein mit Versprechungen alleine macht, kann dies ein schwieriges Problem sein. Außerdem müssen Sie Ihren Warteschlangenstatus irgendwie verwalten. .. MEINER BESCHEIDENEN MEINUNG NACH. 

Ich denke, @ Pcriulans Vorschlag zur Verwendung der Redux-Saga war gut. Auf den ersten Blick bietet Redux-Saga nichts, was Ihnen helfen kann, bis Sie zu channels gelangen. Dies eröffnet Ihnen die Möglichkeit, sich mit der Parallelität auf andere Weise zu befassen, wie dies bei anderen Sprachen der Fall ist, insbesondere bei CSP (siehe beispielsweise Go oder Clojures async), dank JS-Generatoren. Es gibt sogar Fragen warum wird nach dem Saga-Muster benannt und nicht nach CSP haha ​​... sowieso.

So kann Ihnen eine Saga mit Ihrer Warteschlange helfen:

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

    }
  }
}

Der interessante Teil hier ist also, wie der Kanal als Puffer (Warteschlange) fungiert, der auf eingehende Aktionen "überwacht" wird, aber zukünftige Aktionen nicht fortsetzt, bis er mit den aktuellen beendet wird. Sie müssen möglicherweise die Dokumentation durchgehen, um den Code besser zu verstehen, aber ich denke, es lohnt sich. Das Zurücksetzen des Kanalteils funktioniert möglicherweise nicht für Ihre Bedürfnisse: Denken:

Ich hoffe es hilft!

0
roboli

Sie müssen sich nicht mit Warteschlangenaktionen befassen. Dadurch wird der Datenfluss ausgeblendet und Ihre App wird mühseliger für das Debugging.

Ich empfehle Ihnen, einige temporäre IDs zu verwenden, wenn Sie eine Liste oder ein Element erstellen, und diese IDs dann zu aktualisieren, wenn Sie die tatsächlichen wirklich aus dem Geschäft erhalten.

So etwas vielleicht? (nicht getestet, aber Sie erhalten die ID):

EDIT: Ich habe zuerst nicht verstanden, dass die Elemente automatisch gespeichert werden müssen, wenn die Liste gespeichert wird. Ich habe den Action Creator createList bearbeitet.

/* 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 */
0

So würde ich dieses Problem angehen:

Stellen Sie sicher, dass jede lokale Liste einen eindeutigen Bezeichner hat. Ich spreche hier nicht von der Backend-ID. Name reicht wahrscheinlich nicht aus, um eine Liste zu identifizieren? Eine "optimistische" Liste, die noch nicht bestanden hat, sollte eindeutig identifizierbar sein, und der Benutzer kann versuchen, zwei Listen mit demselben Namen zu erstellen, selbst wenn es sich um einen Edge-Fall handelt.

Fügen Sie bei der Listenerstellung einem Cache eine Zusage der Backend-ID hinzu

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

Versuchen Sie beim Hinzufügen von Elementen, die Backend-ID vom Redux-Store abzurufen. Wenn es nicht existiert, versuchen Sie es von CreatedListIdCache zu bekommen. Die zurückgegebene ID muss asynchron sein, da CreatedListIdCache ein Versprechen zurückgibt.

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

Verwenden Sie diese Methode in Ihrer addItem, damit Ihr AddItem automatisch verzögert wird, bis die Backend-ID verfügbar ist

// 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())
);

Möglicherweise möchten Sie den CreatedListIdPromiseCache bereinigen, es ist jedoch für die meisten Anwendungen wahrscheinlich nicht sehr wichtig, wenn Sie nicht sehr strikte Speicheranforderungen haben.


Eine andere Option wäre, dass die Backend-ID am Frontend berechnet wird, etwa mit UUID. Ihr Backend muss nur die Einheit dieser ID überprüfen. Somit hätten Sie immer eine gültige Backend-ID für alle optimistisch erstellten Listen, auch wenn das Backend noch nicht antwortete.

0