使用流实现去抖动的分块异步队列

时间:2020-12-22 10:50:59

标签: javascript typescript rxjs reactive-programming

我是响应式编程的新手,很好奇是否有更优雅的方式来实现去抖动的分块异步队列。你问什么是去抖动分块异步队列?好吧,也许它有一个更好的名字,但这个想法是在一段时间内可以多次调用异步函数。我们首先要消除对函数的调用,并将所有这些参数合并为一个参数。其次,该异步函数的所有执行都应以 FIFO 方式进行序列化。

这是我的实现,没有流、可观察或响应式编程,正如我所见:

const debouncedChunkedQueue = <T>(
  fn: (items: T[]) => Promise<void> | void,
  delay = 1000
) => {
  let items: T[] = [];
  let started = false;
  const start = async () => {
    started = true;
    while (items.length) {
      await sleep(delay);
      const chunk = items.splice(0, items.length);
      await fn(chunk);
    }
    started = false;
  };
  const push = (item: T) => {
    items.push(item);
    if (!started) start();
  };
  return { push };
};

https://codesandbox.io/s/priceless-sanne-dkrkw?file=/src/index.ts:87-550

3 个答案:

答案 0 :(得分:2)

我以 1:1 的方式遵循了您当前的实现,这导致代码比使用简单的 bufferTime 实现的代码更复杂。

您可以在 RxViz 中测试以下代码。

const { BehaviorSubject, fromEvent, of } = Rx;
const { buffer, delay, first, tap, mergeMap, exhaustMap } = RxOperators;

// Source of the queue - click to emit event
const items = fromEvent(document, 'click');

// a "pushback" source
const processing = new BehaviorSubject(false);

items.pipe(
  // we resubscribe to items to start only when an item is there
  buffer(
    // we take items again so we only
    // start if there are any items
    // to be processed and then we wait
    // 1 second
    items.pipe(
      // we do not start processing until
      // pending async call completes
      exhaustMap(() =>
        processing.pipe(
          // wait for processing end
          first(val => !val),
          // wait for 1 second
          delay(1000),
        ),
      ),
    ),
  ),
  tap(() => processing.next(true)),
  // basic mergeMap is okay, since we control that the next value
  // will come no sooner than this is completed
  mergeMap(items =>
    // async fn simulation
    of(items.length).pipe(delay(300 + Math.random() * 500)),
  ),
  tap(() => processing.next(false)),
);

不过,我们必须将状态保持在可观察范围之外,这真是太可惜了。

去抖动的ChunkedQueue

您可以在stackblitz

中测试以下代码
import { BehaviorSubject, Subject, of } from "rxjs";
import {
  buffer,
  delay,
  first,
  tap,
  mergeMap,
  exhaustMap
} from "rxjs/operators";

const debouncedChunkedQueue = <T>(
  fn: (items: T[]) => Promise<void> | void,
  delayMs = 1000
) => {
  // Source of the queue - click to emit event
  const items = new Subject<T>();

  // a "pushback" source
  const processing = new BehaviorSubject(false);

  items
    .pipe(
      // we resubscribe to items to start only when an item is there
      buffer(
        // we take items again so we only
        // start if there are any items
        // to be processed and then we wait
        // 1 second
        items.pipe(
          // we do not start processing until
          // pending async call completes
          exhaustMap(() =>
            processing.pipe(
              // wait for processing end
              first(val => !val),
              // wait for 1 second
              delay(delayMs)
            )
          )
        )
      ),
      tap(() => processing.next(true)),
      // basic mergeMap is okay, since we control that the next value
      // will come no sooner than this is completed
      mergeMap(
        items =>
          // TODO: Make sure to catch errors from the fn if you want the queue to recover
          // async fn simulation
          fn(items) || of(null)
      ),
      tap(() => processing.next(false))
    )
    .subscribe();
  return { push: (item: T) => items.next(item) };
};

答案 1 :(得分:2)

所以,我制作了一个可观察的怪物。

它比您的实现复杂得多,但在防御方面,它也更加强大。

这是一个自定义的 RxJS 运算符,应该可以与 RxJS 库的其余部分无缝协作。例如,如果您正在运行 HTTP 调用并收到错误,则可以使用 retryretryWhencatchError 等来处理错误并响应每个调用或整个调用操作员。

您可以在此操作符之前和之后链接其他操作符(以及其他可观察对象),以在数据使用之前或返回之后对其进行操作。

如果你决定提前取消你的函数调用,你可以只是错误源或取消订阅可观察对象(就像任何其他操作符一样)。如果可能,它会自行清理并取消任何中途操作(Vanilla Promises 无法取消)。

无论如何,是的,如果您只是为返回 void 的异步函数缓冲值,那么它是一个庞然大物,而且可能有点矫枉过正。

这是:

bufferedExhaustMap 运算符

此运算符根据最小缓冲区长度(以毫秒为单位的时间)和最小缓冲区大小来缓冲值。无论是否满足最小时间和缓冲区大小,它始终会耗尽当前预计的 observable。

这与exhaustMap非常相似,除了它在等待当前内部(投影)可观察对象完成时缓冲值而不是丢弃值。

/***
 * ObservableInput works with an Array, an array-like
 * object, a Promise, an iterable object, or an Observable-like object.
 */
function bufferedExhaustMap<T,R>(
  project: (v:T[]) => ObservableInput<R>, 
  minBufferLength = 0, 
  minBufferCount = 1
): OperatorFunction<T,R> {
  return s => new Observable(observer => {

    const idle = new BehaviorSubject<boolean>(true);
    const setIdle = (b) => () => idle.next(b);

    const buffer = (() => {
      const bufS = new Subject<(v:T[]) => T[]>();
      return {
        output: bufS.pipe(
          scan((acc, curr) => curr(acc), [])
        ),
        nextVal: (v:T) => bufS.next(curr => [...curr, v]),
        clear: () => bufS.next(_ => [])
      }
    })();

    const subProject = combineLatest(
      idle,
      idle.pipe(
        filter(v => !v),
        startWith(true),
        switchMap(_ => timer(minBufferLength).pipe(
          mapTo(true),
          startWith(false)
        ))
      ),
      buffer.output
    ).pipe(
      filter(([idle, bufferedByTime, buffer]) => 
        idle && bufferedByTime && buffer.length >= minBufferCount
      ),
      tap(setIdle(false)),
      tap(buffer.clear),
      map(x => x[2]),
      map(project),
      mergeMap(projected => from(projected).pipe(
        finalize(setIdle(true))
      ))
    ).subscribe(observer);

    const subSource = s.subscribe({
      next: buffer.nextVal,
      complete: observer.complete.bind(observer),
      error: e => {
        subProject.unsubscribe();
        observer.error(e);
      }
    });

    return {
      unsubscribe: () => {
        subProject.unsubscribe();
        subSource.unsubscribe();
      }
    }
  });
}

返回debouncedChunkedQueue

这是我将如何使用我构建的运算符实现您的功能。

function debouncedChunkedQueue<T>(
  fn: (items: T[]) => Promise<void>,
  delay = 1000
){
  const processSubject = new Subject<T>();
  processSubject.pipe(
    bufferedExhaustMap(fn, delay)
  ).subscribe();

  return {
    push : processSubject.next.bind(processSubject)
  }
}

虽然这确实抽象了 RxJS。相反,我会更倾向于使用反应式风格,并按原样使用运算符。无论你的承诺对你的程序状态做了什么(设置值、转换对象等),让它返回该信息并相应地反应

如果这对您来说听起来很痛苦,那么您最好不要使用这种方法。那也不错!各有各的。 :)

更新 # 1:更加自信bufferedExhaustMap

受到这里的另一个答案(由 kvetis 提供)的启发,我使用 bufferWhen 重新实现了这个运算符,并将其扩展为包含最小缓冲区大小。

这也会取消订阅源,而不是在缓冲时通过 exhaustMap 忽略源。这应该(理论上)更快,但我不确定它在实践中是否重要。

/***
 * Buffers, then projects buffered source values to an Observable which is merged in 
 * the output Observable only if the previous projected Observable has completed.
 ***/
function bufferedExhaustMap<T,R>(
  project: (v:T[]) => ObservableInput<R>, 
  minBufferLength = 0, 
  minBufferCount = 1
): OperatorFunction<T,R> {
  return s => defer(() => {
    const idle = new BehaviorSubject(true);
    const setIdle = (b) => () => idle.next(b);
    const shared = s.pipe(share());

    const nextBufferTime = () => forkJoin(
      shared.pipe(take(minBufferCount)),
      timer(minBufferLength),
      idle.pipe(first(v => v))
    ).pipe(
      tap(setIdle(false))
    );

    return shared.pipe(
      bufferWhen(nextBufferTime),
      map(project),
      mergeMap(projected => from(projected).pipe(
        finalize(setIdle(true))
      ))
    );
  });
}

也许最复杂的位是 nextBufferTime 工厂函数。它创建了一个 observable,当缓冲区应该被清除时发出,然后完成。

它通过合并 3 个 observable 并仅在所有三个 observable 完成后才完成。这样,内部 observable 完成算作满足条件。

  • 第一个条件:源发出了 minBufferCount 个值。
  • 第二个条件minBufferLength 的计时器已过。
  • 第三个​​条件:当前没有活动的预计可观察对象 (idle == true)。
const nextBufferTime = () => forkJoin(
  // Take minBufferCount from source then complete
  shared.pipe(take(minBufferCount)),
  // Wait minBufferLength milliseconds and then complete
  timer(minBufferLength),
  // Wait until idle emits true, then complete
  idle.pipe(first(v => v))
)

答案 2 :(得分:1)

<块引用>

我们希望首先消除对函数的调用,并将所有这些参数合并为一个参数。其次,该异步函数的所有执行都应以 FIFO 方式进行序列化。

我认为您已经很好地描述了如何使用流来编写它。

根据这句话,我相信你可以写一个自定义操作符,如下所示:

export function debouncedChunkedQueue<T>(
  fn: (items: T[]) => Observable<void>,
  delay = 1000
) {
  return (obs$: Observable<T>) =>
    obs$.pipe(
      bufferTime(delay),
      map(items => fn(items) || EMPTY),
      concatAll()
    );
}

为了演示这应该如何工作,我做了以下工作:

function mockApiCall<T>(items: T[]): Observable<void> {
  console.log(`mock API call for the items`, items);
  return of(undefined).pipe(delay(500));
}

interval(100)
  .pipe(debouncedChunkedQueue(mockApiCall))
  .subscribe(console.log);

输出(这里的前 3 个结果)如下:

mock API call for the items
[0, 1, 2, 3, 4, 5, 6, 7, 8]

mock API call for the items
[9, 10, 11, 12, 13, 14, 15, 16, 17, 18]

mock API call for the items
[19, 20, 21, 22, 23, 24, 25, 26, 27, 28]

这是一个现场演示:

https://stackblitz.com/edit/rxjs-qweh1g?devtoolsheight=60

顺便解释一下:

  • bufferTime 允许您在指定时间内将数据累积到数组中
  • 使用 map 创建一个 cold observable,一旦我们订阅它就会调用回调。所以基本上我们最终得到一个 observable 的 observable
  • concatAll 让我们通过订阅一个 observable 来打开 observable 的 observable 以确保它们按顺序运行