ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写前端Ax调度器:解决请求竞态与并发控制的完整实践

手写前端Ax调度器:解决请求竞态与并发控制的完整实践 做前端这几年我踩过一个特别典型的坑用户在一个页面上快速切换筛选条件A条件下拉的数据还没回来B条件的请求先到了等A的结果返回来直接覆盖了页面界面上一片错乱。接口全部正常后台也没报错最后查下来问题就出在请求返回的顺序乱了。这种问题业内说得最多的一句话是“这不是后端的锅是前端没做好调度。”也就是今天我打算展开讲的“ax调度”话题。很多同学一听到“调度”两个字就觉得很高深以为是中间件、负载均衡或者消息队列那些东西其实放到前端世界里调度就是一个很朴素的问题你发出去的请求谁先发、谁先回、谁可以同时发、谁该被取消得有人管。管好了页面顺滑稳定管不好线上事故一个接一个。这篇文章我想从一个真实的一线开发视角聊聊ax到底是什么为什么一个简单的页面会乱以及我实际是怎么把请求调度一层层做起来的。文章里会有完整可跑的代码、踩过的坑、排查思路适合正在写异步请求、被竞态问题折磨、或者想把自己的请求封装层做得更稳健的人。1. “ax”到底是什么先把这个词拆明白1.1 它不是一个库名而是一套关于“异步”的思考方式说到“ax”行业内更常用的全称是 Ajax也就是 Asynchronous JavaScript And XML。这个名字今天听起来有点年代感——现在我们早就不局限于 XML 了绝大多数场景返回的是 JSON甚至有些场景连 JavaScript 都不用但核心思想没变页面不重新加载在后台发起网络请求拿到数据后局部更新页面。很多人以为 Ajax 是某个库、某个工具比如早期 jQuery 里的$.ajax现在项目里经常用的 axios名字里都带“ax”。但换个角度来看ajax 本质上是一种通用的异步交互模式不是某个具体依赖。底层是 XMLHttpRequest 也好fetch 也好只要你在浏览器里“不刷新页面、发请求、收数据、改界面”就是在用这种思路写代码代码。而“ax调度”这个词重点不在“ax”而在“调度”。你把一次网络请求看成一辆公交车页面上的操作就是不断发车的公交站台。如果没有人调度车一来就发那会出现什么情况有的车挤成一堆有的车堵在半路还有先发出去的车绕路绕到最后才回来。换成页面语言就是请求并发过高浏览器连接被占满接口迟迟不返回用户等得原地转圈数据返回顺序错乱页面展示和用户意图不一致。所以我会把“ax调度”理解成对所有异步网络请求的有序管理。它要解决的问题包括且不限于接口之间的依赖顺序、并发数量控制、失败重试策略、请求取消与竞态处理、超时管理。这些单拎出来任何一项都不复杂真正的难点在于组合起来在真实业务里稳定跑下去。1.2 事件循环视角下的调度本质请求是怎么乱序的聊调度之前必须先理解一个基本问题请求为什么会乱序这里得拉上 JavaScript 的事件循环机制说清楚。浏览器发出一个网络请求之后这个请求不会阻塞主线程。主线程继续执行后面的代码等响应回来之后再把回调扔进任务队列由事件循环按顺序拉出来执行。单看代码你写的是const resA await request(/api/a) const resB await request(/api/b) console.log(resB)这里用了await后面的request(/api/b)确实会等 A 回来再发。但如果你不是按这种顺序写而是同时发出去const resA request(/api/a) const resB request(/api/b)那么 A 和 B 几乎是同一时刻到达服务器的谁先返回完全取决于网络状态、接口耗时、服务器处理速度。A 虽然先发但它完全可能晚于 B 回来。于是问题来了你基于“A 先回来”的假设去更新页面状态但实际先回来的是 B状态就错乱了。再深入一层浏览器的事件循环是单线程的但网络 IO 是系统底层多线程协作的。也就是说回调的执行顺序是确定的但网络完成的顺序完全不确定。这就是一切的根源代码写的顺序是确定的网络返回的顺序是不确定的两者之间如果不做约束就只能靠运气。调度要做的就是把这种“不确定”重新变成“确定”。所以你看对于接口有依赖的场景我们串行发请求对于无所谓顺序但都想快速返回的场景我们并发发对于不想让旧请求影响新请求的场景我们取消旧请求或者做序号标记。每一个操作本质上都是在告诉系统这个请求我要它什么时候发、发的数量、怎么处理结果。2. 为什么需要Ax调度哪些坑逼着我开始写调度器2.1 接口依赖串行写成了并行的地狱先说最常见的依赖场景。比如你要展示一个用户的订单列表需要先拿到用户信息再根据用户信息里的某个字段去查订单。新手最容易写成这样const user await fetchUser() const orders await fetchOrders(user.id)这写法没有错两个接口天然串行A 不回来 B 不会发。问题在于遇到复杂页面时这种链式依赖会写成一长串中间一旦出现异常后续全部断掉排查起来非常费劲。你看到的报错可能是 B 接口 500但真正原因可能是 A 接口已经拿到了错误的 token导致 B 请求带着过期凭证过去了。再有一种情况是两个接口互不依赖但你用串行方式去写const user await fetchUser() const banner await fetchBanner()明明两个接口之间没有任何关系完全可以同时发结果因为代码结构写成了串行页面加载白白多花一个接口的时间。这里的问题不是技术做不到并发而是你没有在代码层面给出“它们可以并行”的指令。我实际做调度后会把这类逻辑统一收口到一个队列里。队列里声明哪些任务互相独立、哪些任务有依赖关系调度器根据依赖图来决定什么时候放行哪个请求。这个思路不复杂但能让你从散落各处的await泥潭里跳出来代码和网络行为都变得可预测。2.2 并发争抢最后一个请求不一定是最后返回的那个在我遇到的真实问题里最典型也最隐蔽的坑是竞态race condition。举一个我印象特别深的例子搜索框的联想词。用户输入“苹”前端发起请求查?keyword苹用户又输入到“苹果”继续发起请求查?keyword苹果。从时间上看keyword苹果的请求比keyword苹晚发晚发的请求本应更晚返回但网络这东西从来不按套路出牌。keyword苹的接口因为缓存或者服务端处理更快反而是在keyword苹果之后才返回。如果你只是简单地谁返回谁更新页面最后页面上显示的结果会是“苹”而不是“苹果”用户直接输入框里打字打了一半联想结果却停留在他几秒前的状态。这种问题靠什么解决最保守的办法是取消旧请求。每次发送新请求之前把上一个未完成的请求 abort 掉只保留最后一次的结果。fetch 和 axios 都提供了取消能力后面我会写具体实现。另一种是用序号标记比如每次请求都带一个自增的 seq只有 seq 最大的那次响应才有资格更新页面。两套方案我都在生产环境用过取消方案更省资源序号方案更简单直观你可以根据情况选。再延伸一下不只是搜索框凡是“连续操作 异步请求 状态更新”的场景都逃不开并发争抢Tab 切换、表格筛选、分页跳转、图片懒加载、图表联动。只要用户的操作频率高于请求的返回频率竞态就一定会出现只是早晚和严重程度的问题。2.3 重复提交与失败风暴调度顺手解决的隐性成本还有一类问题不太起眼但线上事故没少出用户手一抖双击了提交按钮或者网速慢的时候没忍住多点了几下同一个下单请求就那么发出去了。后端如果没做幂等用户会发现订单创建了两条。前端能做的第一道防线当然是按钮 disable但真正严谨的做法是在请求层做去重同样的 key、同样的请求参数在未完成期间直接丢弃掉后续的重复请求而不是放行它们。另一个隐性成本是失败风暴。某个时刻网络抖动页面一次发了几十个请求几乎全部超时然后你又在代码里给每个请求单独加了重试逻辑。好几十个请求同时失败同时重试同时再次失败接口层被打到雪崩。这种情况调度器里必须要有“重试退避”策略失败次数多了自动拉长重试间隔或者在一定时间内直接不允许该接口再次被调用。简单说失败风暴不是靠暴力重试能解决的必须要限速、限流、熔断这些虽然听着像是后端的东西前端请求调度层同样能做。我把这些问题整理过一张表方便你对照自己的场景业务现象背后原因调度手段数据被后返回的旧请求覆盖并发请求竞态取消旧请求 / 序号标记接口耗时被白白拉长本可并行的请求写成串行依赖分析 并发放行页面卡顿请求堆在一起并发数过高浏览器连接队头阻塞并发窗口限制用户重复点击引发重复下单请求层没有幂等去重同 key 合并请求网络抖动导致请求风暴无限制重试指数退避 熔断3. 从零手写一个可用的Ax调度器3.1 先定接口调度器要管哪些事动手写代码之前先把需求想清楚。我最初犯过一个错误一上来就咔咔写实现结果写完发现接口设计得很别扭调用方使用成本很高。调度器本质上对外暴露的就几件事add(task)把请求任务加入调度队列支持串行和并发的配置支持指定优先级支持取消未开始的任务支持给任务设置超时用 TypeScript 定义一下接口最清晰interface TaskOptions { key: string // 任务的唯一标识用于去重和取消 priority?: number // 数值越大优先级越高默认 0 timeout?: number // 超时时间毫秒默认不超时 retry?: number // 失败重试次数默认 0 } interface Scheduler { addT(fn: () PromiseT, options: TaskOptions): PromiseT cancel(key: string): void // 取消指定任务 cancelAll(): void getPendingCount(): number // 当前等待中的任务数 }为什么add接收的是一个返回 Promise 的函数而不是直接把请求发出去因为调度器不该关心请求具体怎么发它只负责管“什么时候调用这个函数”。你把真正发请求的逻辑包在函数里调度器在合适的时机去调用它这种设计的解耦性最好。调用方想用 fetch、axios 还是自己封装的 request 都行只要最终返回 Promise 就行。这一点是很关键的实践心得我后来和一个同事协作时他非要把 axios 实例传进调度器里结果调度器被绑死在一个请求库上迁移成本直线上升。正确的做法就是让调度器只认 Promise。3.2 串行队列版本先把顺序守死最简单的调度器就是一条队列一次只放行一个任务。这里有一个很值得注意的点串行不是让用户一个接一个写await而是把任务全部扔进同一个调度器由调度器决定一次只执行一个。我用代码演示class SerialScheduler { constructor() { this.queue [] this.running false } add(fn, options {}) { return new Promise((resolve, reject) { const task { fn, resolve, reject, options } this.queue.push(task) this.#run() }) } async #run() { if (this.running) return this.running true while (this.queue.length 0) { const task this.queue.shift() try { const result await task.fn() task.resolve(result) } catch (err) { task.reject(err) } } this.running false } }核心逻辑就一个while循环拿着队列里的任务一个接一个地执行。running标志位很重要它保证多个add调用同时发生时不会启动多个循环。而我用#run这种私有方法写法可以避免外部代码误调用。你可能会问这不就是把 await 换个写法吗有什么区别区别在于调度器的串行是全局的。你在页面四个不同组件里各写一段代码每个组件都往同一个调度器里塞任务那么这四个请求会排成一队前面一个不走完后面三个绝不发出去。而如果每个组件自己写await它们之间是互相隔离的无法形成这种全局有序的效果。串行的不足也很明显如果队列里有 20 个不相关的请求全都必须等第一个跑完才能跑页面加载时间会被拖垮。所以串行只适合小规模、强依赖的场景更多时候我们需要“并发但限制数量”的调度器。3.3 并发限制与优先级给请求排队而不是同时挤爆浏览器对同一域下的连接数有上限大概是 6 到 8 个具体数值因浏览器而异。超过之后请求会排队等待连接释放这个等待不受你控制你也不知道要等多久。与其让浏览器默认的等待策略影响体验不如你主动控制并发数把它当成调度策略的一部分。我用一个加上了并发数和优先级的调度器演示一下核心实现class QueueScheduler { constructor(concurrency 4) { this.concurrency concurrency this.queue [] this.activeCount 0 } add(fn, options {}) { return new Promise((resolve, reject) { const task { fn, resolve, reject, priority: options.priority || 0, key: options.key || Math.random().toString(36).slice(2) } this.queue.push(task) this.#schedule() }) } #schedule() { while (this.activeCount this.concurrency this.queue.length 0) { // 从队列里取出优先级最高的任务 this.queue.sort((a, b) b.priority - a.priority) const task this.queue.shift() this.activeCount task.fn().then( (res) { task.resolve(res) }, (err) { task.reject(err) } ).finally(() { this.activeCount-- this.#schedule() }) } } getPendingCount() { return this.queue.length } }这个版本的核心是在#schedule里做“补位”操作每执行完一个任务就把activeCount减一然后立刻从队列里取出下一个最高优先级的任务。while循环保证了只要还有空位就马上有新任务顶上不会出现明明并发没满却空等的情况。排序这里我直接每次取之前 sort 一遍。如果数据量不大这种简单写法完全够用但如果任务队列里经常有上百个任务sort 的复杂度可能成为瓶颈更好的做法是用最小堆维护优先级。不过说句实话我在业务里很少见到任务数超过几十个的工程上先用简单的方案解决 90% 的问题剩下 10% 等到你真的遇到性能问题时再优化这个思路比一开始就搞复杂数据结构要稳妥得多。3.4 取消与合并处理竞态的最后手段很多请求的问题不在于它慢而在于它已经没用了。用户在搜索框敲到第四个字的时候前三个请求基本就是废请求继续等它们只会引入竞态。处理废请求的方法就是取消。现代浏览器里fetch 的取消依赖AbortController下面这段代码展示了调度器如何和它配合class CancellableScheduler extends QueueScheduler { constructor(concurrency 4) { super(concurrency) this.taskMap new Map() // key - AbortController } add(fn, options {}) { const { key, timeout } options // 如果当前 key 的任务已经在等待或执行中先取消旧的 if (key this.taskMap.has(key)) { this.cancel(key) } const controller new AbortController() const wrappedFn () { this.taskMap.set(key, controller) return Promise.race([ fn(controller.signal), timeout ? this.#delayAndAbort(timeout, controller) : new Promise(() {}) ]).finally(() { if (key this.taskMap.get(key) controller) { this.taskMap.delete(key) } }) } const promise super.add(wrappedFn, options) return promise } cancel(key) { const controller this.taskMap.get(key) if (controller) { controller.abort() } } #delayAndAbort(ms, controller) { return new Promise((resolve) { setTimeout(() { controller.abort() resolve() }, ms) }) } }取消旧请求的逻辑是这套方案里最关键的一环。每次add的时候先检查有没有相同 key 的任务还在等有就直接把旧的 abort 掉这样就保证了一条铁律同一时刻同一个 key 对应的请求最多只有一个存活。你根本不需要在业务代码里操心“上次请求返回了要不要覆盖”因为旧请求根本不可能再回来了。Promise.race在这里的作用是加超时。如果请求在timeout毫秒内没返回调度器就强制 abort 掉这个请求。这里要小心一点abort之后 fetch 会抛出一个名为AbortError的异常而不是正常 resolve。所以调用方要注意捕获这个错误避免把取消误判成一次逻辑失败这一点我会在后面的常见问题里详细展开。3.5 接进业务代码错误处理与状态管理写好了调度器类接进业务代码时又踩过几个坑其中最大的教训是调度器只管“什么时候执行”和“要不要取消”错误处理和页面 loading 状态必须单独管理不能混进调度器的核心逻辑里。我习惯的做法是给调度器外面再包一层封装。比如项目里如果用的是 axios我会写一个小的 request helperimport axios from axios import { CancellableScheduler } from ./scheduler const scheduler new CancellableScheduler(4) export function request(config, options {}) { const { key options.url, priority 0, timeout 10000 } options return scheduler.add( (signal) axios({ ...config, timeout: false, // 让调度器统一管超时这里关闭 axios 自带超时 signal }).then(res res.data), { key, priority, timeout } ) }这样业务组件里调用时完全感觉不到调度器的存在就是简单地const data await request(/api/list)。如果需要控制并发、去重、取消只需要传对应的 options。调度逻辑全部收敛在底层不会散落在各个组件的代码里这是我认为最合理的封装方式。还在一个细节上犹豫过页面 loading 状态到底放在哪一层管理。最后我用的是“等待中的任务计数”。调度器暴露一个getPendingCount()方法页面根据这个数量来判断是否显示 loading。这样出现多个请求同时进行时loading 不会被错误地隐藏也不会因为某个请求失败而一直转圈。你说这是不是调度器的职责严格来说不算但在真实工程里这两件事经常纠缠在一起我建议你在调度器里预留一个事件钩子比如onPendingChange既不影响核心逻辑又方便外部做状态联动。4. 常见问题与排查技巧实录4.1 取消的请求在 catch 里误报错误这个问题我第一次实现取消时简直被坑惨了。业务方告诉我请求明明一切正常但控制台里每次取消都会打出一个红红的 error而且来源清晰指向生产环境。查了很久才发现fetch 被abort()之后会 reject 一个DOMException错误名字叫AbortError。这个异常在业务代码的 catch 里被当成普通的网络错误处理了于是每次取消都被记为一次“失败”。解决办法是在自定义的request包装函数里做一次错误过滤function isAbortError(err) { return err err.name AbortError }catch 到错误之后先判断是不是AbortError是的话就静默处理不打印、不上报、不触发重试。放到生产环境后要特别注意监控系统里不要把这种被调度器主动取消的请求统计成接口错误率否则你的服务端监控会天天报警但实际业务毫无异常。4.2 超时配置失效接口卡到天荒地老很多初学同学都干过这种事在 axios 里配置了timeout: 5000以为超过 5 秒请求就会被掐断。但这个 timeout 针对的是“响应超时”也就是说如果请求已经发出去但服务器一直没有返回响应axios 会在 5 秒后 reject 掉这个请求。听起来没问题对吧但实际上你忽略了两个场景一是服务器在 4 秒时已经返回了响应头但 body 在 15 秒后才慢慢吐完这不算超时二是三方库底层使用的 XMLHttpRequest有的实现里 timeout 行为并不完全一致。更严谨的做法是在调度器层控制“整个请求动作”的生命周期也就是我上面代码里用的Promise.race加AbortController。只要超过设定时间不管当前是在等待响应头还是在下载 body都统一通过controller.abort()强制中断。这样超时真正覆盖的是“从发起到结束”的整个流程而不是某个中间状态。你如果现在查看自己项目的请求封装发现超时逻辑还是直接写在库配置里我建议优先往上升级到调度器层。4.3 竞态问题靠“令牌”兜底取消机制能解决绝大多数竞态但它依赖一个前提你的调度器确实取消了旧请求。如果旧请求其实已经返回了只是回调代码还在执行队列里排队Cancel 是杀不掉它的。这个场景比较刁钻我在快速滚动无限列表时碰到过。面对这种情况最可靠的兜底是“令牌校验”。给每次请求分配一个seq响应回来之后先对比seq是不是最新的不是就直接丢弃let requestSeq 0 async function loadList(params) { const currentSeq requestSeq const res await request(/api/list, { params }) if (currentSeq ! requestSeq) { // 已经过期丢弃这次结果 return } renderList(res) }这段代码的好处是它完全不依赖网络层的取消能力纯粹在应用层把关。哪怕旧请求的响应溜回来了令牌一对比不对直接当没看见。我在生产环境里是取消机制和令牌校验两层同时开着的调度器负责第一时间取消应用层负责最后一道防线。不要觉得双保险多余网络调度的坑往往就在你觉得“应该没事了”的地方。4.4 React StrictMode 下调度器重复执行带来的坑如果你用 React开发环境默认开启 StrictMode组件挂载时的副作用会故意执行两次用来暴露潜在问题。这个机制对调度器不友好因为两次挂载可能触发两次重复请求而第一次的请求没有被取消第二次的请求又进队列了导致页面刚打开时出现多余的网络流量。排查过一次之后我把调度器实例挪到了模块作用域或者真实的状态管理仓库里而不是在组件内部新建。这样 StrictMode 即使让组件执行两次同一个组件实例拿到的是同一个调度器引用不会重新发一份完全独立的请求。一旦隔离请求的问题解决你再看到 StrictMode 下的请求日志能明显感觉到“干净”了很多。4.5 排查速查表最后给出一张速查表基本覆盖我这两年积累的常见症状和对应处理思路排查时可以直接拿来对照症状可能原因首选排查手段页面数据被旧请求覆盖并发请求竞态检查是否取消了旧请求加令牌校验接口一个接一个返回页面极慢隐性串行没有并发确认请求是否集中走到同一个调度器检查依赖关系接口卡住无限等待超时配置失效把超时收敛到调度器层配合 AbortController请求日志里频繁出现报错取消误报为失败过滤 AbortError单独处理重复操作产生重复请求没有去重调度器按 key 取消或合并网络抖动后服务端压力剧增重试无退避给调度器加指数退避与熔断排查竞态类问题有一个通用的思路先复现看请求的网络时间线确认哪个请求“晚发先回”再判断是取消时机的问题还是应用层更新逻辑的问题。多数情况下问题出在业务代码里对“过期数据”没有任何防御而不是请求本身。最后分享一点个人体会。我在早期的项目里也迷信过“框架会帮我处理一切”结果每次竞态事故都得靠线上紧急修复去顶。后来下定决心自己手写调度器虽然实现不算复杂但对整个异步模型的理解深入了一层。再往后看团队成员的代码谁有竞态风险、谁的请求没做超时保护几乎一眼就能扫出来。如果你现在正被重复请求、乱序响应、接口风暴困扰建议先别急着引第三方重量级方案用最小代码把队列做出来跑一跑很多问题的答案就在你掌控任务执行的细节里。自己亲手调过的调度业务会稳很多。
返回列表