4.4 高级 Ajax 特性


4.4 高级 Ajax 特性

本节摘要:真实网络会超时、用户会中途反悔、偶发失败需要机器自己扛。本节给请求装上工程必备的四件套——超时、取消(AbortController 全家桶)、带指数退避的重试,以及请求去重与竞态防护的整合——并把它们组装成一个生产级的可靠请求函数。前几章埋的伏笔(3.5 节的超时雏形、3.4 节的竞态、3.6 节的取消)在这里收拢成型。

为什么这四件套是"必备"而不是"高级"

先校准预期:标题里的"高级"是历史称谓,工程现实里它们是上线门槛。逐个看不做防护的代价:

没有超时:弱网下请求挂起两分钟,界面停在加载态,用户在一个已经放弃的页面上被欠着一次承诺。挂起即失败,只是没人宣布。

没有取消:用户切走标签页、关闭弹窗、修改筛选——在途请求继续跑完,回来把过期数据渲染到已经变化的界面上(3.4 与 3.6 节的竞态近亲),白耗流量与服务器算力。

没有重试:移动网络抖一下,请求失败,界面报错——用户手动刷新,重发一模一样的请求。机器能扛的失败让人扛,是体验的失职。

没有去重:双击按钮两个请求、多处组件同时要当前用户信息五个请求——重复执行的风险(不幂等的 POST 双重下单)加纯粹的浪费。

四件套装齐,请求层才算"敢上线"。下面逐件安装,最后组装。

一、超时:给等待设截止日

超时的本质是客户端单方面的最后通牒:超过时限就当失败处理,不等服务器表态。实现靠 AbortController 的定时触发:

function withTimeout(fetchPromise, ms, controller) { const timer = setTimeout(() => controller.abort('timeout'), ms); return fetchPromise.finally(() => clearTimeout(timer)); } async function request(url, options = {}) { const controller = new AbortController(); const { timeout = 8000, ...rest } = options; const p = fetch(url, { ...rest, signal: controller.signal }); const res = await withTimeout(p, timeout, controller); if (!res.ok) throw new Error('HTTP_' + res.status); return res.json(); }

超时值的定夺是业务判断:按用户耐心倒推——搜索联想 3 秒(再久用户已经在打下一个字了)、普通页面数据 8 到 15 秒、导出报表这类长任务不该用同步等待,改异步任务加轮询(5.4 节)。

一个工程细节:超时触发的 abort 与用户主动取消走同一个异常(AbortError),但语义完全不同——超时是故障(要提示、要重试),取消是流程(要静默)。区分方法一是在 abort 时带上原因(较新的实现里 abort 可传参),二是包一层:超时用独立的定时 abort,抛出前把错误翻译成明确的 TimeoutError,别让两者混进同一个 catch 里一锅端。

二、取消:AbortController 的统一机制

AbortController 是现代 Web 给"叫停异步操作"的统一答案:一个控制器管一个(或一组)信号,监听方各自响应。在请求层它有四个高频用法:

**用法一:用户操作取消。**上传进行中用户点"取消"按钮、下载前关闭弹窗:

const controller = new AbortController(); cancelBtn.addEventListener('click', () => controller.abort()); try { const data = await request('/api/export', { signal: controller.signal }); } catch (err) { if (err.name === 'AbortError') return; // 自己人 不算错(3.6 节) showError(err); }

**用法二:页面或组件卸载时批量取消。**用户离开页面,在途的十个请求一起没意义了——一个控制器绑所有请求,离开时一次 abort 全收:

const pageController = new AbortController(); // 页面所有请求都带 pageController.signal window.addEventListener('pagehide', () => pageController.abort());

组件化框架里同理:组件卸载钩子里 abort 它发起的一切请求,杜绝"卸载后 setState"与过期渲染。

**用法三:新请求取代旧请求(竞态根治)。**3.4 与 3.6 节的竞态防护,用取消实现最干净——发新的之前掐掉旧的,过期响应根本不存在:

let filterController = null; function applyFilter(criteria) { if (filterController) filterController.abort(); filterController = new AbortController(); return request('/api/list', { method: 'POST', body: JSON.stringify(criteria), signal: filterController.signal }); }

对比序号对账方案(过期响应到了再丢弃),取消是源头治理——流量、服务器算力、心智负担一起省掉。

**用法四:给请求组配统一超时。**页面级控制器加一个定时器,整页请求共享一个截止时刻("首屏两秒内必须出结果,出不来就统一降级")。

⚠️ 兼容性提示:老浏览器没有 AbortController 时要有降级(XHR 路线用 xhr.abort,或退回序号对账);polyfill 不能真正掐断底层请求,只能让上层"假装"取消——降级方案的诚实说明要写进注释。

三、重试:机器扛住偶发失败

重试的策略空间由两个问题决定:谁可以重试、隔多久再试

谁可以:按 3.5 节的错误分类——网络错误(请求没到,重发无损)与 5xx(服务端临时故障)可重试;4xx(请求本身有病,重试同病)与业务错误(余额不会因为你再问一次而变多)不重试。幂等性是红线:GET 天然可重试;POST 双重执行的代价(重复下单)可能是灾难——要重试 POST,必须配合幂等键(请求带唯一标识,服务端识别重复执行直接返回首次结果)。

隔多久:指数退避——1 秒、2 秒、4 秒,加随机抖动(间隔乘 0.5 到 1.5 的随机数)。抖动不是可有可无的花样:服务端从故障中恢复的瞬间,整齐划一的重试会立刻把它再打趴(雪崩),抖动把重试在时间轴上摊开。次数上限三到五次,总时长封顶(比如 15 秒),别无限循环。

完整实现:

const sleep = ms => new Promise(r => setTimeout(r, ms)); async function requestWithRetry(url, options = {}) { const { retries = 3, ...rest } = options; let lastError; for (let attempt = 0; attempt <= retries; attempt++) { try { return await request(url, rest); // 3.5 节的统一 request } catch (err) { lastError = err; const retryable = err.type === 'network' || (err.type === 'http' && err.status >= 500); const isLast = attempt === retries; if (!retryable || isLast) break; const base = 1000 * Math.pow(2, attempt); // 1s 2s 4s const jitter = base * (0.5 + Math.random()); // 抖动 await sleep(jitter); } } throw lastError; }

对用户的诚实:静默重试期间界面该有"重连中"的轻提示——用户点了没反应和点了在重试,是两种完全不同的心理体验。重试耗尽再正式报错,并把"已尝试几次"带进提示("网络不稳定,请稍后再试"),帮用户判断是再等等还是换网络。

四、去重与整合:一个生产级请求函数

最后一件:请求去重。同一关键请求(键为 URL 加参数加方法的指纹)在途时,后来的调用共享同一个 Promise 而不是再发一次——双击按钮、多组件同时初始化的场景自动免疫:

const inflight = new Map(); function dedup(key, factory) { if (inflight.has(key)) return inflight.get(key); const p = factory().finally(() => inflight.delete(key)); inflight.set(key, p); return p; }

把四件套与 3.5 节的错误分类组装起来,得到生产级请求函数的骨架:

async function api(url, options = {}) { const key = options.method + ' ' + url + ' ' + JSON.stringify(options.body); return dedup(key, () => requestWithRetry(url, { timeout: 8000, // 一、超时 ...options // 二、取消:signal 由调用方按场景传入 // 三、重试:requestWithRetry 内部按错误分类执行 }) ); // 四、去重:dedup 层 } // 调用方视角:只关心业务 const user = await api('/api/me'); const list = await api('/api/list', { signal: pageController.signal });

组装顺序有讲究:去重在最外层(重试期间后来的调用仍应共享同一个进行中的整体),取消信号穿透一切(abort 要能立刻终止包括 sleep 在内的整个链条——sleep 也监听 signal 的练习留给读者),错误分类在最底层翻译(重试与上层都依赖 err.type 决策)。

这个函数做完了什么?回头看它替业务代码挡掉的事故清单:挂起不返回、卸载后渲染、双击双下单、弱网报错、请求风暴、过期覆盖。每一件都曾真实地发生在没有这层的项目里。而它也就四十行——这也解释了 axios 为什么流行(5.1 节):它把这一层做成了带拦截器与实例配置的产品。

常见疑问解答

重试和幂等键必须一起上吗

重试不幂等的请求时必须。GET 与带幂等键的接口可以放心自动重试;裸 POST 的自动重试要谨慎——宁可失败让用户决定,也别替用户赌"应该没提交成功"。

超时后服务器还在处理吗

在。abort 只是客户端不再等待,服务端可能已开始执行写操作。所以超时后对写操作的自动重试要小心(上题的幂等问题在超时场景尤其尖锐——超时可能只是"慢"而不是"没成功")。

去重的键怎么定才不出错

规则:只对幂等或低风险的请求去重(GET 类),或对去重键加入"业务意图一致性"的要素。带用户动态输入的 POST 去重要谨慎——两次"提交订单"可能真的是用户想下两单,靠防重时间窗(3.2 节的按钮锁)比靠 URL 去重语义更正确。

本节要点回顾

  • 四件套是上线门槛:超时防挂起、取消防过期渲染、重试防偶发失败、去重防重复执行——每件都对应一类真实事故。
  • 超时按用户耐心定值(联想 3 秒、页面 8 到 15 秒),超时与取消虽同走 AbortError 但语义要翻译区分。
  • 取消是统一机制:用户操作、组件卸载、新代旧(竞态根治)、组级截止——一个控制器四种用法。
  • 重试两问:谁可试(网络错与 5xx,幂等是红线)、隔多久(指数退避加随机抖动防雪崩,次数与总时长封顶)。
  • 组装顺序:去重最外、取消穿透、分类最底——四十行的 api 函数,挡掉六类事故,也是读懂请求库的地图。

压力自测:给你的请求层做故障注入

四件套装完不算完,要验证它们真的会在该工作时工作。最有效的验证方法是故障注入——故意制造每种"不顺利",观察请求层的行为。一套可以在本地完成的注入清单与预期:

注入一,断网(开发者工具的离线模式):预期网络类错误触发、一次静默重试、重试期间界面有轻提示、最终失败给网络类文案。注入二,慢响应(限速加延迟):预期超时在设定时刻触发、超时错误与取消可区分、退避重试的间隔肉眼可见地变长。注入三,服务器五零二(用代理工具改写响应):预期退避重试若干次后放弃、文案区分于网络错误。注入四,快速连续触发同一操作:预期去重生效(面板里只看到一次请求)、界面无重复副作用。注入五,操作后立即离开页面:预期在途请求被批量取消、控制台无"组件卸载后更新"类警告、无未处理拒绝的噪音。注入六,双击提交按钮:预期按钮态锁住、仅一次请求。

每个注入项跑一遍并记录实际行为,就是一份请求层的"体检报告"。经验上,第一次跑这份清单的实现,六项里至少两项不符合预期——最常见的是重试期间没有界面反馈(用户视角就是"点了没反应")和取消后仍出现未处理拒绝(上报系统里躺着一堆假警报)。故障注入的价值就在此:不顺利的场景不会自然出现等你测试,你得亲自去敲门。这份清单也适合在请求层每次结构调整后回归——它测的不是代码逻辑,是四件套的组合仍然严丝合缝。

再补一层关于参数默认值的讨论,因为四件套装好后最常被问的就是"默认值怎么定"。建议的原则是保守默认、显式放宽:超时默认八秒而不是三十秒(宁可在少数慢接口上显式放宽,也别让全局挂起);重试默认只对幂等请求开启、次数三封顶;去重默认只对 GET 开启(写操作的去重要配合幂等键才安全);取消不设默认——由调用场景决定。每个默认值都写成配置常量并注释理由,半年后有人问"为什么是这个数"时,答案就在原地。默认值是请求层的"性格":保守的性格让它在陌生环境里先不出事,明确的出口让它在该激进时有路可走。最后用一个自检收束本节:合上教程,白纸画出你的请求层——四件套各挂在哪一层、默认值各是多少、故障注入的六项预期分别是什么。画得出来的部分是真正掌握的部分;画不出的地方,翻回对应段落再走一遍,因为这份图纸会在你读请求库源码、做技术评审、带新人答疑时,一次一次地被用到。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U