☰
前端ax调度实战:从请求竞态到并发控制的完整指南
2026/9/28 17:10:39 网站建设 项目流程

1. 到底什么是“ax调度”:从一句玩笑到核心工程问题

1.1 “ax调度”这个热词,戳中的是每个前端都绕不开的痛点

最近技术社区里有个热词叫“ax调度”,被不少同事拿来调侃:说是资深前端每天的日常,就是面红耳赤地跟 ajax 请求搏斗——怎么排队、怎么取消、怎么重试、怎么不把后端打挂。话是玩笑,但说得非常实在。如今的前端项目,尤其是中后台管理系统和重交互的 C 端应用,本质上就是在做一件事:把一堆异步事件编排成一个稳定、不跳变、不闪烁的 UI 状态。而“ax”这个缩写在多数人语境里指向 Ajax/Axios 这类异步请求,“调度”则是对这些请求的全过程管理。

我用一个后厨的例子来解释“调度”到底在调度什么。客人点菜是异步的,后厨炒菜也是异步的,但出菜口必须有个明确的规则:哪一桌的菜先上、哪道菜先做、点菜量太大时怎么排队、某道菜做坏了要重做几次。如果出菜口没有规则,高并发下必然会乱套——有的菜凉了,有的菜重复上,有的客人催了三遍还在等。前端请求调度面对的是同样的局面:搜索框每敲一个字就发一次请求,多个接口之间存在依赖关系,页面切换时旧请求还没返回,批量上传时几十个请求同时发出去,任何一个环节没有规则,用户感知到的就是数据错乱、页面卡顿、按钮一直转圈。

所以我一直觉得,“ax调度”不是新造的概念,而是把以前揉在业务代码里、散落在各个组件里的异步请求管理问题,单独拎出来给了个名字。它解决的是“在资源有限、网络不确定、用户操作随机的环境下,请求仍然能以正确顺序、合理时效、可控数量完成”的问题。这篇文章的价值在于帮你把这个问题彻底理清,适合被请求竞态折腾过的新人,也适合想把异步逻辑从组件中剥离出来的老手。

1.2 不是只有大项目才需要,五个最典型的业务场景

有人觉得调度是架构师才考虑的事,业务页面不就是发个请求、拿到数据渲染吗?真不是。我随手就能列出五个每个前端都写过的场景,每一个都在做调度。

第一个是搜索联想。用户输入“苹果”,可能先触发了一次“苹”,再触发一次“苹果”。网络环境下,后发的请求可能先返回,也可能先发的请求后返回。如果不对响应做处理,页面上很可能会出现输入“苹果”却显示“苹”的联想结果。第二个是级联筛选,比如先选省份再选城市,城市列表依赖省份。如果不做时序控制,用户快速切换省份时,上一次的请求结果没有及时清掉,新旧数据互相覆盖。第三个是批量上传,一次选 30 个文件,如果 30 个请求同时发出,浏览器连接数被占满,上传进度和失败重试都不可控。第四个是列表分页,用户快速翻页时,上一页的请求突然返回,把当前页数据覆盖成旧数据,表格里出现“灵魂闪现”。第五个是登录后的初始化流程,拿 token、拉用户信息、拉权限列表、拉菜单配置,彼此之间有先后依赖,任何一个失败都会导致后续全部白拉。

这些场景单独看都不难,但合在一起,它们暴露的是同一个本质:请求不能只发不管,必须有统一的机制来约束“什么时候发”“发几个”“怎么处理过期响应”“失败后怎么办”。这就是调度的核心价值,也是为什么“ax调度”能成为一个值得讨论的话题——它看起来不高级,但实际项目里,八十的线上问题都出在这一层。

2. 请求调度要解决的四个基本问题

2.1 竞态裁决:当响应顺序和发起顺序不一致

竞态是异步编程里最常见的坑,也是“ax调度”里最优先要解决的问题。网络请求没有保证返回顺序,你在时间点 T1 发起请求 A,在 T2 发起请求 B,但 B 可能比 A 先返回。如果不做处理,页面最终显示的是 A 的结果,而这个结果早已不是用户最新操作对应的数据。

解决竞态最直接的两个思路,一个是“取消”,一个是“丢弃”。取消的思路是:一旦新请求发出,就把旧请求真正中止掉。浏览器原生提供一个非常好用的 API,叫 AbortController。它的用法很简单:

const controller = new AbortController(); fetch('/api/search?q=苹果', { signal: controller.signal }); // 用户继续输入,旧请求不再需要 controller.abort();

fetch 会监听 signal 上的中断通知,中断后本次请求直接失败,不会再产生响应。如果用 axios,同样支持 signal 参数,底层逻辑一致。在实际项目里,我一般会封装一个带取消能力的请求函数,把 controller 的创建和中止都收敛在一个地方,避免每个页面都重复写一遍。

function createCancellableRequest<T>( url: string, options?: RequestInit ) { const controller = new AbortController(); return { signal: controller.signal, abort: () => controller.abort(), run: async (): Promise<T> => { const res = await fetch(url, { ...options, signal: controller.signal }); return res.json() as Promise<T>; } }; }

但有时候你不想真正取消,因为旧请求的结果可能还有缓存价值,比如搜索联想里的热门词结果。这时用“丢弃”策略更合适:给每一次请求加一个自增序号,响应返回后对比序号,如果已经过期就直接忽略。

let seq = 0; async function search(keyword: string) { const current = ++seq; const res = await fetch(`/api/search?q=${keyword}`).then(r => r.json()); // 如果期间又发起了新请求,当前响应直接丢弃 if (current !== seq) return; renderResult(res); }

这个方案虽然简单,但工程上非常可靠。我见过很多团队没有做任何竞态保护,线上用户反馈“搜索不准确”“筛选乱了”,排查到最后都是同一个原因。竞态裁决的核心原则其实就是一句话:让用户最新的一次操作决定界面状态,其余结果只能让路。

2.2 并发控制:浏览器和后端都受不了一次全发

并发控制说起来也简单:不限制当前同时执行的请求数量。为什么必须限制?两个原因。

第一个是浏览器自身的物理限制。HTTP/1.1 下,同一个域名最多只能建立大约 6 个 TCP 连接,超出部分会排队等待。如果一个页面同时发 20 个请求,后 14 个必须等前面的完成,用户直观感受到的就是接口迟迟不返回。第二个原因是服务端压力。哪怕页面和接口都走 HTTP/2,多路复用让单域名连接数不再紧张,但后端应用服务器依然有线程池、连接池、数据库连接数上限。一次页面操作触发 30 个查询请求,数据库可能直接打满,整个系统都跟着变慢。

所以“并发数”必须显式控制。这里有一个经典的并发池思路,核心是维护一个待执行队列和一个“正在执行数量”的计数器。当计数器低于上限时,从队列头部取出任务执行;每当一个任务完成,计数器减一,再自动取出下一个任务。

async function runWithConcurrency<T>( tasks: Array<() => Promise<T>>, limit: number ): Promise<T[]> { const results: T[] = new Array(tasks.length); let index = 0; async function worker() { while (index < tasks.length) { const current = index++; results[current] = await tasks[current](); } } const workers = Array.from( { length: Math.min(limit, tasks.length) }, () => worker() ); await Promise.all(workers); return results; }

worker 的数量就是并发上限,每个 worker 不断从任务数组里取下一个任务,取完为止。这个模式的优点在于,并不是把 30 个请求同时丢出去,而是始终保持最多 N 个在执行。对上传文件、批量初始化这类场景,效果立竿见影。

具体并发数设多少,不要拍脑袋。我一般先看接口耗时和机器配置,再从浏览器 Network 面板观察 queueing 阶段的长短。常见项目里 4 到 6 并发是相对稳妥的值,既不浪费网络带宽,也不会让服务端难堪。注意,这个“4 到 6”是经验值,不是标准答案,最好根据实际并发测试调整。

2.3 时序编排:接口之间的依赖关系不能靠运气

竞态管的是“乱序”,并发管的是“数量”,时序编排解决的是“依赖”。不少业务接口天生有先后关系:先拿登录凭证,再拿用户信息,最后拿权限列表。这个顺序如果乱了,页面轻则报错,重则泄露不属于当前用户的菜单和数据。

最简单的串行方式就是 await 一个一个等,代码很好看,但效率偏低。稍微进阶一点的做法是把“并行无关”和“串行依赖”拆开:没有依赖关系的接口用 Promise.all 一起发,有依赖关系的接口用 await 显式等待前一步完成。

const token = await fetch('/api/token').then(r => r.json()); // 这两个接口互不依赖,可以并行 const [userInfo, permissions] = await Promise.all([ fetch('/api/user', { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json()), fetch('/api/permissions', { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json()) ]); const menus = await fetch(`/api/menus?role=${permissions.role}`).then(r => r.json());

这里要强调一个容易踩的认知误区:很多人喜欢用 Promise.all 处理全部请求,觉得“并发”就是快。但 Promise.all 有一个特性,任何一个请求失败,整体立即 reject,其他请求的结果全部丢弃。如果你的场景是“能拿到多少算多少”,比如页面首屏由三个独立模块组成,其中一个接口挂了,其余模块仍然应该显示,那就应该用 Promise.allSettled 而不是 Promise.all。

还有一点是关于“自动化的依赖编排”。有的项目会引入 DAG 依赖图之类的重型机制去自动分析接口依赖,我的实际体会是,除非你的接口由可视化编排系统生成,否则业务代码里显式写清依赖关系,远比自动化推断可靠。因为接口依赖往往夹带着业务状态,比如“必须先选中一条记录才能查详情”,这类逻辑不在接口参数层面,自动化编排根本感知不到,强行抽象只会增加复杂度,最后还是要手写时序逻辑。

2.4 重试与降级:调度里最容易玩火的部分

重试机制是调度的最后一道防线,也是最容易写错的地方。不少人的第一版重试代码是“失败就再来一次”,看起来直接,但线上常常会因此吃大亏。设想一个场景:后端接口因为依赖的数据库连接池打满,导致大量请求 5 秒超时,如果每个请求失败后立刻重试 3 次,原本 100 个请求进来,实际打到后端的可能是 400 个,后端被进一步压垮,形成“重试风暴”。

正确做法是对每一次重试做退避,也就是让重试间隔随次数指数增长。指数退避的常见公式是:延迟时间 = 基础延迟 × 2 的 n 次方,再加上一个随机抖动,避免多个请求在同一时刻集体重试。

function getBackoffDelay(baseMs: number, attempt: number): number { const exponential = baseMs * 2 ** attempt; const jitter = Math.random() * 100; return exponential + jitter; } // 第 0 次失败后等待约 500ms // 第 1 次失败后等待约 1000ms // 第 2 次失败后等待约 2000ms

除了退避,还有一个容易忽略的原则:不是所有失败都适合重试。HTTP 4xx 这类客户端错误,比如参数不对、鉴权失败,重试多少次结果都一样,反而浪费服务端资源。真正值得重试的是网络层错误和 5xx 服务端错误,因为前者可能是临时断网,后者可能是短暂的负载高峰。在 fetch 中,网络错误通常表现为 TypeError: Failed to fetch,判断起来并不难。

重试之外还要考虑降级。不是所有请求失败都必须失败到底,合理的降级策略能大幅改善用户体验:列表接口失败可以展示缓存数据并提示“数据可能不是最新”,详情接口失败可以展示骨架屏加一张空状态图,权限接口失败可以默认收起所有敏感操作。降级的本质是“在有损状态下仍然给出可用的界面”,而不是一直转圈直到用户刷新页面。

如果你发现某个接口在持续失败,还应该考虑更底层一点的“熔断”思路:记录连续失败次数,达到阈值后,短时间内直接拒绝新的请求,不再打到后端,等冷却窗口过去再恢复。熔断的细节这里不展开,但记住一个结论就够了:调度器不仅要管“怎么发请求”,更要管“发了之后,失败到什么程度必须停手”。

3. 自己动手写一个轻量“ax调度器”

3.1 先想清楚调度器对外长什么样

把上面的机制整合成一个能用的调度器,并不需要写出一个复杂的框架。我建议从对外接口开始设计,一个调度器至少满足四个诉求:并发上限、任务优先级、失败重试、外部取消。

我设计的一个最小接口是这样:

export interface SchedulerTask<T = unknown> { id: string; priority?: number; // 越大越优先 executor: (signal: AbortSignal) => Promise<T>; timeout?: number; // 单次执行超时 retries?: number; // 最大重试次数(不含首次) baseDelay?: number; // 指数退避的基础延迟 } export interface AxScheduler { add<T>(task: SchedulerTask<T>): Promise<T>; cancel(taskId: string): boolean; }

调用方使用起来非常直观:

const scheduler = new AxScheduler({ concurrency: 4 }); const result = await scheduler.add({ id: 'user:list', priority: 10, executor: signal => fetchUsers(signal), timeout: 5000, retries: 2, baseDelay: 300, });

为什么把优先级设计成数字而不是字符串?因为数字天然可比较、可排序,而字符串最终还要映射成数字才能参与排序。优先级的含义是“当并发池已满时,高优先级的任务可以插队先执行”。在业务里这非常有用:用户主动点击的操作,应当优先于后台的预加载、批量同步等低优先级任务。

3.2 优先级队列与并发池的实现

调度器内部有两块核心数据结构:一个待执行任务队列,和一个“当前正在执行的数量”计数器。任务入队后先按优先级排序,每次有机会执行时,从队列里取出优先级最高的任务,开始执行。

由于绝大多数场景的任务量不会大到需要引入二叉堆,我用数组加线性扫描来挑最大优先级任务,代码可读性反而更好。如果你的工程里任务量上万,再考虑换成优先队列实现。

interface InternalTask { id: string; priority: number; retries: number; baseDelay: number; timeout?: number; executor: (signal: AbortSignal) => Promise<unknown>; resolve: (value: unknown) => void; reject: (reason?: unknown) => void; } class AxScheduler { private queue: InternalTask[] = []; private activeCount = 0; private readonly concurrency: number; constructor(options: { concurrency: number }) { this.concurrency = options.concurrency; } add<T>(task: SchedulerTask<T>): Promise<T> { return new Promise<T>((resolve, reject) => { this.queue.push({ id: task.id, priority: task.priority ?? 0, retries: task.retries ?? 0, baseDelay: task.baseDelay ?? 500, timeout: task.timeout, executor: task.executor, resolve: resolve as (value: unknown) => void, reject, }); this.next(); }); } cancel(taskId: string): boolean { const index = this.queue.findIndex(t => t.id === taskId); if (index === -1) return false; this.queue.splice(index, 1)[0].reject(new Error('task cancelled')); return true; } private next(): void { while (this.activeCount < this.concurrency && this.queue.length > 0) { const bestIndex = this.queue.reduce( (best, item, index) => item.priority > this.queue[best].priority ? index : best, 0 ); const [task] = this.queue.splice(bestIndex, 1); this.activeCount++; this.execute(task) .then(task.resolve) .catch(task.reject) .finally(() => { this.activeCount--; this.next(); }); } } }

这段代码里的核心逻辑集中在 next() 方法里。每次有任务完成,activeCount 减一,然后立刻调用 next() 从队列里补充新任务。因为 while 循环会持续到并发池满或者队列为空,所以哪怕同时完成多个任务,并发数也不会超限。

3.3 竞态取消与超时控制如何接入

上面这个版本的调度器还缺两个关键能力:单次执行超时,以及任务开始后还能被外部取消。超时控制很简单,给每个 executor 包一层带 timeout 的 Promise 即可。但注意,超时不能只靠 Promise.race 的方式,因为真正发出去的 fetch 请求并不会因为外层 Promise 失败而停止,必须配合 AbortController 才能把网络请求真正中断。

更优雅的方案是让“超时”和“取消”复用同一个 AbortController。统一封一个执行函数:

private execute(task: InternalTask): Promise<unknown> { const controller = new AbortController(); let timer: ReturnType<typeof setTimeout> | undefined; if (task.timeout) { timer = setTimeout(() => controller.abort(), task.timeout); } const promise = task.executor(controller.signal) .finally(() => { if (timer) clearTimeout(timer); }); // 外部通过 cancel 传入该任务的 signal // 如果 need 取消,调用 controller.abort() return promise; }

但要支持“按任务 id 取消正在执行的任务”,调度器还需要维护一个从 id 到 controller 的映射。我在上一节代码中暂时没有加这个表,实际补充很简单:在 execute 内部加一个 Map 记录,开始时写入,结束时删除。如果业务组件在页面卸载时调用 scheduler.cancel('user:list'),调度器就会让该任务正在执行的请求直接中断。

这样调度器就同时做到了三层取消:任务在排队中取消,直接从队列移除;任务执行中取消,中断底层请求;任务已取消时重试逻辑自动跳过,不再发起新的请求。这三层是竞态保护的地基。

3.4 业务里怎么用:搜索框和批量导入两个实例

有了调度器,业务代码可以非常干净。拿搜索联想来举例,我只需要在组件里维护一个调度器实例,每次输入变化时,先把上一次同 id 的任务取消,再添加一个新任务。注意在真实项目里,调度器应该做成全局单例,而不是组件内部 new 一个,否则切换组件时正在执行的任务就失去控制了。这里为了展示语义,先在组件外创建共享实例。

const sharedScheduler = new AxScheduler({ concurrency: 6 }); let searchId = 0; async function onSearchInput(keyword: string) { const currentId = `search:${++searchId}`; sharedScheduler.cancel(`search:${searchId - 1}`); const data = await sharedScheduler.add({ id: currentId, priority: 10, executor: signal => fetch(`/api/search?q=${encodeURIComponent(keyword)}`, { signal }) .then(r => r.json()), timeout: 3000, }); renderList(data.list); }

这个写法最直观的好处是:无论用户输入多快,上一个请求要么被取消,要么返回后被遗弃,不会出现“慢请求覆盖快结果”的经典问题。而“搜索联想”这类高频请求占用了并发池时,其他页面里的普通请求依然可以正常执行。

批量上传场景则更像一个异步任务队列。每次用户选择一批文件,就把每个文件封装成单独任务,设置较低的优先级,限制并发数量。这样不会因为用户一次性选了 20 个文件,就把整个浏览器的请求队列占满。

const fileScheduler = new AxScheduler({ concurrency: 4 }); function uploadFiles(files: File[]) { return Promise.allSettled( files.map((file, index) => fileScheduler.add({ id: `upload:${Date.now()}:${index}`, priority: 5, executor: signal => uploadFile(file, signal), timeout: 30000, retries: 2, baseDelay: 1000, }) ) ); }

注意这里的返回结果是 Promise.allSettled,一个文件失败不会影响其他文件继续上传。调度器内部则通过 timeoute 和 retries 实现了“失败自动重试”,用户完全不感知重试过程,但上传成功率会明显提升。

3.5 调度器设计的取舍与工程边界

有一个问题经常被讨论:既然已经有 p-limit、p-queue 这些成熟库,为什么还要自己写?我的观点是,生产环境首选成熟库没有任何问题,p-queue 甚至原生提供了优先级、并发、超时能力。自己写一遍调度器的价值在于“理解”,理解之后你才能判断一个第三方库是否合适、需要怎么配置、出了问题怎么排查。即便最后你用了 p-queue,也不影响你对这类问题的认知深度。

还要提醒一点:调度器只应该管任务的执行机制,不要管业务状态。比如“搜索联想结果应该渲染在哪个 div 里”“上传成功后要不要更新列表”,这些属于业务层逻辑。如果把业务状态也塞进调度器,调度器很快就会变成一个难以维护的“上帝对象”。保持职责单一,才能让调度器足够通用、足够稳定。这也是我在实际项目里最深的体会。

4. 实际项目中容易踩的五个坑与排查实录

4.1 坑一:请求是取消了,但定时器和内存泄漏没跟着取消

先描述一个真实场景:页面里有一个轮询接口,每 5 秒拉一次最新状态。用户切走页面,组件卸载时开发者把轮询定时器清掉了,但上一次还没返回的请求没有取消。过了几百毫秒,请求返回,回调里执行了 setState,React 在控制台打出那个经典的警告——“对已卸载组件执行状态更新”。更麻烦的是,如果轮询场景还叠加了 setTimeout 重试,组件卸载后定时器依然在跑,请求会持续打给后端,新开的页面也会被这些“幽灵请求”拖慢。

这个坑的本质是:请求取消和定时器清理是两件事,必须同时处理。在 React 里,推荐的做法是用 AbortController 统一管理。组件卸载时在 effect 的清理函数里调用 abort,同时把定时器也放在同一个清理流程里。

useEffect(() => { const controller = new AbortController(); const timer = setInterval(() => { fetch('/api/status', { signal: controller.signal }) .then(r => r.json()) .then(renderStatus) .catch(() => {}); }, 5000); return () => { clearInterval(timer); controller.abort(); }; }, []);

这里 controller.abort() 不仅会中断当前正在执行的 fetch,还会让后续的 fetch 直接以失败告终,catch 分支可以静默处理。实测下来,这是最不容易漏掉的做法,因为它把“请求生命周期”和“组件生命周期”绑定在了一起。

4.2 坑二:并发数拍脑袋设置,弱网场景直接排队到超时

我见过一个团队把核心页面的接口并发上限设成了 20,理由是页面模块多,希望首屏更快。结果在弱网环境下一测,20 个请求同时发出,HTTP/1.1 下同域名连接数只有 6 个,剩余 14 个全都进入排队阶段。前面几个大接口响应又慢,排队的请求纷纷超时,页面比不加并发限制时更慢。

这个问题的根源是“并发数”和“连接数”并非同一个概念。浏览器连接数是硬限制,超过之后请求只能排队等连接释放。并发池的 limit 应该以“不会让请求在排队阶段滞留过久”为标准,而不是“尽量多开任务”。我现在的习惯是:先看主要接口的响应时间范围,再估算连接数上限,最终把并发池设为 4 到 6。如果项目用的是 HTTP/2,连接数不再是主要瓶颈,但服务端压力仍然在那里,4 到 6 依然是合理的起点。

排查这个问题的快捷方式并不复杂。打开浏览器开发者工具的 Network 面板,看时间线里的每一根请求条,如果大部分时间花在 Queueing(排队)阶段,而且颜色特别重,那多半就是并发/连接数瓶颈,而不是后端慢。

4.3 坑三:对所有错误都重试,打出一个重试风暴

重试逻辑写得不对,很容易把一个小故障放大成大事故。有一种经典事故链路:后端某个接口偶发 0.5% 的超时,前端为了提升成功率,统一对失败错误重试 3 次,间隔只有 200ms。正常情况下没问题,但一旦后端因为部署或负载升高进入“半健康”状态,0.5% 变成 30%,此时前端层层重试,实际打到后端的请求翻了 4 到 5 倍,后端彻底雪崩。

正确的做法是给重试加三个约束。第一,只重试“值得重试”的错误,比如网络层错误 TypeError: Failed to fetch 和 5xx 服务端错误,4xx 一律不允许重试。第二,重试间隔必须指数退避,而且加随机抖动。第三,设置全局重试风暴熔断,例如同一个接口在 30 秒内重试次数超过 10 次,就暂停该接口的重试,直到冷却期结束。

在代码层面,判断 fetch 错误类型并不复杂,但很多人图省事把 catch 到的所有错误都丢进重试逻辑,这是最危险的行为。调度器在设计时就应该把错误分类作为重试的前置条件,这一条比任何重试公式都重要。

4.4 坑四:调度器放在组件内部,一切换页面就集体重建

调度器的生命周期也是一个很容易被忽略的坑。如果把 scheduler 实例写在 React 组件函数体内,每次渲染都会新建一个调度器,旧调度器里的队列和正在执行的任务全部失去引用。用户快速切换 Tab,前一个页面的请求还在跑,但已经没有任何代码能取消它们,只能等浏览器和服务端自己超时。

正确做法是把调度器提到组件之外,做成模块级单例,或者放进一个独立的 ts 文件。组件卸载时,通过 scheduler.cancel 按任务 id 取消属于本组件的任务,而不是销毁整个调度器。这样才能既保证任务可取消,又避免同一个页面的多个组件用不同调度器,互相争抢并发池而失去全局统筹。

全局单例的另一个好处是,多个页面共用同一个并发池。例如列表页后台在批量下载,详情页用户主动刷新,共享并发池会让主动刷新拥有更高优先级,批量下载让出资源。这就是“调度”比“每个页面各自发请求”更高级之所在。

4.5 坑五:没有日志,出了问题只能靠猜

异步请求的排查难度比同步代码高很多。同步代码可以打断点跟局部变量,异步请求的链路在任务队列、并发池、重试循环之间来回穿梭,出错时往往已经找不到第一现场。我见过不少项目,线上用户反馈“功能时好时坏”,开发者打开控制台什么都看不到,因为生产环境早就把 console 清掉了。

我的建议很简单:调度器一定要内置可观测性。任务入队时记一条日志,任务开始执行时记一条,成功时记一条,失败时记一条,取消时记一条,重试时也要记。不需要接很重的监控系统,先统一走一个 logger 回调,本地开发时打印到控制台,生产环境可以上报到已有的监控平台。

日志示例:

type TaskEvent = | { type: 'enqueue'; id: string; priority: number } | { type: 'start'; id: string } | { type: 'success'; id: string; cost: number } | { type: 'failure'; id: string; error: unknown } | { type: 'retry'; id: string; attempt: number } | { type: 'cancel'; id: string }; logger?: (event: TaskEvent) => void;

有了这套日志,排查“某个接口为什么反复请求”这类问题时,你不再靠猜,而是能直接从日志里看到:这个任务入队了几次、重试间隔多少、哪一次失败触发了重试、哪一次被调度器主动取消。可观测性是调度器能不能真正用起来的最后一环,千万别省。

4.6 问题速查表

症状可能原因排查切入点解决方向
列表显示旧数据过期响应覆盖新响应在 then 回调里打印响应中的请求序号请求取消或序列号丢弃
接口全部排队几十秒并发数超过浏览器连接数Network 面板 Queueing 阶段耗时占比降低并发池上限
一次弱网导致几十次重试所有错误都触发重试服务端日志请求量暴增按错误类型区分是否重试,加指数退避
切页面后控制台出现 setState 警告组件卸载未取消请求与定时器React 警告堆栈指向的组件在 effect 清理函数中取消请求
页面刷新后旧接口仍在打后端调度器实例随组件销毁刷新前后对比 Network 面板请求数量调度器全局单例,按任务 id 取消

4.7 一个小技巧:把 React 的调度思想借过来

聊完具体的坑,我分享一个在项目中对我帮助很大的思路:React 把任务调度推向极致的方案是“可中断渲染+优先级”。它把一次大的渲染工作拆成小片,高优先级更新插队,低优先级任务可以被打断。我们的请求调度完全可以借鉴同一个理念。

比如列表页有首次加载和后台预加载两种请求,首次加载优先级是 10,预加载优先级是 3。用户向下滚动触发预加载请求时,如果此时用户又快速点击了“刷新”按钮,调度器应该让刷新请求插队,而不是在预加载请求后面干等。你不需要真的支持“中断一个已经发到网络里的请求”,只需要在排队阶段,让高优先级任务插到队列头部。这一点我们的调度器已经做到了。

更深一层,也可以在渲染层面配合:高优先级请求成功后,立刻渲染完整内容;低优先级请求成功后,只在空闲时把内容补充到页面里,避免一次 setState 引发的主线程卡顿。这种前后端联合的“调度思维”和热词里的“ax调度”刚好形成呼应——调度不是某一层的事,而是从网络请求到界面更新的完整控制链。

5. 写在最后的几点实际体会

在我自己维护的项目里,调度器从无到有,前后只花了一个下午。但就是这几十行代码,让“搜索竞态”“并发打满”“不明重试”这三类问题在后续半年里几乎绝迹。每次有新同事来问我该从哪里入手优化接口层,我都会建议先做三件事:给所有请求加上可取消能力,把并发上限管住,再定义清楚哪些错误值得重试。这三步比引入任何重型框架都更能止痛。

调度器这类东西还有个好处,是它有很强的“可移植性”。今天你写前端用的是 fetch 和 AbortController,明天切到小程序或者 React Native,调度器的队列、优先级、并发池、指数退避这些思路完全通用。换个环境,你不需要重新学一套理论,只需要换一个请求对象和取消机制。所以花时间把“ax调度”彻底吃透,长期来看非常划算。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询