资讯详情

Vue请求防抖与重复提交防护实战:从Axios拦截器到后端幂等

📅 2026/10/3 10:07:08 | 华诺云谱 👁 阅读
Vue请求防抖与重复提交防护实战:从Axios拦截器到后端幂等
第一篇文章1. 重复请求从哪里来先给问题画个像做Vue项目的网络请求层大部分新手甚至不少工作两三年的前端都会在请求防抖与重复提交防护这件事上栽跟头。最常见的一幕是页面里放了一个保存按钮用户手一抖点了两下或者接口响应有点慢用户以为没点上又补了一刀结果后端收到两个一模一样的请求——轻则生成两条重复订单重则库存被扣两次、数据库里出现脏数据。等线上出了事故回头查才发现前端没有做任何防护。这个问题的本质是用户的意图和接口的幂等性之间存在空隙。用户点击一次按钮意图只发生一次但网络层的请求可能被触发多次。防抖、节流、请求去重、按钮loading禁用这些手段的最终目的都是把用户可见的操作和实际发出的请求之间的一一对应关系重新建立起来。我最早处理这类问题是在一个后台管理项目里列表页有一个批量审核功能后端接口要跑3到5秒前端没有任何防护。结果运营同学习惯性双击批量审核接口被触发两次一套数据被审核了两次状态直接错乱。从那之后我对所有写操作接口都默认加一层防护。本文就把我在Vue项目里做请求防抖和重复提交防护的完整思路、代码实现和踩坑记录整理出来给遇到同样问题的朋友一个可以直接抄作业的参考。在动手写代码之前先分清三个概念防抖、节流、请求去重。很多人把它们混着用但其实边界很清楚防抖debounce在连续触发的事件里只让最后一次触发生效。典型场景是搜索框联想用户连续输入只要最后一次。节流throttle在一段时间内只让第一次触发生效后续触发被忽略。典型场景是列表滚动加载。请求去重deduplication在请求层面对相同参数的并发请求只保留一个后续请求直接复用第一个的结果或者直接取消后续请求。防抖和节流解决的是触发频率问题去重解决的是请求并发冲突问题。一个完整的防护体系应该是两者配合使用防抖控制触发源头去重阻断重复请求后端再做幂等兜底。前端永远不可能做到100%防止重复提交但做好这两层绝大多数场景都够了。2. 第一道防线请求防抖在Vue中的落地方案2.1 手写防抖函数还是直接用库防抖函数的实现很简单核心就是setTimeout加闭包function debounce(fn, delay 300) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; }这个版本够用但还是太裸了。在Vue项目里我习惯直接封装成Hooks把组件卸载时的清理也带进去避免定时器泄漏。下面这个useDebounce是我项目里的常用版本import { useRef, useEffect, useCallback } from vue; export function useDebounce(fn, delay 300) { const timer useRef(null); const fnRef useRef(fn); useEffect(() { fnRef.current fn; }, [fn]); useEffect(() { return () { if (timer.current) { clearTimeout(timer.current); timer.current null; } }; }, []); const debouncedFn useCallback((...args) { if (timer.current) clearTimeout(timer.current); return new Promise((resolve, reject) { timer.current setTimeout(() { try { const result fnRef.current(...args); resolve(result); } catch (error) { reject(error); } }, delay); }); }, [delay]); return debouncedFn; }注意这里比普通防抖多了一个设计返回的是Promise。为什么因为很多场景下调用方需要在防抖等待结束后拿到接口返回的数据。如果防抖函数不返回Promise调用方就没法用async/await等结果。这一点是很多教程里不会写的细节但在实际业务里非常关键。2.2 搜索框场景既要防抖又要处理过期响应搜索框是最典型的防抖应用场景。但光做防抖还不够还要考虑响应乱序问题。用户在搜索框里输入vue停顿后输入vue router如果第一个请求响应慢第二个请求响应快最终页面上展示的可能是vue的结果而搜索框里显示的却是vue router。解决方案有两种一种是给请求加序列号只接受最新一次请求的响应另一种是切换查询条件时取消上一个请求。序列号方案简单可靠不依赖Axios的取消机制任何请求封装都能用import { ref, watch } from vue; export function useSearch(fetcher) { const keyword ref(); const results ref([]); const loading ref(false); let requestId 0; // 一次只认最新请求 const search useDebounce(async () { const currentId requestId; loading.value true; try { const data await fetcher(keyword.value); if (currentId requestId) { results.value data; } } finally { if (currentId requestId) { loading.value false; } } }, 300); watch(keyword, search); return { keyword, results, loading }; }// 使用示例 const { keyword, results, loading } useSearch((kw) { return request.get(/api/search, { params: { keyword: kw } }); });这个方案的关键在于requestId每次发起新请求前自增响应回来后只有currentId requestId才允许更新界面。这样即使旧的请求晚于新的请求返回也会被忽略界面不会跳数据。2.3 防抖治标不治本并发竞态问题依然存在接上面的例子继续往深里想搜索请求虽然是防抖后的但如果用户快速切换Tab或者列表页同时发出多个筛选条件的请求防抖并不能阻止已经发出的请求之间的竞态。防抖只是把触发次数降下来了两个request还是可能几乎同时发出去、先后返回。竞态问题在前端请求层是老大难。最常用的两种应对思路忽略过期响应用requestId或类似标记只渲染最新请求的结果上面的代码就是这个思路。取消过期请求新请求发出时直接取消上一个未完成的请求从根源上杜绝多余请求占用网络带宽。从用户体验和服务器压力的角度方案2更优。它需要依赖Axios的取消能力这也正好引出下一层的防护手段请求去重。3. 第二道防线基于Axios的请求阻断与去重3.1 Axios取消请求的底层原理Axios在浏览器端支持两种取消方式一种是老式的CancelToken一种是目前推荐的AbortController。底层原理都是利用浏览器的AbortControllerAPI来终止DOM请求。// AbortController 方式 const controller new AbortController(); request.get(/api/data, { signal: controller.signal, }); // 取消请求 controller.abort();放在Vue组件里搭配onUnmounted钩子可以实现在组件卸载时自动取消未完成的请求import { onUnmounted } from vue; export function useCancellableRequest(url) { const controller new AbortController(); const fetchData async () { const response await request.get(url, { signal: controller.signal, }); return response.data; }; onUnmounted(() { controller.abort(); }); return { fetchData }; }这个做法的意义在于SPA里组件频繁切换如果上一个页面的组件都销毁了请求还在飞等响应回来了却找不到组件实例去更新状态既浪费资源又容易报组件已卸载的警告。3.2 全局拦截器里的请求去重方案在实际项目中与其在业务代码里一个一个手动取消不如在请求层统一做去重。思路是维护一个pendingMapkey是请求的唯一标识可以拼接method url 参数value是对应的AbortController。新请求发出前先检查map里有没有相同key的请求有就把旧的取消掉再把新的存进去请求完成后删除key。// request.js import axios from axios; import qs from qs; const service axios.create({ timeout: 15000 }); const pendingMap new Map(); function getRequestKey(config) { const { method, url, params, data } config; return [method, url, qs.stringify(params), qs.stringify(data)].join(); } function addPending(config) { const requestKey getRequestKey(config); config.cancelToken new axios.CancelToken((cancel) { if (!pendingMap.has(requestKey)) { pendingMap.set(requestKey, cancel); } }); } function removePending(config) { const requestKey getRequestKey(config); if (pendingMap.has(requestKey)) { const cancel pendingMap.get(requestKey); cancel(requestKey); pendingMap.delete(requestKey); } } service.interceptors.request.use((config) { // 先取消之前未完成的相同请求再标记当前请求 removePending(config); addPending(config); return config; }); service.interceptors.response.use( (response) { removePending(response.config); return response.data; }, (error) { if (axios.isCancel(error)) { console.log(请求已取消, error.message); } else { removePending(error.config); } return Promise.reject(error); } );这段代码有几个设计细节值得展开说说。第一为什么要再加一层addPending因为请求并不是注册了拦截器就一定会发出去中间还有可能在请求拦截器里就被别的逻辑拦住了。addPending确保只有真正走完请求拦截流程的请求才会被记录。第二取消时机是先取消再添加还是只添加不取消我见过一些封装是只在发出前判断如果有相同请求就取消并不会取消掉之前的。这两种策略各有用场取消旧请求适合场景是刷新按钮、搜索筛选、Tab切换这些场景下旧请求的结果已经没有意义。保留旧请求 拒绝新请求适合场景是批量操作按钮连点旧请求应该继续执行新请求直接不发出。所以严格来说去重策略有两种模式不能一把梭。下面的代码会演进出一个支持两种模式的版本。3.3 去重策略的取舍Cancel旧请求还是Ignore新请求把两种模式都写出来大家按场景取用。我封装过一个pending模块支持配置deduplicationModeconst pendingMap new Map(); let DEDUPLICATION_MODE cancel-old; // cancel-old | ignore-new function addPending(config) { const requestKey getRequestKey(config); if (pendingMap.has(requestKey)) { if (DEDUPLICATION_MODE ignore-new) { // 模式一直接标记当前请求为取消但不影响旧请求 config.cancelToken new axios.CancelToken((cancel) { cancel(重复请求拦截: ${requestKey}); }); return requestKey; } // 模式二取消旧请求 pendingMap.get(requestKey)(); pendingMap.delete(requestKey); } config.cancelToken new axios.CancelToken((cancel) { pendingMap.set(requestKey, cancel); }); return requestKey; }实际业务里我的经验是页面初始化时的多个并列请求用cancel-old切页重进时以最新一次为准。保存、提交类操作用ignore-new第一次请求没回来前后续的点击全部直接拦截不发出。列表查询类用cancel-old因为用户在快速切换筛选条件时旧请求已经没必要继续跑了。3.4 取消请求后的异常处理坑取消请求在Axios里会走到reject分支如果全局拦截器不做判断业务代码里的catch会把取消当成接口报错来处理轻则弹个错误提示重则还会触发统一的错误上报。前端控制台甚至会频繁出现Uncaught (in promise) Cancel这种红色警告。处理方式是在全局拦截器的错误分支里判断一下service.interceptors.response.use( (response) { removePending(response.config); return response.data; }, (error) { // 判断是不是手动取消 if (axios.isCancel(error)) { return Promise.reject({ __CANCELED__: true, message: error.message }); } removePending(error.config); return Promise.reject(error); } );业务代码里再统一判断async function handleSubmit() { try { await submitApi(data); } catch (error) { if (error?.__CANCELED__) return; // 取消请求静默处理 // 真正的错误处理逻辑 } }这个判断分支建议加到自己的请求封装里因为很多场景下请求被取消不是真正的异常没必要让业务代码感知到。4. 完整落地方案表单提交、列表切换、搜索联动的代码细节光有拦截器和防抖还不够实际业务里的完整防护链路需要把这些东西组合起来。下面按三个高频场景给出我在项目里实际使用的组合方案。4.1 表单提交按钮loading 幂等标记双保险表单提交是我最强调要加双层防护的场景。因为用户连点按钮时即使有loading禁用也存在loading状态还没切换过来第二次点击就已经发出去了的微小时间窗。在事件循环里按钮的disabled属性和click事件的触发时机是有先后差异的不能完全依赖loading来防重。推荐做法是loading禁用管住肉眼可見的重复点击请求层的幂等标记管住用户切换到别的窗口再切回来状态已经乱了的情况。组件里这样写template el-button typeprimary :loadingsubmitting :disabledsubmitting clickhandleSubmit 提交 /el-button /template script setup import { ref } from vue; import request from /utils/request; const submitting ref(false); async function handleSubmit() { if (submitting.value) return; // 竞态防护的第一道关卡 submitting.value true; try { await request.post(/api/order/create, formData); // 提交成功后的逻辑 } finally { // 无论成功失败都要恢复状态 submitting.value false; } } /script有人会问既然请求层已经有ignore-new模式的去重了为什么还要在组件里用submitting标记因为这是两层防护目的不同组件里的submitting是为了立刻在UI层给出反馈避免用户看到按钮一直可用却点了没反应。请求层的去重是为了防止多个组件、多个入口同时对一个接口发起相同请求比如页面里好几个地方都能触发创建订单。所以这两层不是重复的而是互补的。只用UI层禁止一旦用户绕过UI比如按回车键、脚本调用接口防护就失效了只用请求层去重用户在UI上的反馈会很奇怪——点了按钮好像没反应。还要注意finally里必须恢复submitting不要在try分支里恢复。如果接口中途超时或者被取消finally保证状态不会卡死。4.2 列表页的Tab切换、筛选、分页用请求竞态处理列表页的重复提交问题往往不是提交本身而是请求竞态。用户快速切换Tab每个Tab对应一个请求后发出去的请求可能先回来最后页面上展示的是错误的Tab数据。最佳实践是切换Tab时取消旧请求 响应回来后判断是否过期。在实际项目中我推荐第一种因为后请求晚到的场景反而少更常见的是旧请求晚到覆盖新请求。封装一个独立的useListQuery组合式函数import { ref, onUnmounted } from vue; export function useListQuery(fetcher) { const list ref([]); const loading ref(false); let controller null; async function query(params) { // 取消上一个未完成的请求 if (controller) { controller.abort(); } controller new AbortController(); loading.value true; try { const result await fetcher(params, controller.signal); list.value result; } catch (error) { if (error.name AbortError) { // 被取消直接忽略 return; } // 其他错误处理 } finally { loading.value false; } } onUnmounted(() { if (controller) controller.abort(); }); return { list, loading, query }; }// 使用示例 const { list, loading, query } useListQuery((params, signal) { return request.get(/api/goods, { params, signal }); }); function onTabChange(tab) { query({ type: tab.value, page: 1 }); }细节注意点controller.abort()之后旧请求的回调仍会进入fulfilled/rejected流程所以catch里必须判断AbortError否则会被误伤。finally里loading.value false;会让新请求的loading被旧请求的取消逻辑影响。实际的顺序是新请求发出后旧请求的abort击发了catch如果catch里没有return会让loading先变成false。所以我上面的代码把loading的reset放在catch的return之后这样旧请求根本不会走到finally。这一点其实很微妙abort了旧的新的loading只是被置为true并没有被修改所以没问题。4.3 搜索场景的完整链路防抖 过期响应忽略搜索框和列表Tab的区别在于搜索输入天然高频Tab切换天然低频。所以搜索必须要防抖Tab切换只需要取消旧请求。一个比较完整的搜索组件封装组合了防抖和过期响应忽略再加一层取消旧请求import { ref, watch, onUnmounted } from vue; export function useSearchList(fetcher) { const keyword ref(); const list ref([]); const loading ref(false); let controller null; let requestId 0; let timer null; const search async () { const currentId requestId; // 防抖清掉上一次的定时器 if (timer) clearTimeout(timer); // 取消上一次未完成的请求 if (controller) controller.abort(); timer setTimeout(async () { controller new AbortController(); loading.value true; try { const data await fetcher(keyword.value, controller.signal); if (currentId requestId) { list.value data; } } catch (error) { if (error.name AbortError) return; // 错误处理 } finally { if (currentId requestId) { loading.value false; } } }, 300); }; watch(keyword, search); onUnmounted(() { if (timer) clearTimeout(timer); if (controller) controller.abort(); }); return { keyword, list, loading, search }; }这样一套下来即使搜索结果延迟返回也不会出现输入的是A显示的是B的经典问题。5. 兜底与进阶后端幂等与排查经验5.1 为什么前端防护永远做不到100%说句实在话前端能防住的都只是同一客户端、同一页面、同一用户层面的重复。对于分布式系统来说一次请求在网络上超时了客户端重试但服务端其实已经处理成功了这种请求重发前端的防抖和去重完全管不了。唯一的解药是后端做幂等。前端操作也好后端处理也好整个链路里总有那么几个环节会出现双重提交网页按钮连点、手机端双击、微信小程序里用户快速触发两次支付……这些场景前端都能防护大部分但总有漏网之鱼。在做实际项目时我的原则是前端做体验后端做正确性。前端把明显的重复请求拦下来让用户的操作反馈顺畅后端用幂等键、唯一索引、状态机进行兜底保证即使有漏网请求进来也不会造成数据错误。5.2 后端幂等设计的一个核心思路幂等键后端接口如果做幂等最简单的方案是要求客户端在请求头里带一个Idempotency-Key这个key要保证每次用户意图唯一。前端怎么生成这个key我一般在业务层生成表单提交时拿当前用户ID 当前时间戳 随机字符串拼一个存到请求头里。同一个表单一旦提交过后续的重复请求会带上完全相同的key后端就能认出来直接返回第一次的结果。// 生成幂等键 function generateIdempotencyKey() { return ${Date.now()}-${Math.random().toString(36).slice(2)}; } // 表单提交时 async function handleSubmit() { const idempotencyKey generateIdempotencyKey(); await request.post(/api/order/create, formData, { headers: { Idempotency-Key: idempotencyKey }, }); }后端在Redis里以这个key为键存一份处理结果重复请求过来时直接查出来返回。前端配合请求层去重基本能把重复提交造成的脏数据风险降到接近零。5.3 线上排查重复请求的一条实用方法如果线上还是出现了重复数据排查思路可以按这个样子来先确认是不是前端口子打开Network面板看有没有重复的请求记录。如果有两个一模一样的请求说明前端防护没生效去查是没做防抖还是没做去重。再确认是不是网络重试看重复请求之间隔了多久。如果间隔很短毫秒级多半是前端连点如果间隔几秒甚至几十秒可能是网络超时后的客户端自动重试或者是服务端Agent的重试。最后看后端日志请求进来时有没有打到业务日志如果前端只发出一个请求后端却处理了两次那就是后端没有做幂等只能后端补。我排查过的一个典型案例前端在request拦截器里做了去重但用的是cancel-old模式结果用户在一个页面上连续触发两次提交操作第二次请求直接把第一次请求取消了。第一次请求在服务端其实还没处理完但客户端把它取消了导致后端继续处理了一次前端却不知道。这种隐藏问题用ignore-new模式就能避免。所以再次强调写操作必须用ignore-new读操作才用cancel-old。6. 经验汇总与常见问题速查表写到这里把最核心的经验用表格形式整理出来方便以后回顾场景前端方案关键细节搜索框输入防抖300~500ms 过期响应忽略防抖用Promise返回requestId判断最新请求表单提交按钮按钮loading禁用 ignore-new去重loading管UI反馈去重管实际请求缺一不可Tab切换/列表筛选取消旧请求AbortControllercatch里判断AbortError避免误伤组件卸载前的请求onUnmounted钩子里取消避免响应回来更新已销毁组件任意写操作接口ignore-new模式 幂等键前端防一层后端救底任意读操作接口cancel-old模式保证页面展示的是最新查询条件的结果6.1 常见问题速查Q1为什么按钮用了loading还是发出了重复请求答loading的disabled状态切换有微小延迟。在极高频连点下第一次点击触发的update还没渲染到DOM上第二次点击的事件已经带着按钮可用的旧状态发出去了。所以不能只依赖loading组件里要显式判断if (submitting.value) return。Q2请求去重与防抖优先级怎么排答建议先防抖再去重。防抖是源头降频去重是过程阻断。如果只做去重不做防抖用户快速输入搜索关键词时每个关键词都会发一个请求去重只能阻止完全相同请求的重复防不了不同关键词各自请求的浪费。Q3什么情况下应该用节流而不是防抖答滚动加载、拖拽缩放这类持续高频的事件防抖会导致操作停不下来——用户一直滚定时器永远被清掉请求永远发不出去。这种场景必须用节流保证固定时间间隔内至少发出一次。Q4请求取消后全局拦截器的错误处理会误弹错误提示吗答会。我在3.4节里专门写了判断axios.isCancel的代码。不判断的话取消的请求会走正常错误分支轻则控制台报警重则页面弹出无意义的错误消息。Q5Vue 3的ref和useDebounce组合使用时注意什么答useDebounce内部拿到的fn要避免闭包捕获过期值。要使用fnRef.current的方式每次渲染更新ref指向最新的函数。否则防抖函数里拿到的总是旧闭包query数据会错乱。6.2 再分享一个容易被忽略的细节请求Key的序列化请求去重的Key如果拼接不当会出现明明参数不同却判定为相同请求或明明相同请求却判定为不同的问题。常见原因是对象参数的属性顺序不同。比如{ a: 1, b: 2 }和{ b: 2, a: 1 }如果直接用JSON.stringify两个字符串确实不同会被当成两个请求去重就失败了如果用了qs库按字典序排序则能正确识别为相同请求。我的getRequestKey里用的是qs.stringify它默认会对嵌套对象做编码并排序展平。这是我在项目里验证过的稳定方案推荐大家直接抄。function getRequestKey(config) { return [ config.method, config.url, qs.stringify(config.params, { arrayFormat: repeat }), qs.stringify(config.data, { arrayFormat: repeat }), ].join(); }arrayFormat: repeat是为了让数组参数正确序列化比如ids: [1, 2, 3]在不同配置下会变成ids1ids2ids3避免默认的ids[]1ids[]2这类带方括号的字符串影响Key判定。6.3 最后几点真心话做了几年Vue项目我见过太多重复提交引发的线上事故。有的加个按钮loading就草草了事有的洋洋洒洒写了一整套工具库却忘了处理取消请求的副作用。想清楚层次再动手是最重要的。我自己的防护清单永远是这个顺序业务入口防抖 → UI状态禁用 → 请求层去重 → 后端幂等兜底。每一层都只解决一个确定的问题不越俎代庖也不互相替代。前端工程师能做的就是把前三层做扎实最后一层用文档和约定推动后端同事实现。如果你现在正被某个重复请求问题折磨先把submitting这台第一道硬栅栏加好再按本文的思路在请求层做去重。这两步做完绝大多数场景就不会再出错了。剩下的就是遇到具体问题再回来对照我上面整理的知识点逐层排查即可。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑