RxJS: show loading if request is slow

2020-07-26 10:58发布

问题:

I thought of using RxJS to solve elegantly this problem, but after trying various approaches, I couldn't find out how to do it...

My need is quite common: I do a Rest call, ie. I have a Promise. If the response comes quickly, I just want to use the result. If it is slow to come, I want to display a spinner, until the request completes. This is to avoid a flash of a the spinner, then the data.

Maybe it can be done by making two observables: one with the promise, the other with a timeout and showing the spinner as side effect. I tried switch() without much success, perhaps because the other observable doesn't produce a value.

Has somebody implemented something like that?

回答1:

Based on @PhiLho's answer, I wrote a pipeable operator, which does exactly that:

export function executeDelayed<T>(
    fn : () => void,
    delay : number,
    thisArg? : any
) : OperatorFunction<T, T> {
    return function executeDelayedOperation(source : Observable<T>) : Observable<T> {
        let timerSub = timer(delay).subscribe(() => fn());
        return source.pipe(
            tap(
                () => {
                    timerSub.unsubscribe();
                    timerSub = timer(delay).subscribe(() => fn());
                },
                undefined,
                () => {
                    timerSub.unsubscribe();
                }
            )
        );
    }
}

Basically it returns a function, which gets the Observable source.
Then it starts a timer, using the given delay.
If this timer emits a next-event, the function is called.
However, if the source emits a next, the timer is cancelled and a new one is startet.
In the complete of the source, the timer is finally cancelled. This operator can then be used like this:

this.loadResults().pipe(
    executeDelayed(
        () => this.startLoading(),
        500
    )
).subscribe(results => this.showResult())

I did not wirte many operators myself, so this operator-implementation might not be the best, but it works.
Any suggestions on how to optimize it are welcome :)

EDIT: As @DauleDK mentioned, a error won't stop the timer in this case and the fn will be called after delay. If thats not what you want, you need to add an onError-callback in the tap, which calls timerSub.unsubscribe():

export function executeDelayed<T>(
    fn : () => void,
    delay : number,
    thisArg? : any
) : OperatorFunction<T, T> {
    return function executeDelayedOperation(source : Observable<T>) : Observable<T> {
        let timerSub = timer(delay).subscribe(() => fn());
        return source.pipe(
            tap(
                () => {
                    timerSub.unsubscribe();
                    timerSub = timer(delay).subscribe(() => fn());
                },
                () => timerSub.unsubscribe(),   // unsubscribe on error
                () => timerSub.unsubscribe()
            )
        );
    }
}


回答2:

Here is an example that I have used. We assume here that you get the data that you want to send to the server as an Observable as well, called query$. A query coming in will then trigger the loadResults function, which should return a promise and puts the result in the results$ observable.

Now the trick is to use observable$.map(() => new Date()) to get the timestamp of the last emitted value.

Then we can compare the timestamps of the last query and the last response that came in from the server.

Since you also wanted to not only show a loading animation, but wanted to wait for 750ms before showing the animation, we introduce the delayed timestamp. See the comments below for a bit more explanation.

At the end we have the isLoading$ Observable that contains true or false. Subscribe to it, to get notified when to show/hide the loading animation.

const query$ = ... // From user input.

const WAIT_BEFORE_SHOW_LOADING = 750;

const results$ = query$.flatMapLatest(loadResults);

const queryTimestamp$ = query$.map(() => new Date());
const resultsTimestamp$ = results$.map(() => new Date());
const queryDelayTimestamp$ = (
    // For every query coming in, we wait 750ms, then create a timestamp.
    query$
    .delay(WAIT_BEFORE_SHOW_LOADING)
    .map(() => new Date())
);

const isLoading$ = (
    queryTimestamp$.combineLatest(
        resultsTimestamp$,
        queryDelayTimestamp$,
        (queryTimestamp, resultsTimestamp, delayTimestamp) => {
            return (
                // If the latest query is more recent than the latest
                // results we got we can assume that
                // it's still loading.
                queryTimestamp > resultsTimestamp &&
                // But only show the isLoading animation when delay has passed
                // as well.
                delayTimestamp > resultsTimestamp
            );
        }
    )
    .startWith(false)
    .distinctUntilChanged()
);


回答3:

OK, thinking more about it in my commuting, I found a solution...

You can find my experiment ground at http://plnkr.co/edit/Z3nQ8q

In short, the solution is to actually subscribe to the observable handing the spinner (instead of trying to compose it in some way). If the result of the Rest request comes before the observable fires, we just cancel the spinner's disposable (subscription), so it does nothing. Otherwise, the observable fires and display its spinner. We can then just hide it after receiving the response.

Code:

function test(loadTime)
{
  var prom = promiseInTime(loadTime, { id: 'First'}); // Return data after a while
  var restO = Rx.Observable.fromPromise(prom);

  var load = Rx.Observable.timer(750);
  var loadD = load.subscribe(
    undefined,
    undefined,
    function onComplete() { show('Showing a loading spinner'); });

  restO.subscribe(
    function onNext(v) { show('Next - ' + JSON.stringify(v)); },
    function onError(e) { show('Error - ' + JSON.stringify(e)); loadD.dispose(); },
    function onComplete() { show('Done'); loadD.dispose(); }
  );
}

test(500);
test(1500);

Not sure if that's an idiomatic way of doing this with RxJS, but it seems to work... Other solutions are welcome, of course.



回答4:

Just before fetching the data, ie. creating the spinner, set timeout for a function, which creates the spinner. Lets say you are willing to wait half a second, until showing spinner... it would be something like:

spinnerTimeout = setTimeout(showSpinner, 500)
fetch(url).then(data => {
  if (spinner) {
    clearTimeout(spinnerTimeout) //this is critical
    removeSpinnerElement()
  }
  doSomethingWith(data)
});

EDIT: if it's not obvious, clearTimer stops the showSpinner from executing, if the data arrived sooner than 500ms(ish).