简单优雅的JavaScript代码片段文章系列:
简单优雅的JavaScript代码片段(一):异步控制
简单优雅的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
,一个一个地接收请求。
优势
封装后的api非常简单:(input) => Promise<TheOutput>
。因此api使用者(ResourceDetail)只需要简单地调用wrappedAPI
,就可以拿到它想要的数据!
使用者(ResourceDetail)不需要关心【请求是如何合并发出的】,复杂的请求合并逻辑被抽离到了【wrapBatchProcess的调用代码】中,这些代码完全可以放在其他的文件中(关注点分离)。不会污染ResourceDetail和List组件。
接口本身的批量请求能力被充分利用,提升请求效率,减少请求数量。
实现代码
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;
}
适配“接口批量能力”
很多时候,虽然后端接口支持在一个请求中同时查询多个资源ID的信息,但是这个支持的数量也会有一个上限。比如每个请求最多只支持查询10个资源ID。
那么我们在调用api之前也需要做对应的适配:
const wrappedAPI = wrapBatchProcess<string, { details: any }>(
async (inputs, onResult, onError) => {
// 由于api接口每次最多只能支持10个资源查询,而inputs数组可能很多
// 因此要先将inputs切分成多个组,每10个资源一组,确保api能接受
const groups: string[][] = splitArrayIntoGroups(
inputs.map(({ input }) => input),
10
);
groups.forEach(async (group: string[]) => {
const result = await api(group);
Object.keys(result).forEach((key) => {
onResult(result[key], key);
});
});
},
(input) => input
);
function splitArrayIntoGroups<T>(array: T[], groupSize: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < array.length; i += groupSize) {
const group = array.slice(i, i + groupSize);
result.push(group);
}
return result;
}
可组合性
这个工具函数可以与简单优雅的JavaScript代码片段(二):流控和重试介绍的工具函数组合使用。因为它们本质上都是”函数转换器“(将一个函数转换成另一个函数,即高阶函数)。
比如,在我们前面介绍的”接口批量能力有限“的例子中,假如一次性到来了110个资源ID,那么在成批发出的时候,就会同时发出11个请求(每个请求包含10个资源ID查询)。
但是我们的api
有10次/秒
的流控限制。如何避免超出这个限制?
你可以利用上篇文章介绍的wrapFlowControl
,将api
增强成apiWithFlowControl
(适配流控的请求方法):
const apiWithFlowControl = wrapFlowControl(api, 10);
const wrappedAPI = wrapBatchProcess<string, { details: any }>(
async (inputs, onResult, onError) => {
const groups: string[][] = splitArrayIntoGroups(
inputs.map(({ input }) => input),
10
);
groups.forEach(async (group: string[]) => {
// 这一行替换成apiWithFlowControl即可!
const result = await apiWithFlowControl(group);
Object.keys(result).forEach((key) => {
onResult(result[key], key);
});
});
},
(input) => input
);
这样,你获得了wrapBatchProcess的所有好处,同时还能确保它会在流控允许的范围内调度api!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。