面试官问烂了:Vue/React 如何优雅解决「用户重复点击、重复请求」问题?

4 阅读14分钟

一、前言:为什么重复请求是前端面试必问?

作为有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 双版本完整代码,可以直接用到项目中,同时也覆盖了面试中所有的加分点。希望这篇文章能帮你在面试中脱颖而出,写出生产环境可用的高质量代码。