一、前言:为什么重复请求是前端面试必问?
作为有14年前端经验的老前端,我在面试中几乎每次都会被问到这个问题:用户多次点击按钮,导致重复发请求、重复创建数据,你怎么处理?
这个问题看似简单,实则是前端工程化能力的试金石:
- 初级开发者只会说「给按钮加个禁用」,完全忽略异常场景和全局管控;
- 中级开发者能写出防抖节流、局部禁用,但无法应对跨组件、网络延迟等极端场景;
- 高级开发者会从「前端拦截+后端幂等」的全链路视角,给出生产环境级的完整方案。
今天这篇文章,我会把这个问题拆解成3层递进方案,同时提供Vue3 + React 双版本完整可运行代码,帮你在面试中直接拉开差距,写出生产环境可用的解决方案。
二、方案总览:从基础到终极的三层防护
我们先建立一个完整的认知:重复请求的防护不是单一方案能解决的,而是一个分层递进的防护体系,每一层都有明确的适用场景和边界:
方案层级 核心思路 适用场景 优缺点 方案1:基础防护 按钮禁用+状态管理 单组件、常规点击场景 实现简单,用户体验直观;但无法应对跨组件、网络异常等场景 方案2:进阶优化 全局请求锁+请求拦截 多组件、多场景、跨组件请求 全局管控,避免代码冗余;但仍无法完全避免网络延迟导致的漏网之鱼 方案3:终极兜底 前端拦截+服务端幂等 生产环境、极端场景(网络延迟、控制台调用) 双重保障,彻底解决重复数据;需要前后端配合,实现成本稍高
接下来我们逐个拆解,给出完整代码实现。
三、方案1:基础防护——按钮禁用+状态管理(解决常规点击问题)
3.1 核心思路
这是最容易想到的方案,但90%的开发者都会漏关键细节:
- 点击按钮后,立即设置提交状态为 true ,禁用按钮并显示「提交中」提示;
- 请求成功/失败后,必须重置状态、恢复按钮,避免网络错误导致按钮永久禁用;
- 状态要和组件生命周期绑定,避免页面刷新后状态异常。
3.2 Vue3 + Composition API 完整实现
vue
.submit-container { padding: 20px; }
3.3 React 18 + Hooks 完整实现
jsx
import { useState, useEffect } from 'react'
import { Button, message } from 'antd'
import axios from 'axios'
const SubmitOrder = () => {
// 提交状态,控制按钮禁用与loading
const [isSubmitting, setIsSubmitting] = useState(false)
// 组件卸载时重置状态
useEffect(() => {
return () => {
setIsSubmitting(false)
}
}, [])
// 提交请求
const handleSubmit = async () => {
// 1. 先判断状态,避免重复进入
if (isSubmitting) return
try {
// 2. 立即设置提交状态,禁用按钮
setIsSubmitting(true)
// 3. 发起请求(模拟订单提交接口)
const res = await axios.post('/api/order/create', {
goodsId: 123,
count: 1
})
// 4. 请求成功,处理业务逻辑
console.log('订单创建成功', res.data)
message.success('订单提交成功')
} catch (error) {
// 5. 必须处理请求失败场景,否则按钮会一直禁用
console.error('订单提交失败', error)
message.error('订单提交失败,请重试')
} finally {
// 6. 无论成功失败,都要重置状态,恢复按钮
setIsSubmitting(false)
}
}
return (
<div style={{ padding: '20px' }}>
<Button
type="primary"
loading={isSubmitting}
disabled={isSubmitting}
onClick={handleSubmit}
>
{isSubmitting ? '提交中...' : '提交订单'}
</Button>
</div>
)
}
export default SubmitOrder
3.4 关键细节(面试加分项)
1. 必须处理请求失败场景:如果只在成功时重置状态,网络中断、接口报错会导致按钮永久禁用,严重影响用户体验; 2. 状态与生命周期绑定:组件卸载时重置状态,避免页面切换、刷新后状态异常; 3. 入口处做状态判断:在函数开头先判断 isSubmitting ,避免异步逻辑导致的重复进入; 4. 用户体验优化:配合 loading 状态,给用户明确的反馈,而不是单纯禁用按钮。
四、方案2:进阶优化——全局请求锁(解决多组件、多场景重复请求)
4.1 核心痛点
当项目中多个组件都有提交功能(比如订单提交、表单提交、评论提交),每个组件单独写禁用逻辑会导致大量冗余代码;同时,跨组件调用、控制台手动调用接口,也会绕过按钮禁用,导致重复请求。
此时我们需要全局请求锁,统一管控所有请求的状态,从根源上避免重复请求。
4.2 核心思路
- 创建全局工具类,用 Set 存储正在进行的请求唯一标识(比如 请求URL+请求参数组合 );
- 请求发起前,检查 Set 中是否已存在该请求,存在则拦截;
- 请求结束后(成功/失败),从 Set 中移除标识,释放锁;
- 结合Axios拦截器,实现全局自动管控,无需在每个组件中手动写逻辑。
4.3 Vue3 全局请求锁完整实现
4.3.1 全局请求锁工具类 requestLock.js
js
// src/utils/requestLock.js
class RequestLock {
constructor() {
// 存储正在进行的请求唯一标识
this.pendingRequests = new Set()
}
/**
* 生成请求唯一标识
* @param {Object} config - Axios请求配置
* @returns {string} 唯一标识
*/
generateKey(config) {
const { url, method, params, data } = config
// 组合URL、方法、参数,生成唯一标识
return [
method.toUpperCase(),
url,
JSON.stringify(params || {}),
JSON.stringify(data || {})
].join('&')
}
/**
* 添加请求锁
* @param {Object} config - Axios请求配置
* @returns {boolean} 是否成功加锁(true=可以发起请求,false=重复请求,拦截)
*/
addLock(config) {
const key = this.generateKey(config)
if (this.pendingRequests.has(key)) {
// 已存在该请求,拦截
return false
}
this.pendingRequests.add(key)
return true
}
/**
* 移除请求锁
* @param {Object} config - Axios请求配置
*/
removeLock(config) {
const key = this.generateKey(config)
this.pendingRequests.delete(key)
}
/**
* 清空所有锁(页面卸载、路由跳转时调用)
*/
clearAll() {
this.pendingRequests.clear()
}
}
// 导出单例,全局唯一
export default new RequestLock()
4.3.2 结合Axios拦截器,实现全局管控 request.js
// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import requestLock from './requestLock'
const service = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器:加锁
service.interceptors.request.use(
(config) => {
// 1. 对需要防重复的请求加锁(可根据业务配置白名单/黑名单)
if (config.method === 'post' && !config.headers['skip-lock']) {
const canRequest = requestLock.addLock(config)
if (!canRequest) {
// 重复请求,直接拦截
ElMessage.warning('请勿重复提交')
return Promise.reject(new Error('重复请求,已拦截'))
}
}
return config
},
(error) => {
// 请求错误时,移除锁
requestLock.removeLock(error.config)
return Promise.reject(error)
}
)
// 响应拦截器:解锁
service.interceptors.response.use(
(response) => {
// 请求成功,移除锁
requestLock.removeLock(response.config)
return response.data
},
(error) => {
// 请求失败,移除锁
if (error.config) {
requestLock.removeLock(error.config)
}
ElMessage.error(error.message || '请求失败')
return Promise.reject(error)
}
)
export default service
4.3.3 组件中使用(无需手动写禁用逻辑)
vue
<template>
<div class="submit-container">
<el-button type="primary" @click="handleSubmit">提交订单</el-button>
</div>
</template>
<script setup>
import request from '@/utils/request'
import { ElMessage } from 'element-plus'
const handleSubmit = async () => {
try {
const res = await request.post('/order/create', {
goodsId: 123,
count: 1
})
ElMessage.success('订单提交成功')
console.log(res)
} catch (error) {
console.error(error)
}
}
</script>
```
4.4 React 全局请求锁完整实现
4.4.1 全局请求锁工具类 requestLock.js
js
// src/utils/requestLock.js class RequestLock { constructor() { this.pendingRequests = new Set() }
generateKey(config) { const { url, method, params, data } = config return [ method.toUpperCase(), url, JSON.stringify(params || {}), JSON.stringify(data || {}) ].join('&') }
addLock(config) { const key = this.generateKey(config) if (this.pendingRequests.has(key)) { return false } this.pendingRequests.add(key) return true }
removeLock(config) { const key = this.generateKey(config) this.pendingRequests.delete(key) }
clearAll() { this.pendingRequests.clear() } }
export default new RequestLock() ```
4.4.2 结合Axios拦截器,实现全局管控 request.js
js
// src/utils/request.js
import axios from 'axios'
import { message } from 'antd'
import requestLock from './requestLock'
const service = axios.create({
baseURL: '/api',
timeout: 10000
})
service.interceptors.request.use(
(config) => {
if (config.method === 'post' && !config.headers['skip-lock']) {
const canRequest = requestLock.addLock(config)
if (!canRequest) {
message.warning('请勿重复提交')
return Promise.reject(new Error('重复请求,已拦截'))
}
}
return config
},
(error) => {
requestLock.removeLock(error.config)
return Promise.reject(error)
}
)
service.interceptors.response.use(
(response) => {
requestLock.removeLock(response.config)
return response.data
},
(error) => {
if (error.config) {
requestLock.removeLock(error.config)
}
message.error(error.message || '请求失败')
return Promise.reject(error)
}
)
export default service
```
4.4.3 组件中使用
jsx
import { Button, message } from 'antd' import request from '@/utils/request'
const SubmitOrder = () => { const handleSubmit = async () => { try { const res = await request.post('/order/create', { goodsId: 123, count: 1 }) message.success('订单提交成功') console.log(res) } catch (error) { console.error(error) } }
return ( <div style={{ padding: '20px' }}> 提交订单 ) }
export default SubmitOrder ```
4.5 关键细节(面试加分项)
1. 请求唯一标识的生成:必须包含 URL+方法+参数 ,避免不同参数的相同URL被误拦截; 2. 白名单/黑名单机制:可以通过 headers['skip-lock'] 跳过特定请求的锁,比如轮询、实时数据请求; 3. 路由跳转时清空锁:在Vue的 router.beforeEach 或React的路由守卫中,调用 requestLock.clearAll() ,避免页面切换后锁残留; 4. 单例模式:全局唯一的请求锁实例,确保所有请求都被统一管控; 5. 用户体验优化:拦截重复请求时,给出明确的提示,而不是静默拦截。
五、方案3:终极兜底——请求拦截+服务端幂等(解决极端场景)
5.1 核心痛点
前端防护再完善,也会出现「漏网之鱼」:
- 网络延迟导致请求重复发送(比如用户点击后,网络卡顿,前端锁已释放,但请求还在途中);
- 用户通过浏览器控制台手动调用接口,绕过所有前端防护;
- 前端代码出现bug,导致锁失效。
此时必须配合服务端做兜底,形成**「前端拦截+后端校验」的双重保障**,这才是生产环境的标准解决方案。
5.2 核心思路
前端层面:Axios拦截器+唯一请求标识
- 在请求拦截器中,为每个请求生成唯一标识(比如 requestId 、一次性 token ),放到请求头中;
- 服务端通过这个标识做幂等校验,确保同一个请求只处理一次。
服务端层面:接口幂等性实现
- 一次性token方案:用户进入页面时,服务端生成一个唯一token,前端携带token发起请求,服务端校验token是否存在,存在则处理请求并删除token,不存在则直接拒绝;
- 业务唯一标识方案:用业务唯一键(比如订单号、用户ID+商品ID+时间戳)作为幂等键,服务端通过数据库唯一索引或Redis去重,避免重复创建;
- 乐观锁/悲观锁:针对更新操作,通过版本号实现幂等。
5.3 Vue3 完整实现(前端部分)
5.3.1 生成唯一请求ID工具 uuid.js
js
// src/utils/uuid.js
/**
* 生成唯一请求ID
* @returns {string} 唯一ID
*/
export function generateRequestId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
5.3.2 结合Axios拦截器,添加请求头 request.js
js
// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import requestLock from './requestLock'
import { generateRequestId } from './uuid'
const service = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器:加锁+添加唯一请求ID
service.interceptors.request.use(
(config) => {
// 1. 防重复请求锁
if (config.method === 'post' && !config.headers['skip-lock']) {
const canRequest = requestLock.addLock(config)
if (!canRequest) {
ElMessage.warning('请勿重复提交')
return Promise.reject(new Error('重复请求,已拦截'))
}
}
// 2. 为每个请求生成唯一ID,放到请求头(服务端用于幂等校验)
config.headers['X-Request-Id'] = generateRequestId()
return config
},
(error) => {
requestLock.removeLock(error.config)
return Promise.reject(error)
}
)
// 响应拦截器:解锁
service.interceptors.response.use(
(response) => {
requestLock.removeLock(response.config)
return response.data
},
(error) => {
if (error.config) {
requestLock.removeLock(error.config)
}
// 处理服务端幂等校验失败的情况
if (error.response?.status === 409) {
ElMessage.error('请勿重复提交订单')
} else {
ElMessage.error(error.message || '请求失败')
}
return Promise.reject(error)
}
)
export default service
5.4 React 完整实现(前端部分)
5.4.1 生成唯一请求ID工具 uuid.js
js
// src/utils/uuid.js
export function generateRequestId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
5.4.2 结合Axios拦截器,添加请求头 request.js
js
// src/utils/request.js
import axios from 'axios'
import { message } from 'antd'
import requestLock from './requestLock'
import { generateRequestId } from './uuid'
const service = axios.create({
baseURL: '/api',
timeout: 10000
})
service.interceptors.request.use(
(config) => {
if (config.method === 'post' && !config.headers['skip-lock']) {
const canRequest = requestLock.addLock(config)
if (!canRequest) {
message.warning('请勿重复提交')
return Promise.reject(new Error('重复请求,已拦截'))
}
}
config.headers['X-Request-Id'] = generateRequestId()
return config
},
(error) => {
requestLock.removeLock(error.config)
return Promise.reject(error)
}
)
service.interceptors.response.use(
(response) => {
requestLock.removeLock(response.config)
return response.data
},
(error) => {
if (error.config) {
requestLock.removeLock(error.config)
}
if (error.response?.status === 409) {
message.error('请勿重复提交订单')
} else {
message.error(error.message || '请求失败')
}
return Promise.reject(error)
}
)
export default service
5.5 服务端幂等性实现(伪代码,Node.js 示例)
js
// 服务端幂等校验中间件(Node.js + Redis 示例)
const redis = require('ioredis')
const redisClient = new Redis()
const idempotentMiddleware = async (ctx, next) => {
// 从请求头获取唯一请求ID
const requestId = ctx.headers['x-request-id']
if (!requestId) {
ctx.status = 400
ctx.body = { code: 400, msg: '请求ID不能为空' }
return
}
// 校验Redis中是否存在该请求ID
const isExists = await redisClient.get(`idempotent:${requestId}`)
if (isExists) {
// 已存在,说明是重复请求,直接拒绝
ctx.status = 409
ctx.body = { code: 409, msg: '请勿重复提交' }
return
}
// 不存在,设置请求ID,过期时间5分钟(根据业务调整)
await redisClient.set(`idempotent:${requestId}`, '1', 'EX', 300)
// 执行后续业务逻辑
await next()
}
// 订单创建接口,使用幂等中间件
router.post('/api/order/create', idempotentMiddleware, async (ctx) => {
// 处理订单创建逻辑
const { goodsId, count } = ctx.request.body
// ... 业务逻辑
ctx.body = { code: 200, msg: '订单创建成功', data: { orderId: '123456' } }
})
5.6 关键细节(面试加分项)
1. 前后端协同是核心:前端拦截是「防君子」,后端幂等是「防小人」,两者缺一不可; 2. 幂等键的选择:- 一次性 token 适合一次性操作(比如订单提交、表单提交);
- 业务唯一键适合有明确业务标识的操作(比如用户充值、订单支付); 3. 过期时间设置:Redis中幂等键的过期时间要大于接口最大超时时间,避免误删; 4. 异常处理:服务端处理请求失败时,要删除幂等键,允许用户重试; 5. 防抖节流的适用场景:防抖节流可以作为辅助方案,但不适合订单提交等实时性要求高的场景,容易出现请求延迟,需根据业务选择。
六、补充:防抖节流的正确使用场景
很多开发者会把防抖节流作为解决重复请求的方案,但这里必须明确:防抖节流不是万能的,有严格的适用场景。
6.1 防抖(Debounce)
- 核心逻辑:事件触发后,延迟n秒执行,如果n秒内再次触发,则重新计时;
- 适用场景:搜索框输入联想、窗口resize、滚动事件等;
- 不适用场景:订单提交、表单提交等需要立即响应的操作,会导致用户点击后延迟执行,体验差。
6.2 节流(Throttle)
- 核心逻辑:规定时间内,只执行一次;
- 适用场景:按钮频繁点击、滚动加载、拖拽事件等;
- 不适用场景:网络延迟高的场景,节流无法避免重复请求,只能限制请求频率。
6.3 Vue3 防抖按钮示例
vue
<template>
<el-button @click="handleDebounceClick">搜索</el-button>
</template>
<script setup>
import { ref } from 'vue'
import { debounce } from 'lodash-es'
import axios from 'axios'
// 防抖处理,300ms内只执行一次
const handleDebounceClick = debounce(async () => {
const res = await axios.get('/api/search', { params: { keyword: 'vue' } })
console.log(res.data)
}, 300)
</script>
6.4 React 防抖按钮示例
jsx
import { Button } from 'antd'
import { debounce } from 'lodash-es'
import axios from 'axios'
const SearchBtn = () => {
const handleDebounceClick = debounce(async () => {
const res = await axios.get('/api/search', { params: { keyword: 'react' } })
console.log(res.data)
}, 300)
return <Button onClick={handleDebounceClick}>搜索</Button>
}
export default SearchBtn
七、面试回答框架
当面试官问你「如何解决用户重复点击、重复请求」时,不要只说方案,要按照**「分层递进+全链路保障」**的逻辑回答,直接拉开差距:
7.1 回答框架
1. 第一层:基础防护(单组件场景)- 核心:按钮禁用+状态管理,点击后立即禁用按钮,请求结束后重置状态;
- 关键细节:必须处理请求失败场景,状态与生命周期绑定,避免按钮永久禁用。 2. 第二层:进阶优化(全局场景)- 核心:全局请求锁+Axios拦截器,用Set存储请求唯一标识,统一管控所有请求;
- 优势:避免代码冗余,解决跨组件、控制台调用等场景的重复请求。 3. 第三层:终极兜底(生产环境)- 核心:前端拦截+服务端幂等,前端生成唯一请求标识,服务端通过Redis/数据库做幂等校验;
- 优势:彻底解决网络延迟、前端bug等极端场景,是生产环境的标准方案。 4. 补充:防抖节流的适用场景- 明确说明防抖节流的适用场景和局限性,避免滥用。
7.2 加分项
- 对比不同方案的优缺点,说明选择依据;
- 结合实际项目经验,分享踩过的坑(比如按钮永久禁用、网络延迟导致的重复请求);
- 提到前后端协同的重要性,体现全链路思维。
八、总结
解决用户重复点击、重复请求的问题,本质是前端工程化能力的体现:
- 初级开发者只关注「能不能用」,用按钮禁用解决表面问题;
- 中级开发者关注「好不好用」,用全局请求锁优化代码结构;
- 高级开发者关注「稳不稳定」,用前后端协同的全链路方案,保障生产环境的稳定性。
本文提供的Vue3 + React 双版本完整代码,可以直接用到项目中,同时也覆盖了面试中所有的加分点。希望这篇文章能帮你在面试中脱颖而出,写出生产环境可用的高质量代码。