请求队列实战:提升前端性能与用户体验的关键技术

UX-爱慧 优化 阅读 2,208
赞 25 收藏
二维码
手机扫码查看
反馈

为什么我要折腾请求队列?

去年做项目时,用户频繁点击“保存”按钮,结果后端收到一堆重复请求,数据库直接炸了。后来加了防抖,但又遇到新问题:有些请求必须按顺序执行(比如先创建再更新),防抖一搞,顺序全乱了。这时候我才意识到,光靠防抖节流不够,得上请求队列。

请求队列实战:提升前端性能与用户体验的关键技术

我试过几种方案,有的简单粗暴,有的灵活但复杂。今天就聊聊我踩过的坑,以及我现在主力用的方案。

最糙快猛的方案:手动维护一个数组

刚接触这问题时,我第一反应是:不就是个队列吗?自己写一个呗。于是搞了个全局数组,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 卡住。建议按业务域分队列,比如 userQueuedataQueue,互不干扰。

结尾

以上是我个人对请求队列方案的踩坑总结。说实话,没有银弹,但 AbortController 这套在灵活性和实用性上平衡得最好。改完后仍有个小问题:如果队列里有非 fetch 的异步操作(比如本地计算),得手动监听 signal,不过加个 wrapper 就能解决,不算大碍。

以上是我的对比总结,有不同看法欢迎评论区交流。这个技巧的拓展用法还有很多(比如带优先级的队列),后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论