动态

详情 返回 返回

前端如何徹底解決重複請求問題?看看這5種方案 - 动态 详情

在前端開發中,重複請求是一個常見且棘手的問題。比如用户快速點擊"保存"按鈕導致生成多條重複單據,或者列表頁頻繁刷新造成服務器壓力飆升,這些場景不僅影響用户體驗,還可能引發數據一致性問題。本文將系統梳理重複請求的解決方案,從基礎到進階進行對比分析,並結合實際代碼案例解決這一痛點。

一、重複請求不止是"多花錢"

在討論解決方案前,我們先明確重複請求的具體影響,避免因"覺得問題不大"而忽視它:

  • 數據一致性風險:如表單重複提交導致生成多個相同訂單、重複創建用户,後續需要額外成本修復數據
  • 服務器資源浪費:相同請求反覆發送,佔用帶寬和服務器算力,極端情況下可能引發服務過載
  • 前端體驗降級:重複請求可能導致頁面多次渲染閃爍,或觸發多次錯誤提示
  • 網絡資源消耗:尤其在移動端,重複請求會浪費用户流量,增加加載時間

瞭解危害後,我們來看當前主流的解決方案,及其適用場景和優缺點。

二、5種重複請求解決方案對比

方案1:UI層面控制(最簡單但不徹底)

這是最基礎的解決方案,通過控制UI交互阻止重複觸發請求,核心思路是"讓用户無法重複點擊"。

實現方式

  • 按鈕點擊後立即禁用,直到請求完成(成功/失敗)後重新啓用
  • 列表刷新時顯示加載狀態,禁止再次觸發刷新操作
  • 路由切換時取消當前頁面未完成的請求

代碼示例(React)

const SaveButton = () => {  
 const [loading, setLoading] = useState(false);  
  
 const handleSave = async () => {  
 if (loading) return; // 防止重複觸發  
 setLoading(true);  
 try {  
 await api.submitForm(data);  
 message.success("保存成功");  
 } catch (error) {  
 message.error("保存失敗");  
 } finally {  
 setLoading(false); // 請求完成後恢復按鈕狀態  
 }  
 };  
  
 return <Button loading={loading} onClick={handleSave}>保存</Button>;  
};  

優缺點分析

實現簡單,無額外依賴
對現有代碼侵入性低
即時反饋,提升用户體驗

無法覆蓋所有場景(如代碼層面直接調用接口)
多個組件調用同一接口時,無法共享狀態
無法處理網絡延遲導致的"隱性重複請求"

適用場景

  • 簡單表單提交、單按鈕交互場景
  • 快速迭代的小型項目,無複雜接口調用邏輯

方案2:請求攔截器+緩存(適合讀操作)

對於查詢類接口(如列表查詢、詳情獲取),可通過"請求攔截器+緩存"實現重複請求攔截,核心思路是"相同請求只發一次,結果緩存複用"。

實現原理

  1. 定義緩存容器(如Map),存儲已發送但未完成的請求Promise
  2. 發起請求前,生成請求唯一標識(如URL+參數+方法的哈希值)
  3. 若緩存中存在該請求的Promise,直接返回緩存的Promise;若不存在,發送請求並將Promise存入緩存
  4. 請求完成(成功/失敗)後,清除緩存,確保下次請求可正常發起

代碼示例(Axios攔截器)

import axios from 'axios';  
import { sha256 } from 'js-sha256';  
  
// 緩存容器:key=請求唯一標識,value=請求Promise  
const requestCache = new Map();  
  
// 創建Axios實例  
const service = axios.create({  
 baseURL: import.meta.env.VITE_API_BASE_URL,  
 timeout: 5000  
});  
  
// 請求攔截器  
service.interceptors.request.use(  
 (config) => {  
 // 1. 生成請求唯一標識(URL+方法+參數)  
 const requestKey = generateRequestKey(config);  
   
 // 2. 檢查緩存:若存在未完成的請求,直接返回緩存的Promise  
 if (requestCache.has(requestKey)) {  
 return requestCache.get(requestKey);  
 }  
   
 // 3. 若不存在緩存,發送請求並緩存Promise  
 const requestPromise = Promise.resolve(config);  
 requestCache.set(requestKey, requestPromise);  
 return requestPromise;  
 },  
 (error) => Promise.reject(error)  
);  
  
// 響應攔截器  
service.interceptors.response.use(  
 (response) => {  
 // 請求完成,清除緩存  
 const requestKey = generateRequestKey(response.config);  
 requestCache.delete(requestKey);  
 return response.data;  
 },  
 (error) => {  
 // 請求失敗,同樣清除緩存(避免緩存失敗狀態)  
 if (error.config) {  
 const requestKey = generateRequestKey(error.config);  
 requestCache.delete(requestKey);  
 }  
 return Promise.reject(error);  
 }  
);  
  
// 生成請求唯一標識:基於URL、方法、params、data的哈希值  
function generateRequestKey(config) {  
 const { url, method, params, data } = config;  
 const requestStr = JSON.stringify({ url, method, params, data });  
 // 使用sha256生成哈希值,確保唯一性  
 return sha256(requestStr);  
}  
  
export default service;  

優缺點分析

優點:
對業務代碼無侵入,全局生效
減少重複請求,減輕服務器壓力
支持多組件共享請求結果

缺點:
不適合寫操作(如新增/修改/刪除),可能導致數據更新不及時
緩存有效期難控制,需手動處理過期邏輯
無法處理請求取消場景

適用場景

  • 讀操作接口(如列表查詢、詳情獲取、下拉選單數據加載)
  • 無實時數據要求的場景,允許短期緩存

方案3:請求取消+狀態管理(適合寫操作)

對於寫操作接口(如新增、修改、刪除),不能使用緩存(需確保每次請求都能觸達服務器),此時需通過"請求取消+狀態管理"實現重複攔截,核心思路是"相同寫請求同時只能存在一個,重複請求直接取消"。

實現原理

  1. 維護一個請求狀態容器,存儲當前未完成的寫請求標識及對應的取消函數
  2. 發起寫請求前,生成請求唯一標識,檢查容器:若存在相同請求,調用取消函數取消新請求
  3. 若不存在相同請求,創建AbortController(或CancelToken),將取消函數和請求標識存入容器
  4. 請求完成(成功/失敗)或取消後,從容器中移除該請求標識

代碼示例(結合AbortController)

import axios from 'axios';  
import { sha256 } from 'js-sha256';  
  
// 管理未完成的寫請求:key=請求唯一標識,value=AbortController  
const pendingWriteRequests = new Map();  
  
// 寫請求專用Axios實例  
const writeService = axios.create({  
 baseURL: import.meta.env.VITE_API_BASE_URL,  
 timeout: 5000  
});  
  
// 發起寫請求(如POST/PUT/DELETE)  
export function sendWriteRequest(config) {  
 // 1. 生成請求唯一標識  
 const requestKey = generateRequestKey(config);  
   
 // 2. 檢查是否存在未完成的相同請求:若有,取消新請求  
 if (pendingWriteRequests.has(requestKey)) {  
 const newController = new AbortController();  
 // 取消新請求  
 newController.abort('重複請求已取消');  
 return Promise.reject(new Error('重複請求已取消'));  
 }  
   
 // 3. 創建AbortController,用於取消請求  
 const controller = new AbortController();  
 const newConfig = {  
 ...config,  
 signal: controller.signal // 綁定取消信號  
 };  
   
 // 4. 將請求標識和取消控制器存入容器  
 pendingWriteRequests.set(requestKey, controller);  
   
 // 5. 發送請求,完成後清除容器  
 return writeService(newConfig)  
 .then((response) => {  
 pendingWriteRequests.delete(requestKey);  
 return response.data;  
 })  
 .catch((error) => {  
 pendingWriteRequests.delete(requestKey);  
 // 過濾"主動取消"的錯誤,避免業務層處理  
 if (error.name === 'AbortError') {  
 console.log('請求已取消:', requestKey);  
 return Promise.reject(new Error('請求已取消'));  
 }  
 return Promise.reject(error);  
 });  
}  
  
// 生成請求唯一標識(同方案2)  
function generateRequestKey(config) {  
 const { url, method, params, data } = config;  
 const requestStr = JSON.stringify({ url, method, params, data });  
 return sha256(requestStr);  
}  
  
// 手動取消指定請求(如頁面卸載時)  
export function cancelWriteRequest(config) {  
 const requestKey = generateRequestKey(config);  
 if (pendingWriteRequests.has(requestKey)) {  
 const controller = pendingWriteRequests.get(requestKey);  
 controller.abort('手動取消請求');  
 pendingWriteRequests.delete(requestKey);  
 }  
}  
  
export default writeService;  

優缺點分析

優點:
適合寫操作,確保數據一致性
支持手動取消(如頁面卸載)
避免重複寫請求導致的數據問題

缺點:
實現較複雜,需手動管理取消邏輯
對業務代碼有一定侵入性(需使用專用請求函數)
無法複用請求結果,每次請求都需觸達服務器

適用場景

  • 寫操作接口(如表單提交、數據修改、刪除操作)
  • 對數據一致性要求高的場景(如訂單創建、支付請求)

方案4:訂閲-發佈模式(多訂閲者共享請求結果)

當多個組件同時調用同一接口時,可通過"訂閲-發佈模式"實現"一次請求,多端複用",核心思路是"相同請求只發送一次,結果分發給所有訂閲者",這也是參考範文中採用的核心方案。

實現原理

  1. 維護一個請求狀態容器:key=請求唯一標識,value=訂閲者列表+請求Promise
  2. 組件發起請求時,生成請求唯一標識,檢查容器:
  • 若請求已存在(未完成):將當前組件的回調函數加入訂閲者列表
  • 若請求不存在:發送請求,將Promise存入容器,並添加當前組件的訂閲者
  1. 請求完成後,遍歷訂閲者列表,將結果分發給所有訂閲者
  2. 訂閲者取消訂閲(如組件卸載)時,從訂閲者列表中移除自身

代碼示例(基於參考範文封裝)

import axios from 'axios';  
import { sha256 } from 'js-sha256';  
  
class RequestSubscriber {  
 // 容器:key=請求唯一標識,value={ promise: 請求Promise, subscribers: 訂閲者列表 }  
 constructor() {  
 this.requestStore = new Map();  
 this.instance = axios.create({  
 baseURL: import.meta.env.VITE_API_BASE_URL,  
 timeout: 5000  
 });  
 }  
  
 // 發起請求(訂閲)  
 request(config) {  
 const requestKey = this.generateRequestKey(config);  
 const storeItem = this.requestStore.get(requestKey);  
  
 // 1. 若請求已存在,添加訂閲者  
 if (storeItem) {  
 return new Promise((resolve, reject) => {  
 storeItem.subscribers.push({ resolve, reject });  
 });  
 }  
  
 // 2. 若請求不存在,創建請求並訂閲  
 const subscribers = [];  
 const controller = new AbortController();  
 const newConfig = { ...config, signal: controller.signal };  
  
 // 創建請求Promise  
 const requestPromise = this.instance(newConfig)  
 .then((response) => {  
 // 請求成功,通知所有訂閲者  
 this.notifySubscribers(requestKey, 'resolve', response.data);  
 return response.data;  
 })  
 .catch((error) => {  
 // 請求失敗,通知所有訂閲者  
 this.notifySubscribers(requestKey, 'reject', error);  
 return Promise.reject(error);  
 })  
 .finally(() => {  
 // 請求完成,清除容器  
 this.requestStore.delete(requestKey);  
 });  
  
 // 存入容器  
 this.requestStore.set(requestKey, {  
 promise: requestPromise,  
 subscribers,  
 controller  
 });  
  
 // 返回當前訂閲的Promise  
 return new Promise((resolve, reject) => {  
 subscribers.push({ resolve, reject });  
 });  
 }  
  
 // 通知所有訂閲者  
 notifySubscribers(requestKey, type, data) {  
 const storeItem = this.requestStore.get(requestKey);  
 if (!storeItem) return;  
  
 storeItem.subscribers.forEach((subscriber) => {  
 subscriber[type](data);  
 });  
 }  
  
 // 取消請求(如組件卸載)  
 cancelRequest(config) {  
 const requestKey = this.generateRequestKey(config);  
 const storeItem = this.requestStore.get(requestKey);  
 if (storeItem) {  
 // 取消請求  
 storeItem.controller.abort('請求已取消');  
 // 清除容器  
 this.requestStore.delete(requestKey);  
 }  
 }  
  
 // 生成請求唯一標識  
 generateRequestKey(config) {  
 const { url, method, params, data } = config;  
 const requestStr = JSON.stringify({ url, method, params, data });  
 return sha256(requestStr).slice(0, 40); // 截取前40位,平衡唯一性和長度  
 }  
}  
  
// 單例模式:確保全局只有一個實例  
export const requestSubscriber = new RequestSubscriber();  

優缺點分析

優點:
多組件共享請求結果,減少請求次數
支持請求取消,避免內存泄漏
兼顧讀操作和寫操作(寫操作可關閉共享)

缺點:
實現複雜,需維護訂閲者列表和請求狀態
調試難度高,需跟蹤訂閲者和請求狀態
對新手不友好,需理解訂閲-發佈模式

適用場景

  • 多組件同時調用同一接口的場景(如多個組件需要同一批下拉選單數據)
  • 大型項目,需統一管理請求狀態和訂閲關係

方案5:後端配合攔截(最徹底的方案)

前端方案雖能解決大部分場景,但仍存在"極端情況漏洞"(如網絡延遲導致的請求繞過前端攔截),此時需後端配合,從源頭攔截重複請求,核心思路是"後端基於唯一標識判斷是否為重複請求"。

實現原理

  1. 前端發起請求時,生成一個唯一標識(如UUID),存入請求頭(如X-Request-ID
  2. 後端接收到請求後,檢查X-Request-ID
  • 若Redis中不存在該ID:處理請求,並將ID存入Redis(設置過期時間,如5秒)
  • 若Redis中已存在該ID:判定為重複請求,直接返回"重複請求"錯誤
  1. 前端接收到"重複請求"錯誤後,提示用户或忽略該響應

代碼示例(前後端配合)

前端部分

import axios from 'axios';  
import { v4 as uuidv4 } from 'uuid';  
  
const service = axios.create({  
 baseURL: import.meta.env.VITE_API_BASE_URL,  
 timeout: 5000  
});  
  
// 請求攔截器:添加唯一請求ID  
service.interceptors.request.use(  
 (config) => {  
 // 生成唯一請求ID(UUID)  
 const requestId = uuidv4();  
 // 存入請求頭  
 config.headers['X-Request-ID'] = requestId;  
 // 存入localStorage,用於後續重複請求判斷(可選)  
 localStorage.setItem(`request_${requestId}`, 'pending');  
 return config;  
 },  
 (error) => Promise.reject(error)  
);  
  
// 響應攔截器:處理重複請求錯誤  
service.interceptors.response.use(  
 (response) => {  
 const requestId = response.config.headers['X-Request-ID'];  
 // 請求完成,刪除localStorage中的標識  
 localStorage.removeItem(`request_${requestId}`);  
 return response.data;  
 },  
 (error) => {  
 if (error.response?.data?.code === 'DUPLICATE_REQUEST') {  
 // 後端返回重複請求錯誤,提示用户  
 message.warning('請勿重複操作');  
 const requestId = error.config.headers['X-Request-ID'];  
 localStorage.removeItem(`request_${requestId}`);  
 return Promise.reject(new Error('重複請求已攔截'));  
 }  
 return Promise.reject(error);  
 }  
);  
  
export default service;  
``

**後端部分(Node.js + Redis)**:

``const express = require('express');  
const redis = require('redis');  
const { v4: uuidv4 } = require('uuid');  
  
const app = express();  
const redisClient = redis.createClient({  
 url: process.env.REDIS_URL  
});  
redisClient.connect();  
  
// 重複請求攔截中間件  
app.use(async (req, res, next) => {  
 const requestId = req.headers['x-request-id'];  
 if (!requestId) {  
 return res.status(400).json({ code: 'INVALID_REQUEST', message: '缺少請求ID' });  
 }  
  
 // 檢查Redis中是否存在該請求ID  
 const exists = await redisClient.exists(`request:${requestId}`);  
 if (exists) {  
 // 已存在,判定為重複請求  
 return res.status(400).json({ code: 'DUPLICATE_REQUEST', message: '重複請求已攔截' });  
 }  
  
 // 不存在,存入Redis(設置5秒過期,避免內存泄漏)  
 await redisClient.setEx(`request:${requestId}`, 5, 'pending');  
 next();  
});  
  
// 業務接口  
app.post('/api/submit-form', (req, res) => {  
 // 處理表單提交邏輯  
 res.json({ code: 'SUCCESS', message: '提交成功' });  
});  
  
app.listen(3000, () => {  
 console.log('Server running on port 3000');  
});  

優缺點分析

優點:
從源頭攔截重複請求,最徹底
不受前端環境影響(如多標籤頁、多設備)
支持分佈式系統,可跨服務判斷重複請求

缺點:
需後端配合,增加後端開發成本
依賴Redis等存儲服務,增加部署複雜度
需處理請求ID的過期邏輯,避免存儲膨脹

適用場景

  • 對數據一致性要求極高的場景(如支付、訂單創建)
  • 大型分佈式系統,前端攔截無法覆蓋所有場景

三、推薦組合方案實現"徹底解決"

單一方案無法覆蓋所有場景,實際項目中建議採用"組合方案",兼顧性能、體驗和數據一致性:

  1. 基礎層:方案1(UI控制)+ 方案2(緩存)
  • 所有按鈕點擊添加loading狀態,防止重複觸發
  • 所有讀操作接口添加緩存,減少服務器壓力
  1. 核心層:方案3(請求取消)+ 方案4(訂閲-發佈)
  • 所有寫操作接口添加請求取消邏輯,避免重複提交
  • 多組件共享的接口使用訂閲-發佈模式,提升性能
  1. 保障層:方案5(後端配合)
  • 核心業務接口(如支付、訂單)添加後端重複攔截
  • 前端傳遞唯一請求ID,後端基於Redis判斷重複

通過這種"三層防護",可徹底解決前端重複請求問題,同時兼顧開發效率和系統穩定性。

user avatar baozouai 头像 huyouxueboshi 头像 awbeci 头像 delia_5a38831addb7b 头像 gaoxingdeqincai 头像 fsjohnhuang 头像 aiyaotoudedianfengshan 头像 papermoon 头像 jrainlau 头像 tingtinger 头像 sugar_coffee 头像
点赞 11 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.