做前端的朋友,多少都遇到过这种糟心事:页面一打开,七八个接口同时发出去,有的在转圈,有的已经报错了,返回顺序还对不上;用户输入一个关键词搜索,防抖已经做了,结果慢的旧请求还是把快的新请求结果覆盖了;批量上传文件,一口气几十个请求直接把后端连接数打爆,服务端直接返回 502。这些问题看着五花八门,本质上是同一件事——异步请求没有调度。
我习惯把这套东西叫做ax 调度。所谓 ax,就是异步请求(async)的简称,“x”在我们圈子里经常代表交换、交互,所以 ax 调度说白了就是:给页面里所有异步请求安排一个合理的执行秩序。今天不聊那些重型框架里的现成封装,我直接用一整个项目的思路,从零拆一个轻量、可复用的请求调度方案,把队列、并发控制、优先级、取消、重试这些核心细节全部讲透。无论你是刚接触前端的初学者,还是在传统项目中想优化现有请求逻辑的老手,这套思路都可以直接抄过去用。
1. 先搞清楚 ax 调度到底在解决什么问题
1.1 从一次常见的接口乱序讲起
我接手过一个后台管理系统,列表页需要同时请求五六个接口:用户信息、角色权限、菜单列表、操作日志、待办数量。最早的写法很直白:
const [user, roles, menus, logs, todos] = await Promise.all([ fetch('/api/user'), fetch('/api/roles'), fetch('/api/menus'), fetch('/api/logs'), fetch('/api/todos'), ]);看起来没问题对不对?后来线上反馈页面有时候要卡四五秒,打开控制台一看,所有请求几乎同一时间发出去,最慢的那个接口耗时 3.8 秒,期间页面一直白屏。更麻烦的是,用户权限校验的接口如果慢了一点,菜单接口已经把权限外的内容渲染出来了,虽然权限系统会兜底,但闪烁的菜单栏真的很掉价。
这就是没有调度的代价。Promise.all确实能做到“并行”,但它没有任何秩序感:不限制并发数量、不区分优先级、不支持单个任务超时和重试、更没法取消一个已经发出去的请求。当页面规模一大,接口一多,这套裸奔的写法就会成为线上事故的温床。
ax 调度要解决的,就三件事:控制同时发多少个请求,决定先发谁后发谁,以及请求发出去之后怎么处理失败、超时和取消。
1.2 调度的四个设计目标
我在写这个调度器之前,先给自己列了一份清单,这四个目标后来也成为整个项目的核心支柱:
| 目标 | 要解决的问题 | 直观类比 |
|---|---|---|
| 并发控制 | 避免瞬间打爆服务器和浏览器连接池 | 收费站只开三个窗口,车到了就在入口排队 |
| 队列管理 | 任务多了要排队,不能乱插 | 银行叫号,先来先办 |
| 优先级调度 | 重要请求优先发,次要请求靠后 | 急诊病人优先就诊 |
| 任务取消与重试 | 过期请求要作废,失败请求要有补救 | 外卖超时了可以催单,也可以取消重下 |
这四个目标之间互相影响。比如并发控制做不好,队列再长也没意义,因为前面的请求会一直占着位置;优先级做不好,一个不重要的导出接口可能把用户点击的关键接口挤到后面;取消机制做不好,搜索这种高频场景必然出现竞态。
搞设计之前先别急着写代码,把目标想清楚了,后面每一步都有据可依。
2. 核心概念拆解:队列、并发窗口这一次听明白
2.1 一次请求从进来到离开的完整生命周期
调度器里的每个请求,我把它分成三个状态:
- pending(排队中):任务已提交,但还没轮到它执行。
- running(执行中):任务已进入并发窗口,真正的网络请求已经发出。
- settled(已结束):请求成功、失败、超时或被取消,任务从调度器里移出。
画个简单的流程:add() 入队→排队→进入并发窗口→发起请求→成功/失败/超时/取消→调度器回收并发槽位→从队列里拉下一个任务。
这个状态机是整个调度器的心脏。你可能会想,这不就是一个队列吗?对,本质就是一个队列,但真正难的是当任务结束之后,如何平稳地把资源让给下一个任务。很多初学者写调度器翻车,就是忘记了“回收并发槽位”这一步,导致前几个任务跑完之后队列再也不动了。
2.2 并发控制背后的数学直觉
并发控制最经典的思路是“信号量”,但前端场景里我更喜欢把它理解成一个并发窗口。什么意思?假设窗口的最大并发数是 3,意思就是同一时刻只能有 3 个请求在飞。
不用敲太多数学公式,你可以想象一个停车场,只有 3 个车位。车子想要进场必须有一个空车位,没有车位就在门口排队。每辆车离场(请求结束),门卫才放下一辆车进场。这个“放行”的动作,就是调度器里的_schedule()方法。
从工程角度,并发数怎么定?我给自己总结过一个经验公式:
并发数 = 目标服务端每秒可承受请求数(QPS)× 单请求平均耗时(秒)打个比方,后端接口能扛 200 QPS,一个请求通常要花 0.5 秒,那理想并发就是 200 × 0.5 = 100。但实际前端场景里,还要留余量,因为服务端不只是为你一个前端服务。经验上,中小型后台上限设 5~8,移动端网络不稳的上限设 2~4,纯前端静态资源可以放宽到 10 左右。
2.3 优先级、超时、重试,这三个参数别一起乱调
优先级、超时、重试,这三兄弟看着简单,实际组合起来非常讲究。
优先级:我用一个整数表示,数值大的先执行。业务里最好只分三种:高(用户主动触发)、普通(页面初始化加载)、低(预加载/统计)。因为人类对超过三档的优先级就失去直觉了,分五档反而会给后续排障带来麻烦。
超时:不是所有接口都适合一个超时值。我见过有些项目全局统一 10 秒超时,结果数据导出接口本身就要跑 20 秒,每次必超时。超时设置需要按接口能力区分,详情查询类接口给 5~8 秒,文件上传/导出类接口给 30 秒以上,上报类接口甚至可以不给超时。
重试:重试是一把双刃剑。网络抖动导致的重试很有价值,服务端返回 500 时重试基本没用。我的原则是:只有幂等请求(GET、PUT)才允许自动重试,POST 这类非幂等请求坚决不自动重试,否则可能会出现重复下单、重复扣费这种事故。
这三个参数单看都好理解,但把它们一起放进调度器,必须要有清晰的优先级判断顺序:先判断有没有超时,再判断决议重不重试,同时要能响应取消信号。顺序一旦乱了,超时的任务可能又被重试一次,然后用户却已经取消了它。
3. 手写一个轻量 ax 调度器,240 行代码搞定
3.1 类设计:调度器只有两个职责
整个调度器我拆成了两部分:任务(Task)和调度器(AxScheduler)。
Task 只描述一件事:这个任务是谁、有多重要、要在多久内完成、最多允许失败几次、真正执行的函数是什么。它不关心调度逻辑。
AxScheduler 负责三件事:
- 维护待执行队列(需要支持按优先级排序)。
- 维护“正在执行的集合”(控制并发窗口)。
- 在任意一个任务结束时,把并发槽位让给队列里的下一个任务。
为什么要把“正在执行的集合”单独拎出来?因为队列和正在执行的任务完全是两回事。我见过很多半吊子实现,把正在执行的任务也放在队列里,然后通过标记状态来区分,一旦并发数变大,状态判断的逻辑就乱成一锅粥。分开维护,逻辑清爽,排障也容易。
3.2 完整代码实现,可以直接复制
下面是我在实际项目里打磨过的一个简化版本,去掉了业务依赖,你拿到手改改就能用。
class AxTask { constructor(options) { this.id = options.id || `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; this.priority = options.priority ?? 0; this.timeout = options.timeout ?? 8000; this.retry = options.retry ?? 0; this.handler = options.handler; // 接收 signal 并返回 Promise 的函数 this.status = 'pending'; this.resolve = null; this.reject = null; } } class AxScheduler { constructor({ maxConcurrency = 3, defaultTimeout = 8000, defaultRetry = 0 } = {}) { this.maxConcurrency = maxConcurrency; this.defaultTimeout = defaultTimeout; this.defaultRetry = defaultRetry; this.queue = []; this.pool = new Map(); // 执行中的任务,key 是任务 id this.abortMap = new Map(); // 任务 id -> AbortController } add(options) { return new Promise((resolve, reject) => { const task = new AxTask({ ...options, timeout: options.timeout ?? this.defaultTimeout, retry: options.retry ?? this.defaultRetry, }); task.resolve = resolve; task.reject = reject; this.queue.push(task); this._schedule(); }); } // 核心:扩容并发窗口 _schedule() { this.queue.sort((a, b) => b.priority - a.priority); while (this.pool.size < this.maxConcurrency && this.queue.length > 0) { const task = this.queue.shift(); task.status = 'running'; this.pool.set(task.id, task); this._execute(task); } } async _execute(task) { let attempts = 0; let lastError; while (attempts <= task.retry) { attempts++; const controller = new AbortController(); this.abortMap.set(task.id, controller); try { const result = await this._runWithTimeout(task, controller); this._finish(task); task.resolve(result); return; } catch (err) { this.abortMap.delete(task.id); // 取消导致的异常:不再重试,直接对外抛错 if (err && err.name === 'CancelError') { this._finish(task); task.reject(err); return; } lastError = err; if (attempts <= task.retry) { // 简单退避:500ms * 重试次数 await new Promise((r) => setTimeout(r, 500 * attempts)); continue; } } } this._finish(task); task.reject(lastError); } _runWithTimeout(task, controller) { const timer = setTimeout(() => { controller.abort(); }, task.timeout); return Promise.race([ Promise.resolve().then(() => task.handler(controller.signal)), new Promise((_, reject) => { controller.signal.addEventListener( 'abort', () => { const error = new Error(`任务 ${task.id} 已取消或超时`); error.name = 'CancelError'; reject(error); }, { once: true }, ); }), ]).finally(() => { clearTimeout(timer); }); } // 任务结束,回收并发窗口 _finish(task) { this.pool.delete(task.id); this.abortMap.delete(task.id); this._schedule(); } cancel(taskId) { if (this.abortMap.has(taskId)) { this.abortMap.get(taskId).abort(); } } cancelAll() { for (const controller of this.abortMap.values()) { controller.abort(); } } get pendingCount() { return this.queue.length; } get runningCount() { return this.pool.size; } } module.exports = { AxScheduler };代码不长,几个关键点我解释一下:
_schedule()是唯一一个“放行”入口。任何任务入队、任务结束,都会触发它。它做的事情很简单:先按优先级排序队列,然后把并发窗口塞满。这个设计最大的好处是,你不需要在任务结束时手动去找下一个任务,只需要调用_schedule(),它会自动判断。
把AbortController和超时绑定在一起。这里用Promise.race同时跑真实请求和超时计时器,一旦超时就直接 abort 真实请求的 signal,两件事通过同一个信号源联动,逻辑闭环。这也是原生 fetch 的标准用法:
fetch('/api/xxx', { signal });取消和超时统一用一种异常类型。我特意把它们都命名为CancelError,这样调度器内部就知道这个任务是被外部因素终止的,不应该重试、不应该记录成业务失败。
3.3 一次真实调度过程推演
光看代码没感觉,我模拟一个场景:并发窗口设为 3,依次提交 5 个任务。
时刻 t0:任务 A、B、C 入队,_schedule()发现池子里是空的,直接从队列里取出 A、B、C 三个任务,进入执行状态。
t0+t1:任务 D 提交,入队。此时池子 3/3 满了,D 只能排队等待。同时任务 A 率先结束,_finish(A)被调用,池子变成 2/3,_schedule()触发,从队列里取出 D,池子回到 3/3。
t0+t2:任务 C 超时,抛出CancelError,调度器重试次数(假设 retry=1)用尽,最终 reject。_finish(C)触发,池子变成 2/3,队列已空,调度结束。
整个过程非常顺滑,没有任何一处需要手动判断“下一个该谁”。这就是把调度器做成“状态收拢 + 统一放行”的好处。
3.4 核心参数到底怎么设
我整理了一个参数选择表,方便你直接参考:
| 参数 | 推荐值 | 原因 |
|---|---|---|
| maxConcurrency | 中小后台 5~8,移动端 2~4 | 兼顾并发效率与服务端承受能力 |
| defaultTimeout | 查询类 5~8 秒,上传类 30 秒以上 | 越重的任务越要给足时间窗口 |
| defaultRetry | 幂等请求 1~2,非幂等请求 0 | 防止重复提交产生业务副作用 |
| priority | 高 100 / 普通 0 / 低 -100 | 三档足够,五档会让人失去直觉 |
4. 三种真实业务场景,就这么往里套
4.1 搜索联想词:防抖、串行、取消过期请求
搜索联想是 ax 调度最经典的场景。用户在输入框里连续输入,每隔几百毫秒发一个请求,如果不对请求做调度,必然会出现“旧请求返回比新请求晚,把新结果盖掉”的竞态。
以前我用防抖 + 时间戳对比来做,不好用,时间戳只能挡最后一瞬间,请求多了还是会漏。现在我的做法是三层:
- 输入防抖 250ms,减少无效请求的数量。
- 把搜索任务串行化,
maxConcurrency设为 1,保证同一时刻只有一个搜索请求在飞。 - 每次发起新搜索时,取消上一个尚未完成的搜索任务,从根上消灭旧请求。
配合调度器的cancel(taskId),代码大概是这样的:
let currentSearchTask = null; function onInput(keyword) { clearTimeout(timer); timer = setTimeout(() => { if (currentSearchTask) scheduler.cancel(currentSearchTask); currentSearchTask = scheduler.add({ priority: 100, handler: () => fetch(`/api/search?q=${keyword}`).then((res) => res.json()), }); }, 250); }这样旧请求的响应哪怕晚到一秒钟,也永远不会出现在界面上,因为它的任务已经被取消了。这个方案我在实际项目中用了一年,没有再出现过搜索结果跳动的问题。
4.2 批量上传/下载任务:并发限制 + 重试 + 进度
批量上传文件,如果一次性把几十个文件全部发出去,不仅浏览器连接池扛不住,后端更是分分钟 502。这时候用调度器就非常简单:maxConcurrency设为 2 或者 3,然后一次add()一个文件。
const fileList = [...]; // 用户选择的文件 const scheduler = new AxScheduler({ maxConcurrency: 2, defaultRetry: 2 }); fileList.forEach((file) => { scheduler.add({ priority: 0, handler: () => uploadFile(file), // 返回 Promise }).then(() => { console.log(`${file.name} 上传完成`); }).catch((err) => { console.error(`${file.name} 上传失败`, err); }); });这里有个经验:重试次数不要写死在上传函数内部,而是交给调度器。因为上传这种任务往往是被网络抖动打死的,重试一次就好,但如果服务端是 4xx 错误(比如文件格式不对),重试一百次都是白搭。所以我建议重试策略里的请求必须幂等,因为重复上传同一个文件,至少对文件服务来说是无害的。
顺带提醒一句,上传任务如果用户中途取消了整个批次,记得调用scheduler.cancelAll(),别让那些任务在后台默默跑到超时,白白浪费时间。
4.3 首屏接口并行优化:慢请求兜底与优先级调整
首屏加载往往需要同时请求几个接口,这时候并不适合全部并行。我的做法是:
- 核心渲染接口(页面主数据)优先级设 100,
maxConcurrency至少给到 3。 - 辅助接口(站点配置、用户偏好等)优先级设 0 或 -100,不能抢占核心接口的资源。
- 主数据接口超时后,不要一直白屏等待,给个兜底重新请求或者直接展示错误页,避免用户干等。
举个例子,如果首屏要请求 6 个接口,服务器扛不住 6 个并发,但可以扛 3 个。把调度器的maxConcurrency设为 3,核心接口优先发,剩下的排队,既保住了性能,又稳住了用户体验。
5. 常见问题与排查实录速查表
5.1 队列卡死,任务提交后永远不执行
现象:页面加载了,接口迟迟不发请求,控制台没有任何报错。
排查:先看pendingCount和runningCount。如果runningCount一直等于maxConcurrency,但池子里没有任务,说明有任务占着坑但永远不结束。最常见的原因有两个:任务返回的 Promise 没有调用 resolve/reject,或者请求函数里因为异常被吞掉了,既没 catch 也没往外抛。
这个坑我踩过:封装的请求库在某个错误分支里return undefined,导致返回的 Promise 永远 pending。给调度器接一个“任务卡死心跳检测”就很有价值,最简单的做法是每个任务加一个超时兜底,反正我们本来就做了超时,如果你发现超时也没用,那就要检查是不是 Promise 链断裂了。
5.2 重试的时候把错误吞了
现象:服务端 500 了,任务重试一次成功,但用户看到的是第一次失败的报错提示,因为第一次 catch 已经弹了 toast。
原因:错误提示写在了add()里,而不是重试结束后的最终 catch 里。调度器重试的是内部逻辑,但外层 Promise 只在所有重试都结束后才会进入 reject。所以错误提示一定要统一放在最外层的.catch(),不要在任务函数里又弹 toast 又抛异常,双份反馈很让人困惑。
5.3 竞态问题:旧响应覆盖新响应
现象:页面上有两个请求,一个是查 A 数据的,一个是查 B 数据的,B 先返回了,A 后返回,A 的结果把 B 的给覆盖了。或者搜索场景里旧结果的延迟返回盖掉新结果。
排查:没有在发送新请求前取消旧任务,也没有用请求 ID 做对比。用调度器解决的办法就是前面说的:新任务入队前调用cancel()把旧任务取消掉,同时检查响应里携带的任务 ID,务必发回来的数据和当前展示的条件一致才更新页面。
5.4 取消监听器泄漏
现象:页面不断开关抽屉,每次关闭都触发 cancel,页面越来越卡,内存占用逐步上升。
原因:_runWithTimeout里注册的abort监听器在任务成功时必须移除。我代码里用{ once: true }就是为了让监听器在触发后自动摘掉,但如果你自己封装任务,记得在finally里也要移除监听,否则每次取消就多一个监听函数挂在 signal 上,慢慢把内存吃满。
5.5 并发为 1 时的隐式死锁
现象:maxConcurrency设为 1,任务 A 执行过程中又调用了scheduler.add()添加任务 B,但任务 A 没有结束,B 永远排不进去;而任务 A 又可能在等待 B 的结果返回,于是双双卡死。
原因:这是所有调度器都会遇到的“递归依赖”问题。解决办法有三种:
- 禁止在执行中的任务内部新增任务(简单粗暴,写个断言检测一下)。
- 新增任务时如果检测到调用栈里已有任务在跑,直接绕过队列、立即执行(风险较高,不推荐)。
- 把任务拆成两段,先执行 A 的前半段,再在回调里执行 B,最后在回调里把 A 的后半段加回调度器。
大多数业务场景用第 1 种就够了,至少能在开发阶段快速暴露问题。
排查问题我整理成一张速查表,直接按图索骥:
| 现象 | 排查方向 | 解法 |
|---|---|---|
| 入队后不执行 | 执行中任务是否永远 pending | 给任务加超时兜底,检查 Promise 是否白返回 |
| 重试后只看到错误提示 | 错误提示写在任务内部 | 移到最外层.catch() |
| 旧响应覆盖新响应 | 没有取消旧任务 /* 没有用任务 ID 校验 | 入队前 cancel,回复时校验 ID |
| 内存缓慢上涨 | abort 监听器未移除 | 使用{ once: true }并在 finally 里清理 |
| 并发为 1 时卡死 | 线程内加入新任务导致循环等待 | 禁止递归依赖,或拆分任务流程 |
6. 我的一点实操心得
6.1 调度粒度别设计太细
一个调度器别想着把所有东西都包含进去,比如把“接口缓存”“请求合并”“失败降级”全塞进去。一开始我犯过这个错,想做一个全能调度器,结果代码越来越肥,bug 越来越多。后来我把能力拆开:调度器只负责任务的排队、执行、取消、重试;缓存、降级是另外的模块,通过任务函数自己组合进去。比如某个接口想加缓存,就在 handler 里先查缓存,查不到再 fetch。
这样拆的好处是,调度器可以私有化部署在任何项目里,其他模块换掉也不影响请求调度的稳定性。
6.2 日志先行,一切好排查
调度器这种基础设施,一定要从一开始就打日志。每个任务从入队到结束的全过程,都记录一条结构化日志:
console.log('[ax-scheduler]', { event: 'task_executed', taskId: task.id, status: task.status, runningCount: this.runningCount, pendingCount: this.queue.length, costMs: Date.now() - task.createdAt, });有了这些日志,线上出问题时,你一眼就能看出是并发窗口太小导致排队过久,还是某个任务超时导致链路断掉。磨刀不误砍柴工,这个建议一定要听。
6.3 往链路追踪方向扩展
调度器本身就是一个天然的“请求时序记录器”。我给调度器加过一个轻量监控:当一个任务从入队到结束耗时超过 3 秒就把日志上报到监控平台,并且把该任务的完整请求链(从哪里触发、依赖哪些接口)记录下来。后来定位性能问题时,这个监控帮了大忙:有一个页面慢,不是接口慢,而是队列里排在它前面的 8 个低优先级预加载任务把并发窗口挤满了。我调整了优先级之后,页面秒开。没有调度器的日志,这种问题基本只能猜。
现在我自己的每个项目几乎都保留了这套 ax 调度的骨架,不同的只有那些挂在 handler 外面的具体业务。分享这些,是希望你能避开我踩过的坑。最后提醒一句:调度器的价值不体现在代码量上,而是体现在它能不能在你毫不知情的情况下,替你把所有请求的秩序维持好。就像红绿灯,它不说话,但有了它,路口就不乱了。