当我在Shopee中时,有一个有趣的案例,关于触发某些API请求的React组件,并且该组件的一长长列表经常出现在页面中。结果,客户端在短时间内发送了许多请求,不仅放慢了界面,而且也大大占用了服务器资源。
为了改善这一点,我的聪明队友创建了一个我非常喜欢的“批处理”解决方案,并在稍后采访中使用了这个想法。
我们是如何实现这一目标的?
让我首先使用一些详细的设置回顾一下情况:
- 有一个
<ListItem/>
组件在某些事件(例如,鼠标enter)下发送带有强制性参数的apiGET /item
,例如项目ID:
import React, { useCallback, useState } from 'react';
// Proper error handling is ignored here...
const fetchItem = id => fetch(`/item?id=${id}`).then(res => res.json());
export function ListItem({ id }) {
const [item, setItem] = useState({});
const onMouseEnter = useCallback(() => {
fetchItem(id).then(setItem);
}, [id]);
return <div onMouseEnter={onMouseEnter} >{item.name}</div>;
}
-
<ListItem/>
组件用于显示一个长列表,例如:
import React from 'react';
import { ListItem } from './ListItem';
export function List({ ids }) {
return (
<div>
{ids.map(id =>
<ListItem id={id} />
)}
</div>
);
}
现在,您可能会质疑不是仅在<List/>
组件中发送诸如GET /items?ids=1,2,3
之类的列表请求的解决方案吗?好吧,在这里并非如此,因为仅在用户触发特定事件时,请求才发送。但是您越来越接近点 - 而不是用不同的参数反复反复的GET /item?id=xxx
,最好找到一种发送GET /items?ids=1,2,3
的方法。
由于这些请求是在不同时间发送的,这是做出的重要决定:在哪个条件下,我们应结合请求?
这里有2个想法:
- 结合一定期内触发的请求;
- 当请求队列达到一定长度时,请组合请求。
时序是考虑接口正在等待请求的响应数据的第一个因素,我们不会让用户等待太久。
排队请求长度但是,更多取决于服务器端。他们一次可以处理多少个项目(考虑每个项目可能需要额外的信息来处理)?是否有任何请求URL(用于获取请求)或身体尺寸限制(nginx这样的负载平衡器具有默认设置,有时也是框架集限制)?
通过时间结合
说到时间和要求,有两种众所周知的现有策略:throttle and debounce。
调试将函数调用延迟到定义的时间段,以避免不必要的调用。它可以在按钮点击和关键事件中效果很好。
节气门只要活动触发器处于活动状态,就会定期调用回调函数。它更适合连续事件。
在这里,我将通过节气门启发的策略演示。
首先,让我们添加一个fetchItems()
助手:
const fetchItems = ids => fetch(`/item?ids=${ids.join(',')}`).then(res => res.json());
然后调整原始fetchItem()
将单个参数存储到队列中:
const queue = [];
const fetchItem = id => {
queque.push(id);
// TODO: flush the queue
};
并用计时器延迟200ms
。
const queue = [];
const fetchItem = id => {
queue.push(id);
if (queue.length === 1) {
setTimeout(() => {
fetchItems(quque);
queue.length = 0;
}, 200);
}
}
您可能会注意到,我只有在参数开始排队时才创建一个计时器,并在发送批处理请求后清除队列。
现在,它应该适用于不希望响应的请求(例如记录用户行为)。如果我们想处理响应,我们可能会创建一个Promise
并以稍后的方式缓存:
const queue = [];
let task;
const fetchItem = id => {
queue.push(id);
if (queue.length === 1) {
task = new Promise((resolve) => {
setTimeout(() => {
fetchItems(quque);
queue.length = 0;
}, 200);
});
}
return task;
}
承诺正在包装计时器,每个单个fetchItem()
“请求”都返回了这一承诺。结果,一旦这项承诺解决:
您可能会在同一批次中获得所有项目
fetchItem(1).then(items => items[0]);
fetchItem(2).then(items => items[1]);
fetchItem(3).then(items => items[2]);
此外,我们可以将此逻辑抽象为未来用法:
function batchRequests(request, paramMerger, period = 200) {
const queue = [];
let task;
return (params) => {
queue.push(params);
if (queue.length === 1) {
task = new Promise((resolve) => {
setTimeout(() => {
const params = paramMerger(queue.slice());
queue.length = 0;
resolve(request(params));
}, period);
});
}
return task;
};
}
const fetchItem = id => batchRequests(
fetchItems,
ids => ids
);
这是可以尝试的代码框:
结合计数
仅通过计数组合请求非常简单。让我们重复使用上面创建的fetchItems()
:
const MAX_QUEUE = 5;
const queue = [];
const fetchItem = id => {
queue.push(id);
if (queue.length === MAX_QUEUE) {
fetchItems(quque);
queue.length = 0;
}
}
但是,仅考虑计数是一个坏主意,因为fetchItem()
在收集足够的参数之前永远不会发送请求。因此,我们将一起考虑这样的时间:
const MAX_QUEUE = 5;
const queue = [];
let timer;
const flushQueue = () => {
clearTimeout(timer);
fetchItems(quque);
queue.length = 0;
};
const fetchItem = id => {
queue.push(id);
if (queue.length === 1) {
timer = setTimeout(flushQueue, 200);
} else if (queue.length === MAX_QUEUE) {
flushQueue();
}
}
和糟糕,如果我们期望来自函数的响应数据,那将变得很棘手:
const MAX_QUEUE = 5;
const queue = [];
let timer;
let flushQueue = () => null;
const fetchItem = id => {
queue.push(id);
if (queue.length === 1) {
const timer = setTimeout(() => flushQueue(), period);
task = new Promise((resolve) => {
flushQueue = () => {
const params = paramMerger(queue.slice());
queue.length = 0;
clearTimeout(timer);
resolve(request(params));
};
});
} else if (queue.length === MAX_QUEUE) {
flushQueue();
}
return task;
};
这是因为它试图解决两个两个条件的承诺,但是我们只为每批次创建一次计时器和承诺。它看起来很混乱和冒险。我们稍后将讨论替代方案。
与上述类似,我们可能会为将来的用法提取逻辑:
function batchRequests(request, paramMerger, period = 200, maxQueue = 5) {
const queue = [];
let task;
let flush = () => null;
if (maxQueue < 2) {
throw new Error("Max queuable requests must be more than 1!");
}
return (params) => {
queue.push(params);
if (queue.length === 1) {
const timer = setTimeout(() => flush(), period);
task = new Promise((resolve) => {
flush = () => {
const params = paramMerger(queue.slice());
queue.length = 0;
clearTimeout(timer);
resolve(request(params));
};
});
} else if (queue.length === maxQueue) {
flush();
}
return task;
};
}
这是可以尝试的代码框:
替代方案
当逻辑变得棘手时,我们很可能存在错误或简单的替代方法。
在这里获得fetchItems()
响应,我相信诸如Redux之类的州经理是更好的选择。使用州经理,我们只需要在修改后的fetchItem()
中更新项目状态即可。然后,“反应性”框架将通知更改并渲染最新数据,而解决的承诺不再是fetchItem()
。
风险
在用户关闭选项卡而无需发送请求和您的重要数据(例如,数据与广告费用有关的数据)丢失之前,一切看起来都很好!有必要考虑将逻辑的这一部分移至ServiceWorker
中以避免损失。
终于
总结,处理异步请求是当今必不可少的前端技能,有时不仅仅是应用aysnc
/await
和Promise
Apis。