前端并发控制的实战技巧与常见陷阱解析

Newb.楚恒 优化 阅读 2,737
赞 11 收藏
二维码
手机扫码查看
反馈

谁更灵活?谁更省事?

最近给一个后台数据导出功能加并发控制,本来以为就几行 throttle/debounce 事情,结果翻车了三次——第一次用 setTimeout 手搓队列,导出中途点了五次按钮,后台收到七个请求;第二次上了 lodash.throttle,发现它压根不支持「等上一个执行完再决定是否执行下一个」;第三次换 async-pool,倒是能控数量,但错误处理一塌糊涂,失败了连哪个任务挂的都看不到。

前端并发控制的实战技巧与常见陷阱解析

折腾完我干脆把项目里用过的、查文档看过的、甚至同事甩过来的几个方案全拉出来,真刀真枪跑了一遍。不是为了搞学术对比,纯粹是不想下次再花两小时 debug 并发逻辑。

我最常写的三个方案

下面这几个,我都在线上跑过,不是玩具代码。排名不分先后,按我实际踩坑顺序来。

1. 手写 Promise 队列(我目前主力)

我比较喜欢用这个,核心就一个原则:所有任务必须排队,串行执行,失败不中断,可取消。不是为了性能,是为了可控。

代码不复杂,但得自己管状态:

class SerialQueue {
  constructor() {
    this.queue = [];
    this.isRunning = false;
  }

  push(fn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ fn, resolve, reject });
      this.next();
    });
  }

  async next() {
    if (this.isRunning || this.queue.length === 0) return;
    this.isRunning = true;
    const { fn, resolve, reject } = this.queue.shift();
    try {
      const result = await fn();
      resolve(result);
    } catch (err) {
      reject(err);
    } finally {
      this.isRunning = false;
      this.next(); // 继续下一个
    }
  }

  clear() {
    this.queue = [];
  }
}

// 用法:
const queue = new SerialQueue();

document.getElementById('export-btn').addEventListener('click', () => {
  queue.push(() => fetch('https://jztheme.com/api/export', {
    method: 'POST',
    body: JSON.stringify({ type: 'user' })
  }).then(r => r.json()));
});

优点:逻辑完全透明,加 loading、重试、取消、日志埋点都随心所欲。我上周刚给它加了个 timeout(10000) 包装,超时自动 reject,没动队列本身一行。

缺点:要自己维护状态,clear() 得想清楚什么时候调(比如用户切页了就得清)。还有个坑:如果 fn 本身没返回 Promise,会直接 resolve(undefined),得加 guard,我已在生产环境补了 Promise.resolve(fn())

2. async-pool(适合固定并发数场景)

这个库我只在「批量上传图片」这种明确要限制同时请求数(比如 max=3)时用。API 简洁得过分:

import pool from 'tiny-async-pool';

const urls = ['/img/1.jpg', '/img/2.jpg', '/img/3.jpg', '/img/4.jpg'];
const results = await pool(3, urls, async url => {
  const res = await fetch(https://jztheme.com${url});
  return res.arrayBuffer();
});

优点:开箱即用,max 参数直接拍死并发数,错误不会滚雪球(单个失败不影响其他)。

缺点:**不能动态调整并发数**,也不能插队、暂停、取消单个任务。我们有个需求是「用户点暂停后,等当前正在跑的 2 个传完就停」,async-pool 没法做,最后还是切回手写队列。

另外注意:它默认是「fire and forget」风格,失败了你得靠 try/catch 在每个 fn 里包,不然整个 pool() 就 reject 掉——这点文档写得不清,我第一次用被坑过,报错堆栈还指向 pool 内部,debug 半小时才发现是某张图 404 了没 catch。

3. useRequest + 轮子方案(React 场景下妥协之选)

如果你项目里已经用了 ahooksswr,别硬套上面两个。我试过在 antd 表格里配 useRequestthrottleInterval,结果发现它只防抖,不控并发——用户狂点导出,照样发一堆请求,只是时间上错开了……

后来改用 loading + 状态锁:

const { run, loading } = useRequest(
  () => fetch('https://jztheme.com/api/export').then(r => r.json()),
  { manual: true }
);

// 按钮加锁
<button onClick={() => !loading && run()} disabled={loading}>
  {loading ? '导出中...' : '导出'}
</button>

这招简单粗暴,适合 MVP 阶段或内部工具。但它解决的是「不让用户重复点」,不是「控制并发」——万一接口本身被其他地方调了呢?所以我在 useEffect 里又加了一层全局锁变量,算是混合打法。

结论:够用,但不够干净。如果你的业务对「请求幂等性」「任务可见性」有要求,别用这个糊弄。

性能对比:差距比我想象的大

实测 100 个任务(每个模拟 200ms 延迟),三种方案耗时如下:

  • 手写队列(串行):≈20s(100×200ms)
  • async-pool(max=5):≈4.2s(20 个批次 × 200ms)
  • useRequest 锁按钮:≈20s(因为还是串行触发,只是 UI 锁了)

但!真实世界里,慢一点真不重要。我宁可多等 15 秒,也要知道第 47 个任务在哪挂了、能不能重试、有没有进度条。async-pool 的 4.2s 看着漂亮,但上次线上出问题,排查日志时根本分不清「是第 3 批里的第 2 个失败了,还是第 4 批第一个」——因为 pool 把它们全 flatten 成数组了。

我的选型逻辑

看场景,我一般选这三个:

  • 后台管理类操作(导出、审核、同步) → 手写队列。理由:需要精确控制、可观察、可干预。哪怕多写 20 行,也比半夜被告警 call 起来强。
  • 批量上传/下载(数量大、失败可接受) → async-pool。理由:省事,且天然支持「失败跳过」,加个 filter(Boolean) 就能拿到成功列表。
  • 轻量级表单提交、搜索建议 → useRequest + loading 锁。理由:90% 场景够用,团队新人也能看懂,上线快。

至于 throttle/debounce,我基本不用在并发控制上。它们是「节流输入」,不是「控请求流」。很多人混淆这点,导致点了十次按钮,最后发了十次请求,只是时间错开了……

踩坑提醒:这三点一定注意

1. 不要信任 Promise.all 的错误处理:它一失败就全崩,不适合并发控制场景。要用 Promise.allSettled 或自己包装 reject。

2. 取消机制一定要暴露出去:我见过太多「cancel() 方法写在注释里」的轮子。手写队列我一定加 abortController 支持,哪怕暂时不用。

3. 错误堆栈别被 Promise 吃掉:async-pool 里 catch 到的 error,error.stack 是空的。解决方案:在每个 fn 里手动 console.error(e),或者用 try/catch 包一层再 throw 新 error。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多(比如加优先级、带权重的调度),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

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

暂无评论