在RxJS中加载具有延迟和抗闪烁的指示

时间:2019-05-29 08:20:36

标签: angular rxjs

我想使用RxJS(版本6)实现加载指示。在异步数据调用完成之前,组件中将显示一个加载指示器(微调器)。我要执行一些规则(这些规则是否正确,可能是另一个问题,也许要发表评论):

  • 如果数据在1秒内成功到达,则不应显示任何指示符(并且数据应正常呈现)
  • 如果呼叫在1秒钟内失败,则不应显示任何指示符(并且应显示错误消息)
  • 如果数据到达的时间晚于1秒,则应显示至少1秒钟的指示器(为防止旋转器闪烁,应在之后呈现数据)
  • 如果呼叫在1秒钟内失败,则指示器应至少显示1秒钟
  • 如果通话时间超过10秒,则应取消通话(并显示错误消息)

我正在Angular项目中实现此功能,但我相信这不是Angular特有的。

我已经找到了这个难题的一些片段,但是我需要帮助将它们组装在一起。

In this SO answer中存在一种操作员的实现方式,它会延迟显示加载指示器。

this article中介绍了Angular的一个不错但不完整的实现。

this Medium article中描述了在最短的时间内显示加载指示符。

4 个答案:

答案 0 :(得分:1)

首先,卢卡斯,这是一个很好的问题!

前言:虽然还有其他方法可以实现您的要求,但我只是想让我的答案更像是详细的分步教程

为方便起见,让我们想象一下,我们有一个执行请求并向我们返回字符串消息的Observable的方法:

const makeARequest: () => Observable<{ msg: string }>;

现在我们可以声明将包含结果的Observables:

// Our result will be either a string message or an error
const result$: Observable<{ msg: string } | { error: string }>;

和加载指示:

// This stream will control a loading indicator visibility
// if we get a true on the stream -- we'll show a loading indicator
// on false -- we'll hide it
const loadingIndicator$: Observable<boolean>;

现在,解决#1

  

如果数据在1秒钟内成功到达,则不应显示任何指示符(并且数据应正常呈现)

我们可以将timer设置1秒钟,然后将该计时器事件转换为true值,这意味着将显示加载指示器。 takeUntil将确保如果result$在1秒之前到来-我们将不会显示加载指示器:

const showLoadingIndicator$ = timer(1000).pipe(
  mapTo(true),       // turn the value into `true`, meaning loading is shown
  takeUntil(result$) // emit only if result$ wont emit before 1s
);

#2

  

如果呼叫在1秒内失败,则不应显示任何指示符(并且应显示错误消息)

尽管第一部分将由#1解决,但要显示错误消息,我们需要从源流中捕获错误并将其转换为某种{ error: 'Oops' }catchError运算符将使我们做到这一点:

result$ = makeARequest().pipe(
  catchError(() => {
    return of({ error: 'Oops' });
  })
)

您可能已经注意到我们在两个地方都使用了result$。这意味着我们将对同一请求Observable进行两个预订,这将发出两个请求,这不是我们想要的。为了解决这个问题,我们可以简单地share在订户中观察到这一点:

result$ = makeARequest().pipe(
  catchError(() => { // an error from the request will be handled here
    return of({ error: 'Oops' });
  }),
  share()
)

#3

  

如果数据晚于1秒钟到达,则应显示至少1秒钟的指示器(为防止旋转器闪烁,应在以后渲染数据)

首先,我们有一种方法可以打开加载指示器 ,尽管我们目前没有将其关闭 。让我们使用result$流上的事件作为通知,我们可以隐藏加载指示符。收到结果后,我们可以隐藏指标:

// this we'll use as an off switch:
result$.pipe( mapTo(false) )

因此我们可以merge进行开/关切换:

const showLoadingIndicator$ = merge(
  // ON in 1second
  timer(1000).pipe( mapTo(true), takeUntil(result$) ),

  // OFF once we receive a result
  result$.pipe( mapTo(false) )
)

现在,我们需要切换加载指示器 on off ,尽管我们需要摆脱闪烁的加载指示器并至少显示1秒钟。我猜,最简单的方法是将{em> off 开关的值combineLatest和2秒的timer

const showLoadingIndicator$ = merge(
  // ON in 1second
  timer(1000).pipe( mapTo(true), takeUntil(result$) ),

  // OFF once we receive a result, yet at least in 2s
  combineLatest(result$, timer(2000)).pipe( mapTo(false) )
)

注意::如果结果是在第二秒之前收到的,则此方法可能会在2秒时为我们提供冗余的 off 开关。我们待会再处理。

#4

  

如果呼叫在1秒钟内失败,则指示器应至少显示1秒钟

我们对#3的解决方案已经具有防闪代码,并且在#2中,我们已经处理了流抛出错误的情况,因此在这里很好。

#5

  

如果通话时间超过10秒,则应取消通话(并显示错误消息)

为帮助我们取消长时间运行的请求,我们有一个timeout运算符:如果可观察的源在给定时间内不发出值,它将抛出一个错误

result$ = makeARequest().pipe(
  timeout(10000),     // 10 seconds timeout for the result to come
  catchError(() => {  // an error from the request or timeout will be handled here
    return of({ error: 'Oops' });
  }),
  share()
)

我们快完成了,只需要一点改进。让我们以一个showLoadingIndicator$值开始我们的false流,这表明我们没有在开始时显示加载程序。并使用distinctUntilChanged来省略冗余的 off off 开关,这些开关由于我们在#3中的方法而可以获得。

总结所有事情,以下是我们已经取得的成就:

const { fromEvent, timer, combineLatest, merge, throwError, of } = rxjs;
const { timeout, share, catchError, mapTo, takeUntil, startWith, distinctUntilChanged, switchMap } = rxjs.operators;


function startLoading(delayTime, shouldError){
  console.log('====');
  const result$ = makeARequest(delayTime, shouldError).pipe(
    timeout(10000),     // 10 seconds timeout for the result to come
    catchError(() => {  // an error from the request or timeout will be handled here
      return of({ error: 'Oops' });
    }),
    share()
  );
  
  const showLoadingIndicator$ = merge(
    // ON in 1second
    timer(1000).pipe( mapTo(true), takeUntil(result$) ),
  
    // OFF once we receive a result, yet at least in 2s
    combineLatest(result$, timer(2000)).pipe( mapTo(false) )
  )
  .pipe(
    startWith(false),
    distinctUntilChanged()
  );
  
  result$.subscribe((result)=>{
    if (result.error) { console.log('Error: ', result.error); }
    if (result.msg) { console.log('Result: ', result.msg); }
  });

  showLoadingIndicator$.subscribe(isLoading =>{
    console.log(isLoading ? '⏳ loading' : ' free');
  });
}


function makeARequest(delayTime, shouldError){
  return timer(delayTime).pipe(switchMap(()=>{
    return shouldError
      ? throwError('X')
      : of({ msg: 'awesome' });
  }))
}
<b>Fine requests</b>

<button
 onclick="startLoading(500)"
>500ms</button>

<button
 onclick="startLoading(1500)"
>1500ms</button>

<button
 onclick="startLoading(3000)"
>3000ms</button>

<button
 onclick="startLoading(11000)"
>11000ms</button>

<b>Error requests</b>

<button
 onclick="startLoading(500, true)"
>Err 500ms</button>

<button
 onclick="startLoading(1500, true)"
>Err 1500ms</button>

<button
 onclick="startLoading(3000, true)"
>Err 3000ms</button>

<script src="https://unpkg.com/rxjs@6.5.2/bundles/rxjs.umd.min.js"></script>

希望这会有所帮助

答案 1 :(得分:1)

这是另一个版本。该查询使用timeout于10s结束查询。并使用throttleTime防止加载程序闪烁。它也只预订一次查询。它产生一个可观察的对象,该对象将发出showLoader布尔值,并最终发出查询结果(或错误)。

// returns Observable<{showLoader: boolean, error: Error, result: T}>
function dataWithLoader(query$) {
   const timedQuery$ = query$.pipe(
       // give up on the query with an error after 10s
       timeout(10000),
       // convert results into a successful result
       map(result => ({result, showLoader: false})),
       // convert errors into an error result
       catchError(error => ({error, showLoader: false})
   );

   // return an observable that starts with {showLoader: false}
   // then emits {showLoader: true}
   // followed by {showLoader: false} when the query finishes
   // we use throttleTime() to ensure that is at least a 1s
   // gap between emissions.  So if the query finishes quickly
   // we never see the loader
   // and if the query finishes _right after_ the loader shows
   // we delay its result until the loader has been
   // up for 1 second
   return of({showLoader: false}, {showLoader: true}).pipe(
       // include the query result after the showLoader true line
       concat(timedQuery$),
       // throttle emissions so that we do not get loader appearing
       // if data arrives within 1 second
       throttleTime(1000, asyncScheduler, {leading:true, trailing: true}),
       // this hack keeps loader up at least 1 second if data arrives
       // right after loader goes up
       concatMap(x => x.showLoader ? EMPTY.pipe(delay(1000), startWith(x)) : of(x))
   );
}

答案 2 :(得分:0)

您可以尝试按照以下方式构建蒸汽。

(假设data$是您可观察到的数据,它在数据出现而出错时发出,而在失败时发出)

import { timer, merge, of } from 'rxjs';
import { mapTo, map, catchError, takeUntil, delay, switchMap } from 'rxjs/operators'


const startTime = new Date();
merge(
  data$.pipe(
    takeUntil(timer(10000)),
    map((data) => ({ data, showSpinner: false, showError: false })),
    catchError(() => of({ data: null, showSpinner: false, showError: true })),
    switchMap((result) => {
      const timeSinceStart = (new Date).getTime() - startTime.getTime();
      return timeSinceStart > 1000 && timeSinceStart < 2000 ? of(result).pipe(delay(2000 - timeSinceStart)) : of(result)
    }),
  )
  timer(1000).pipe(
    mapTo({ data: null, showSpinner: true, showError: false }),
    takeUntil(data$)
  ),
  timer(10000).pipe(
    mapTo({ data: null, showSpinner: false, showError: true }),
    takeUntil(data$)
  )
).subscribe(({ data, showSpinner, showError }) => {
   // assign the values to relevant properties so the template can
   // show either data, spinner, or error

});



答案 3 :(得分:0)

编辑:我的旧答案有错误...

我现在构建了一个可运行的管道运算符,但是它很大。也许有人可以提供一些改进:)

preDelay是显示加载指示符之前的毫秒数。 postDelay是加载指示器至少可见的毫秒数。

const prePostDelay = (preDelay: number, postDelay: number) => (source: Observable<boolean>) => {
  let isLoading = false; // is some loading in progress?
  let showingSince = 0; // when did the loading start?

  return source.pipe(
    flatMap(loading => {

      if (loading) { // if we receive loading = true
        if (!isLoading) { // and loading isn't already running
          isLoading = true; // then set isLoading = true

          return timer(preDelay).pipe( // and delay the response
            flatMap(_ => {
              if (isLoading) { // when delay is over, check if we're still loading
                if (showingSince === 0) { // and the loading indicator isn't visible yet
                  showingSince = Date.now(); // then set showingSince
                  return of(true); // and return true
                }
              }

              return EMPTY; // otherwise do nothing
            })
          );
        }
      } else { // if we receive loading = false
        if (isLoading) {
          isLoading = false;

          // calculate remaining time for postDelay
          const left = postDelay - Date.now() + showingSince;
          if (left > 0) { // if we need to run postDelay
            return timer(left).pipe( // then delay the repsonse
              flatMap(_ => {
                if (!isLoading) { // when delay is over, check if no other loading progress started in the meantime
                  showingSince = 0;
                  return of(false);
                }

                return EMPTY;
              })
            );
          } else { // if there's no postDelay needed
            showingSince = 0;
            return of(false);
          }
        }
      }

      return EMPTY; // else do nothing
    })
  );
}

用法:

loadingAction1 = timer(1000, 2000).pipe(
  take(2),
  map(val => val % 2 === 0)
);

loadingAction2 = timer(2000, 2000).pipe(
  take(2),
  map(val => val % 2 === 0)
);

loadingCount = merge([loadingAction1, loadingAction2]).pipe(
  scan((acc, curr) => acc + (curr ? 1 : -1), 0)
);

loading = loadingCount.pipe(
  map(val => val !== 0)
);

loading.pipe(
  prePostDelay(500, 1000)
).subscribe(val => console.log("show loading indicator", val));