前端AJAX并发请求导致数据错乱覆盖的解决方案实战代码示例
问题的本质
你肯定遇到过这种场景:用户在搜索结果框里快速打字,每敲一个字都会发一次请求,结果返回的数据顺序和请求顺序不一致,页面上显示的结果完全乱套。或者点击列表里某个商品,还没来得及看详情,又点了另一个,结果跳回去的是第一个商品的详情。
这种现象在开发圈里有个专门的叫法——竞态问题(Race Condition),本质上就是”抢着回复,谁先回来谁覆盖谁”。
一个真实的生活比喻
想象你在餐厅点菜,你点了三道菜:鱼香肉丝、宫保鸡丁、麻婆豆腐。服务员记完单子后去厨房下单。结果厨房很忙,先做好的是最后点的麻婆豆腐,然后才是鱼香肉丝,最后是宫保鸡丁。
如果服务员不加区分,哪道菜先做好就直接端上桌,可能出现这种情况:麻婆豆腐先上桌(但你还没点这个),然后鱼香肉丝上桌,最后宫保鸡丁上桌。但你本来想先吃鱼香肉丝的,结果被端上来的菜完全打乱了。
AJAX并发请求就是这种情况,请求出去的顺序和返回的顺序不一定一致。
最常见的”翻车”现场
先来看一段非常典型的错误代码,很多人一开始都是这么写的:
// 搜索框输入事件
const searchInput = document.querySelector('#search');
const resultDiv = document.querySelector('#result');
searchInput.addEventListener('input', (e) => {
const keyword = e.target.value;
// 每次输入都发新请求
fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
.then(res => res.json())
.then(data => {
// 问题:如果这个请求最后才返回,但keyword已经不是当前值了
resultDiv.innerHTML = data.map(item => `<li>${item.name}</li>`).join('');
});
});
这段代码看起来没问题,但实际运行时会出大问题。你快速输入”javascript”,假设每输入一个字都发请求:
- 输入”j”时发出请求A
- 输入”ja”时发出请求B
- 输入”jav”时发出请求C
- 输入”java”时发出请求D
- 输入”javas”时发出请求E
- 输入”javasc”时发出请求F
- 输入”javascr”时发出请求G
- 输入”javascri”时发出请求H
- 输入”javascript”时发出请求I
服务器处理速度各不相同,有可能请求B先于请求I返回,然后它就把结果写进resultDiv了。更糟糕的是,如果请求B恰好比请求I还慢一点,那就完全覆盖了正确答案。
解决方案一:请求序号追踪法
这是最经典、最易懂的方案。给每个请求分配一个序号,返回时检查是否还是最新的那个请求。
let requestCounter = 0;
const searchInput = document.querySelector('#search');
const resultDiv = document.querySelector('#result');
searchInput.addEventListener('input', async (e) => {
const keyword = e.target.value;
// 关键:当前这次请求分配一个唯一的序号
const currentRequestId = ++requestCounter;
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
const data = await response.json();
// 关键:返回时检查序号,如果不是最新的就丢弃结果
if (currentRequestId !== requestCounter) {
console.log(`请求 ${currentRequestId} 已过期,丢弃结果`);
return;
}
resultDiv.innerHTML = data.map(item => `<li>${item.name}</li>`).join('');
} catch (error) {
console.error('搜索失败:', error);
}
});
这个方案的逻辑非常简单:
- 每次发请求前,计数器加一,得到当前序号
- 请求返回时,对比存储的序号和当前计数器
- 如果一致,说明这是最新请求,正常渲染
- 如果不一致,说明期间又有新请求发出了,丢弃这次的结果
举个例子:你输入”js”,requestCounter变成1,发出请求A。然后你继续输入变成”jsc”,requestCounter变成2,发出请求B。如果请求A先返回,检查发现1不等于2,直接丢弃。如果请求B先返回,检查发现2等于2,正常渲染。完美解决。
解决方案二:AbortController取消法
这是现代浏览器原生支持的方式,更优雅。可以在发出新请求时取消旧请求。
let currentController = null;
const searchInput = document.querySelector('#search');
const resultDiv = document.querySelector('#result');
searchInput.addEventListener('input', async (e) => {
const keyword = e.target.value;
// 关键:取消上一次未完成的请求
if (currentController) {
currentController.abort();
console.log(`已取消上一次请求`);
}
// 创建新的控制器
currentController = new AbortController();
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(keyword)}`,
{ signal: currentController.signal }
);
const data = await response.json();
resultDiv.innerHTML = data.map(item => `<li>${item.name}</li>`).join('');
} catch (error) {
// 被取消的请求会抛出 AbortError,不需要处理
if (error.name === 'AbortError') {
console.log('请求已被取消,忽略');
return;
}
console.error('搜索失败:', error);
}
});
这个方案的妙处在于:
- 真正做到了”旧请求直接杀掉”,不浪费服务器资源
- 代码结构清晰,语义明确
- 兼容性好,现代浏览器都支持
但要注意一个细节:AbortController只能取消还在进行中的请求。如果旧请求已经完成了,cancel也没用,不过这种情况不需要cancel,因为它已经不会覆盖新数据了。
解决方案三:Promise链式追踪法
这个方案适合更复杂的场景,尤其是需要处理多个相关请求的情况。
let latestPromise = Promise.resolve();
const searchInput = document.querySelector('#search');
const resultDiv = document.querySelector('#result');
searchInput.addEventListener('input', async (e) => {
const keyword = e.target.value;
// 关键:把新请求接在上一个请求的链上
latestPromise = latestPromise.then(async () => {
// 如果当前输入已经变了,说明中间有新请求,跳过
// 但我们用另一种方式判断:检查keyword是否匹配
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
const data = await response.json();
// 再检查一次,确保keyword还没变
const inputElement = document.querySelector('#search');
if (inputElement.value !== keyword) {
console.log(`关键词已变化为 "${inputElement.value}",丢弃本次结果`);
return;
}
resultDiv.innerHTML = data.map(item => `<li>${item.name}</li>`).join('');
} catch (error) {
console.error('搜索失败:', error);
}
});
});
等等,这个方案有个小问题。latestPromise链式追踪法虽然能保证请求按顺序执行,但keyword变量在闭包中的值可能不是最新的。
让我给你一个更完善的版本:
let pendingKeyword = null;
let isProcessing = false;
const searchInput = document.querySelector('#search');
const resultDiv = document.querySelector('#result');
searchInput.addEventListener('input', async (e) => {
const keyword = e.target.value;
// 如果已经在处理请求,先标记当前keyword
if (isProcessing) {
pendingKeyword = keyword;
return;
}
isProcessing = true;
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
const data = await response.json();
// 关键:返回时检查是否还有更新的请求在等待
if (pendingKeyword !== null && pendingKeyword !== keyword) {
console.log(`结果过期,有新的请求等待处理`);
return;
}
resultDiv.innerHTML = data.map(item => `<li>${item.name}</li>`).join('');
} catch (error) {
console.error('搜索失败:', error);
} finally {
isProcessing = false;
// 如果有pending的keyword,自动处理下一个
if (pendingKeyword !== null) {
const nextKeyword = pendingKeyword;
pendingKeyword = null;
// 触发下一次处理(重新走整个流程)
performSearch(nextKeyword);
}
}
});
function performSearch(keyword) {
// 这个函数封装了搜索逻辑,方便递归调用
if (isProcessing) {
pendingKeyword = keyword;
return;
}
isProcessing = true;
fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
.then(res => res.json())
.then(data => {
if (pendingKeyword !== null && pendingKeyword !== keyword) {
return;
}
resultDiv.innerHTML = data.map(item => `<li>${item.name}</li>`).join('');
})
.catch(err => console.error('搜索失败:', err))
.finally(() => {
isProcessing = false;
if (pendingKeyword !== null) {
const nextKeyword = pendingKeyword;
pendingKeyword = null;
performSearch(nextKeyword);
}
});
}
这个方案的核心思路是串行化:同一时间只允许一个请求在处理,新请求要么排队,要么取消旧的。
解决方案四:结合防抖(Debounce)使用
实际上,大多数情况下我们不需要上面那么复杂的方案。防抖往往能从根本上解决问题。
function debounce(func, delay) {
let timer = null;
return function(...args) {
// 每次调用都清除之前的定时器
if (timer) clearTimeout(timer);
// 重新设置定时器
timer = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
// 使用防抖包裹搜索逻辑
const searchInput = document.querySelector('#search');
const resultDiv = document.querySelector('#result');
const debouncedSearch = debounce(async (keyword) => {
if (!keyword.trim()) {
resultDiv.innerHTML = '';
return;
}
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
const data = await response.json();
resultDiv.innerHTML = data.map(item => `<li>${item.name}</li>`).join('');
} catch (error) {
console.error('搜索失败:', error);
}
}, 300);
searchInput.addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
防抖的原理是:在用户停止输入300毫秒后才真正发送请求。这样无论用户打字多快,最终只会发送一次请求,根本不存在竞态问题。
四种方案的对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 序号追踪 | 简单直观,兼容性最好 | 请求还是发出了,浪费带宽 | 兼容性要求高的老项目 |
| AbortController | 真正取消请求,语义清晰 | 需要现代浏览器 | 现代Web应用首选 |
| Promise链式 | 保证串行执行 | 代码复杂,容易出错 | 复杂的状态管理场景 |
| 防抖 | 根本解决问题,简单高效 | 用户可能感觉有延迟 | 搜索框等输入场景 |
一个完整的实战示例
下面是一个综合了多种技术的完整案例,模拟一个真实的搜索组件:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>搜索防竞态实战</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
#search { width: 100%; padding: 12px; font-size: 16px; border: 2px solid #ddd; border-radius: 8px; }
#search:focus { border-color: #4a90d9; outline: none; }
#result { margin-top: 16px; list-style: none; padding: 0; }
#result li { padding: 10px 12px; border-bottom: 1px solid #eee; cursor: pointer; }
#result li:hover { background: #f5f5f5; }
.loading { color: #999; font-size: 14px; }
.cancelled { color: #bbb; font-style: italic; font-size: 13px; }
</style>
</head>
<body>
<h2>🔍 搜索示例(防竞态)</h2>
<input type="text" id="search" placeholder="输入关键词搜索...">
<ul id="result"></ul>
<div id="status"></div>
<script>
// ========== 配置 ==========
const DEBOUNCE_DELAY = 300; // 防抖延迟(毫秒)
const MOCK_API_DELAY = { min: 200, max: 800 }; // 模拟API响应时间范围
// ========== 工具函数 ==========
function randomDelay() {
return Math.random() * (MOCK_API_DELAY.max - MOCK_API_DELAY.min) + MOCK_API_DELAY.min;
}
// 模拟API(实际项目中换成真实接口)
function mockSearchApi(keyword) {
return new Promise((resolve) => {
setTimeout(() => {
const mockData = [
{ id: 1, name: `${keyword} JavaScript教程` },
{ id: 2, name: `${keyword} TypeScript入门` },
{ id: 3, name: `${keyword} 前端开发指南` },
{ id: 4, name: `${keyword} Vue.js实战` },
{ id: 5, name: `${keyword} React原理` },
];
resolve(mockData);
}, randomDelay());
});
}
// ========== 核心搜索组件 ==========
class SearchComponent {
constructor(inputSelector, resultSelector, statusSelector) {
this.input = document.querySelector(inputSelector);
this.resultList = document.querySelector(resultSelector);
this.statusEl = document.querySelector(statusSelector);
// 请求追踪
this.requestId = 0;
this.abortController = null;
// 防抖定时器
this.debounceTimer = null;
this._bindEvents();
}
_bindEvents() {
this.input.addEventListener('input', (e) => {
this._handleInput(e.target.value);
});
}
_handleInput(keyword) {
// 清除之前的防抖定时器
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
// 设置新的防抖
this.debounceTimer = setTimeout(() => {
this._search(keyword);
}, DEBOUNCE_DELAY);
}
async _search(keyword) {
if (!keyword.trim()) {
this.resultList.innerHTML = '';
this._setStatus('', '');
return;
}
// 取消上一次未完成的请求
if (this.abortController) {
this.abortController.abort();
this._setStatus('已取消上一次请求', 'cancelled');
}
// 创建新的控制器
this.abortController = new AbortController();
const currentId = ++this.requestId;
this._setStatus(`请求 #${currentId} 进行中...`, 'loading');
try {
const data = await mockSearchApi(keyword);
// 双重检查:确保没有被取消且是最新的请求
if (this.abortController.signal.aborted) {
this._setStatus(`请求 #${currentId} 已取消`, 'cancelled');
return;
}
if (currentId !== this.requestId) {
this._setStatus(`请求 #${currentId} 结果过期`, 'cancelled');
return;
}
// 渲染结果
this.resultList.innerHTML = data
.map(item => `<li>${item.name}</li>`)
.join('');
this._setStatus(`请求 #${currentId} 完成`, '');
} catch (error) {
if (error.name === 'AbortError') {
this._setStatus(`请求 #${currentId} 被取消`, 'cancelled');
} else {
this._setStatus(`请求 #${currentId} 出错: ${error.message}`, '');
}
}
}
_setStatus(text, className) {
this.statusEl.textContent = text;
this.statusEl.className = className;
}
}
// ========== 初始化 ==========
const search = new SearchComponent('#search', '#result', '#status');
</script>
</body>
</html>
实际项目中的最佳实践
在我的经验里,真实的工业级解决方案往往是组合拳:
// utils/raceCondition.js
class RequestManager {
constructor() {
this.currentController = null;
this.pendingRequests = new Map(); // 记录所有活跃请求
}
/**
* 发送一个防竞态的fetch请求
* @param {string} url - 请求地址
* @param {object} options - fetch配置
* @returns {Promise<any>}
*/
async request(url, options = {}) {
// 1. 取消上一个同类型的请求(通过key标识)
const key = options.requestKey || url;
if (this.currentController && this.pendingRequests.has(key)) {
this.currentController.abort();
this.pendingRequests.delete(key);
}
// 2. 创建新控制器
const controller = new AbortController();
this.currentController = controller;
this.pendingRequests.set(key, controller);
// 3. 发送请求
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} finally {
// 4. 请求结束后清理
this.pendingRequests.delete(key);
if (this.currentController === controller) {
this.currentController = null;
}
}
}
/**
* 取消所有待处理的请求
*/
cancelAll() {
for (const [key, controller] of this.pendingRequests) {
controller.abort();
}
this.pendingRequests.clear();
this.currentController = null;
}
}
// 全局单例
const requestManager = new RequestManager();
// 使用示例
async function searchUsers(keyword) {
try {
const users = await requestManager.request(`/api/users/search?q=${keyword}`, {
requestKey: 'userSearch', // 同一key的请求会互相取消
});
renderUsers(users);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('搜索失败:', error);
}
}
}
总结
竞态问题本质上是时间不同步造成的。解决思路无非两条:
- 让旧请求消失:用AbortController主动取消
- 让结果不被错误应用:用序号追踪,返回时验证是否过期
在真实项目中,我推荐防抖 + AbortController的组合:防抖减少不必要的请求数量,AbortController确保旧请求不会干扰新结果。这样既高效又安全。
记住,竞态问题不是bug,是异步编程的固有特性。理解它、管理它,你的代码就会更加健壮。
