简单优雅的JavaScript代码片段(三):请求合并,成批发出

发布于:2023-06-05 ⋅ 阅读:(302) ⋅ 点赞:(0)
本系列的其他篇文章:简单优雅的JavaScript代码片段(一):异步控制简单优雅的JavaScript代码片段(二):流控和重试

场景说明

后端提供的接口具备批量查询能力(比如,同时查询10个资源的详情信息),但是调用者每次只需要请求一个资源的详情信息。

比如:

import React from "react";

const List = ({ ids }: { ids: string[] }) => {
  return (
    <div>
      {ids.map((id) => (
        <ResourceDetail key={id} id={id} />
      ))}
    </div>
  );
};

const ResourceDetail = ({ id }: { id: string }) => {
  // 在这个组件请求资源详情并渲染...
  // 注意在这个组件中,你只关心这一个资源的信息,你不再具备”整个列表“的数据视角。
};

// 后端接口支持同时请求多个资源的信息
declare function api(ids: string[]): Promise<Record<string, { details: any }>>;

这个时候,一般只有2个方案:

  • 直接使用批量接口来请求单个资源的信息。在ResourceDetail组件中,直接调用api来请求当前资源的信息:api([id]),只传入当前资源ID。

    • 好处是简单直接,易于理解;
    • 坏处是没有将接口本身的批量请求能力利用起来,发出过多请求,导致接口流控限制或效率低下。
  • 将请求逻辑提升至更高的组件层次,在具备”整个列表“的数据视角的时候进行批量请求。在上层的List组件中,批量请求多个资源的信息:api([id1, id2, ...]),然后将结果传递给子组件进行渲染。

    • 好处是将接口本身的批量请求能力利用起来;
    • 坏处是代码耦合紧密(ResourceDetail依赖父组件帮它请求资源信息);代码逻辑设计迎合技术因素,不符合直觉造成List组件职责扩大、逻辑复杂(比如接口每次最多只能支持10个资源批量请求,因此你要将列表分为10个一组,分别请求)。

有没有两全其美方案呢?既能让组件职责简单清晰,又能将接口本身的批量请求能力利用起来?

解决方案

这篇文章介绍一个工具函数,将一个【批量请求】的接口转换成【单个请求】的接口:wrapBatchProcess。示例用法:

// 先使用wrapBatchProcess工具将api转换成单个请求的接口
// const wrappedAPI: (input: string) => Promise<{details: any}>
const wrappedAPI = wrapBatchProcess<string, { details: any }>(
  async (inputs, onResult, onError) => {
    // 工具函数将「汇集」成一大批请求,调用你提供的这个回调函数
    // 因此你就可以在这个回调中同时处理一大批请求了!
    const result = await api(inputs.map(({ input }) => input))
    Object.keys(result).forEach((key) => {
      // 请求到结果以后,将结果提供给工具函数,然后工具函数就会将结果提供给调用者
      onResult(result[key], key)
    })
  },
  // getKey函数,在这个场景下比较简单。实现中的代码注释有解释
  (input) => input
)

// 调用wrappedAPI,每次只需要传入一个请求。工具会自动合并请求,通过api来批量发出。
// 结果到达以后,wrappedAPI会将【对应于这个请求的结果】返回回来
wrappedAPI(id) // Promise<{details: any}>

wrappedAPI本质上是一个请求的缓冲队列(buffer),虽然它一个一个地接受请求,但是它不会立即将请求发出,而是等待一段时间(debounce),将请求累积起来,然后将累积起来的请求成批发出。

wrapBatchProcess就是一个创建wrappedAPI的工具函数。用户传入一个函数来定义【如何处理一批请求】,然后它给用户返回wrappedAPI,一个一个地接收请求。

实现代码:

import debounce from "lodash.debounce";

/**
 * 「汇集」处理请求,成批处理。
 * 将「批量请求」接口封装成「逐个请求」的接口。
 * 让使用者享受「逐个处理」的简单性,同时在底层通过「批量请求」来获得效率提升。
 * 使用者只需要关心当前input的处理,而不需要关心这个input是如何与其他input一起成批请求的 (关注点分离)。
 */
export function wrapBatchProcess<InputItem, OutputItem>(
  // 之所以通过callback的方式来返回数据,是因为要支持分批多次返回(拿到一批响应就立刻返回给调用者),而不是等待所有结果到达以后再全部一次性返回
  fn: (
    inputs: { input: InputItem; key: string }[],
    /**
     * 返回结果的时候,需要返回key,与input的key对应,这样我们才能知道每个output对应于哪个input
     */
    onResult: (output: OutputItem, key: string) => void,
    onError: (error: any, key: string) => void
  ) => void,
  // input可能是一个复杂对象,而wrapBatchProcess需要一个string来标识一个请求
  getKey: (input: InputItem) => string
  /** 封装后函数的使用者不需要了解key的概念 */
): (input: InputItem) => Promise<OutputItem> {
  let buffer: Map<string, BufferItem> = new Map();

  const check = debounce(
    () => {
      if (buffer.size === 0) return;
      // 将整个buffer作为一批,发出请求,并清空buffer
      const batch = new Map(buffer);
      buffer = new Map();
      const inputs = Array.from(batch.values()).map((item) => ({
        key: item.key,
        input: item.input,
      }));
      fn(
        inputs,
        (output, key) => {
          const item = batch.get(key);
          if (!item) return;
          item.resolve(output);
          batch.delete(key);
        },
        (error, key) => {
          const item = batch.get(key);
          if (!item) return;
          item.reject(error);
          batch.delete(key);
        }
      );
    },
    // 等待若干毫秒作为一个请求收集窗口,然后将收集到的所有请求作为一批发出
    50,
    {
      leading: false,
      trailing: true,
      // 避免不断有请求到来,导致debounce一直无法被调用,这个参数可调
      maxWait: 200,
    }
  );

  function schedule(input: InputItem) {
    const key = getKey(input);
    // 如果已经有相同的input在buffer中,则不重复调度它,而是与前一个input共享同一个结果
    const existBufferItem = buffer.get(key);
    if (existBufferItem) return existBufferItem.promise;
    // 将input信息加入buffer中,准备调度
    const bufferItem: BufferItem = {
      input,
      key,
      ...createControllablePromise<OutputItem>(),
    };
    buffer.set(key, bufferItem);
    check();
    return bufferItem.promise;
  }

  return schedule;

  type BufferItem = {
    input: InputItem;
    key: string;
    promise: Promise<OutputItem>;
    resolve: (ret: OutputItem) => void;
    reject: (error: any) => void;
  };
}

function createControllablePromise<T>(): {
  promise: Promise<T>;
  resolve: (ret: T) => void;
  reject: (error: any) => void;
} {
  let result: any = {};
  result.promise = new Promise<T>((resolve, reject) => {
    result.resolve = resolve;
    result.reject = reject;
  });
  return result;
}
本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到