请求队列实战:提升前端性能与用户体验的关键技术
为什么我要折腾请求队列?
去年做项目时,用户频繁点击“保存”按钮,结果后端收到一堆重复请求,数据库直接炸了。后来加了防抖,但又遇到新问题:有些请求必须按顺序执行(比如先创建再更新),防抖一搞,顺序全乱了。这时候我才意识到,光靠防抖节流不够,得上请求队列。
我试过几种方案,有的简单粗暴,有的灵活但复杂。今天就聊聊我踩过的坑,以及我现在主力用的方案。
最糙快猛的方案:手动维护一个数组
刚接触这问题时,我第一反应是:不就是个队列吗?自己写一个呗。于是搞了个全局数组,push 请求进去,然后用 async/await 串行执行。
const requestQueue = [];
async function enqueueRequest(fn) {
requestQueue.push(fn);
if (requestQueue.length === 1) {
processQueue();
}
}
async function processQueue() {
while (requestQueue.length > 0) {
const fn = requestQueue.shift();
try {
await fn();
} catch (err) {
console.error('请求失败', err);
}
}
}
用起来也简单:
enqueueRequest(() => fetch('https://jztheme.com/api/save-data', { method: 'POST' }));
优点?确实快,10分钟就能跑起来。但缺点更明显:没法取消、没超时控制、错误处理弱。有次网络卡住,整个队列堵死,用户点啥都没反应。而且多个模块共用这个队列的话,互相干扰——A模块的慢请求会拖垮B模块的快请求。所以这方案我只在 demo 里用过,上线?不敢。
用 Promise 链式调用:优雅但难维护
后来我想,既然 JS 是单线程,能不能用 Promise 链把请求串起来?比如每次新请求都接在上一个 Promise 后面:
let lastPromise = Promise.resolve();
function queueRequest(fn) {
const newPromise = lastPromise.then(() => fn());
lastPromise = newPromise.catch(() => {}); // 避免 unhandledrejection
return newPromise;
}
调用方式:
queueRequest(() => fetch('https://jztheme.com/api/action1'));
queueRequest(() => fetch('https://jztheme.com/api/action2'));
这方案看起来很“函数式”,代码也短。但实际用起来特别别扭。你想取消某个请求?做不到。想看当前队列状态?得自己额外维护。而且一旦中间某个请求 reject 了,后续逻辑容易断掉(虽然我加了 catch 补丁,但总觉得不干净)。最烦的是,调试时堆栈信息全是 then/catch,根本看不出原始调用位置。折腾了两天,最后还是放弃了。
我的主力方案:基于 AbortController 的可取消队列
现在我基本都用这个方案——核心是每个请求都绑定一个 AbortController,队列管理器能主动 abort 掉旧请求。好处是:既能串行,又能随时清理“过期”任务。
class RequestQueue {
constructor() {
this.queue = [];
this.isProcessing = false;
}
async add(requestFn) {
const controller = new AbortController();
const signal = controller.signal;
const task = {
fn: requestFn,
controller,
signal
};
this.queue.push(task);
this.process();
return { cancel: () => controller.abort() };
}
async process() {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
while (this.queue.length > 0) {
const task = this.queue.shift();
try {
// 检查是否已取消
if (task.signal.aborted) continue;
await task.fn({ signal: task.signal });
} catch (err) {
if (err.name !== 'AbortError') {
console.error('请求出错', err);
}
}
}
this.isProcessing = false;
}
}
使用时:
const queue = new RequestQueue();
// 用户点击保存
const { cancel } = queue.add(({ signal }) =>
fetch('https://jztheme.com/api/save', {
method: 'POST',
signal // 传入 signal,fetch 会自动监听 abort
})
);
// 如果用户又点了,可以取消上一个
// cancel();
这个方案我用了半年多,真香。关键点在于:1)每个请求独立可控;2)利用原生 AbortController,和 fetch 天然兼容;3)队列内部自动跳过已取消的任务。唯一要注意的是,task.fn 必须接收 { signal } 并正确使用,否则 abort 无效。我一开始忘了传 signal,结果 cancel 完全没用,查了好久才定位到。
谁更灵活?谁更省事?
如果只是简单场景,比如表单提交防重,其实用防抖就够了,别过度设计。但一旦涉及“顺序依赖”或“用户可能频繁操作”的场景(比如富文本编辑器的自动保存),队列就绕不开。
对比下来:
- 手动数组:适合临时 hack,别上生产
- Promise 链:代码少但难扩展,调试痛苦
- AbortController 方案:初期多写几行,但长期维护成本低,还能配合取消逻辑
我比较喜欢用 AbortController 那套,因为真实业务中,“取消”需求比想象中多。比如用户快速切换 tab,旧 tab 的数据请求就没必要继续了。这时候队列自动 skip aborted task,体验顺滑很多。
性能对比:差距比我想象的大
我拿三种方案各跑 1000 次模拟请求(用 setTimeout 模拟网络延迟),结果:
- 手动数组:内存占用最低,但无法释放阻塞请求
- Promise 链:内存略高,rejected Promise 会短暂堆积
- AbortController:内存稍高(每个任务多存 controller),但能主动释放资源
实际项目中,前两种方案在低端机上容易卡顿,尤其是队列积压时。而 AbortController 方案因为能及时 abort,反而更流畅。性能不是瓶颈,可控性才是。
我的选型逻辑
现在我基本默认用 AbortController 队列,除非:
- 请求完全无依赖,且不需要取消 → 用防抖
- 团队技术栈老,不支持 AbortController(比如 IE)→ 退化到手动数组 + 超时兜底
但 90% 的新项目,我都推 AbortController 方案。它不完美——比如不能并行,但串行队列本来就不该并行。而且代码结构清晰,新人接手也能快速看懂。
对了,还有一点:别把所有请求都塞进同一个队列!我见过有人把登录、列表、详情全用一个队列,结果登录慢了,整个 App 卡住。建议按业务域分队列,比如 userQueue、dataQueue,互不干扰。
结尾
以上是我个人对请求队列方案的踩坑总结。说实话,没有银弹,但 AbortController 这套在灵活性和实用性上平衡得最好。改完后仍有个小问题:如果队列里有非 fetch 的异步操作(比如本地计算),得手动监听 signal,不过加个 wrapper 就能解决,不算大碍。
以上是我的对比总结,有不同看法欢迎评论区交流。这个技巧的拓展用法还有很多(比如带优先级的队列),后续会继续分享这类博客。

暂无评论