Composing and sequencing multiple epics in redux-observable

我与影子孤独终老i 提交于 2019-12-21 05:57:01

问题


I have a problem that I don't know how to resolve.

I have two epics that do requests to api and update the store:

const mapSuccess = actionType => response => ({
  type: actionType + SUCCESS,
  payload: response.response,
});

const mapFailure = actionType => error => Observable.of({
  type: actionType + FAILURE,
  error,
});

const characterEpic = (action$, store) =>
  action$.ofType(GET_CHARACTER)
    .mergeMap(({ id }) => {
      return ajax(api.fetchCharacter(id))
        .map(mapSuccess(GET_CHARACTER))
        .catch(mapFailure(GET_CHARACTER));
    });

const planetsEpic = (action$, store) =>
  action$.ofType(GET_PLANET)
    .mergeMap(({ id }) => {
      return ajax(api.fetchPlanet(id))
        .map(mapSuccess(GET_PLANET))
        .catch(mapFailure(GET_PLANET));
    });

Now I have a simple scenario where I would like to create the third action that combines the two above, let's call it fetchCharacterAndPlanetEpic. How can I do it? I think in many cases (and in my) it's important that result of the first action is dispatched to the store before the second begins. That would be probably trivial to do with Promises and redux-thunk, but I can't somehow think of a way to do it with rxjs and redux-observable.

Thanks!


回答1:


Tomasz's answer works and has pros and cons (it was originally suggested in redux-observable#33). One potential issue is that it makes testing harder, but not impossible. e.g. you may have to use dependency injection to inject a mock of the forked epic.

I had started typing up an answer prior to seeing his, so I figured I might as well post it for posterity in case it's interesting to anyone.

I also previously answered another question which is very similar that may be helpful: How to delay one epic until another has emitted a value


We can emit the getCharacter(), then wait for a matching GET_CHARACTER_SUCCESS before we emit the getPlanet().

const fetchCharacterAndPlanetEpic = (action$, store) =>
  action$.ofType(GET_CHARACTER_AND_PLANET)
    .mergeMap(({ characterId, planetId }) =>
      action$.ofType(GET_CHARACTER_SUCCESS)
        .filter(action => action.payload.id === characterId) // just in case
        .take(1)
        .mapTo(getPlanet(planetId))
        .startWith(getCharacter(characterId))
    );

One potential negative of this approach is that theoretically the GET_CHARACTER_SUCCESS this epic receives could be a different one the exact one we were waiting for. The filter action.payload.id === characterId check protects you mostly against that, since it probably doesn't matter if it was specifically yours if it has the same ID.

To truly fix that issue you'd need some sort of unique transaction tracking. I personally use a custom solution that involves using helper functions to include a unique transaction ID. Something like these:

let transactionID = 0;

const pend = action => ({
  ...action,
  meta: {
    transaction: {
      type: BEGIN,
      id: `${++transactionID}`
    }
  }
});

const fulfill = (action, payload) => ({
  type: action.type + '_FULFILLED',
  payload,
  meta: {
    transaction: {
      type: COMMIT,
      id: action.meta.transaction.id
    }
  }
});

const selectTransaction = action => action.meta.transaction;

Then they can be used like this:

const getCharacter = id => pend({ type: GET_CHARACTER, id });

const characterEpic = (action$, store) =>
  action$.ofType(GET_CHARACTER)
    .mergeMap(action => {
      return ajax(api.fetchCharacter(action.id))
        .map(response => fulfill(action, payload))
        .catch(e => Observable.of(reject(action, e)));
    });

const fetchCharacterAndPlanetEpic = (action$, store) =>
  action$.ofType(GET_CHARACTER_AND_PLANET)
    .mergeMap(action =>
      action$.ofType(GET_CHARACTER_FULFILLED)
        .filter(responseAction => selectTransaction(action).id === selectTransaction(responseAction).id)
        .take(1)
        .mapTo(getPlanet(action.planetId))
        .startWith(getCharacter(action.characterId))
    );

The key detail is that the initial "pend" action holds a unique transaction ID in the meta object. So that initial action basically represents the pending request and is then used when someone wants to fulfill, reject, or cancel it. fulfill(action, payload)

Our fetchCharacterAndPlanetEpic code is kinda verbose and if we used something like this we'd be doing it a lot. So let's make a custom operator that handles it all for us.

// Extend ActionsObservable so we can have our own custom operators.
// In rxjs v6 you won't need to do this as it uses "pipeable" aka "lettable"
// operators instead of using prototype-based methods.
// https://github.com/ReactiveX/rxjs/blob/master/doc/pipeable-operators.md
class MyCustomActionsObservable extends ActionsObservable {
  takeFulfilledTransaction(input) {
    return this
      .filter(output =>
        output.type === input.type + '_FULFILLED' &&
        output.meta.transaction.id === input.meta.transaction.id
      )
      .take(1);
  }
}
// Use our custom ActionsObservable
const adapter = {
  input: input$ => new MyCustomActionsObservable(input$),
  output: output$ => output$
};
const epicMiddleware = createEpicMiddleware(rootEpic, { adapter });

Then we can use that custom operator in our epic nice and cleanly

const fetchCharacterAndPlanetEpic = (action$, store) =>
  action$.ofType(GET_CHARACTER_AND_PLANET)
    .mergeMap(action =>
      action$.takeFulfilledTransaction(action)
        .mapTo(getPlanet(action.planetId))
        .startWith(getCharacter(action.characterId))
    );

The transaction-style solution described here is truly experimental. In practice there are some warts with it I've noticed over the years and I haven't gotten around to thinking about how to fix them. That said, overall it's been pretty helpful in my apps. In fact, it can also be used to do optimistic updates and rollbacks too! A couple years ago I made this pattern and the optional optimistic update stuff into the library redux-transaction but I've never circled back to give it some love, so use at your own risk. It should be considered abandoned, even if I may come back to it.




回答2:


I've found a help in this github topic. First I had to create helper method that will allow me to combine epics together:

import { ActionsObservable } from 'redux-observable';

const forkEpic = (epicFactory, store, ...actions) => {
  const actions$ = ActionsObservable.of(...actions);
  return epicFactory(actions$, store);
};

Which allows me to call any epic with stubbed actions like:

const getCharacter = id => ({ type: GET_CHARACTER, id });
forkEpic(getCharacterEpic, store, getCharacter(characterId))

...and will return result Observable of that epic. This way I can combine two epics together:

export const getCharacterAndPlanet = (characterId, planetId) => ({
  type: GET_CHARACTER_AND_PLANET,
  characterId,
  planetId,
});

const fetchCharacterAndPlanetEpic = (action$, store) =>
  action$.ofType(GET_CHARACTER_AND_PLANET)
    .mergeMap(({ characterId, planetId }) =>
      forkEpic(characterEpic, store, getCharacter(characterId))
        .mergeMap((action) => {
          if (action.type.endsWith(SUCCESS)) {
            return forkEpic(planetsEpic, store, getPlanet(planetId))
                     .startWith(action);
          }
          return Observable.of(action);
        })
    );

In this example second request is called only if first ends with SUCCESS.



来源:https://stackoverflow.com/questions/48261438/composing-and-sequencing-multiple-epics-in-redux-observable

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!