AJAX并发请求实战浏览器同时发送多个请求时数据错乱超时竞态条件问题的解决方案
写代码这些年,我被竞态条件坑过太多次了。有一次线上数据全乱了,查了整整三天,最后发现是三个并发请求返回顺序和发送顺序不一致导致的。今天就把我踩过的坑、总结出的方案,一次性给你讲清楚。
什么是竞态条件
想象你在餐厅点菜,你同时点了三道菜,厨师按顺序上菜:第一道、第二道、第三道。但有时候厨房忙,第三道菜比第一道先上桌。如果你以为上的菜顺序是固定的,那体验就糟糕了。
并发请求的问题一模一样:
- 你同时发了请求A、请求B、请求C
- 它们到达服务器的时间差不多
- 但返回顺序可能是C、A、B(看谁先处理完)
- 如果你直接用返回的数据更新UI,数据就对不上了
举个真实的例子,我之前做的一个股票行情页面:
// 错误写法:直接请求,不做任何处理
function fetchStock(symbol) {
fetch('/api/stock/' + symbol)
.then(res => res.json())
.then(data => {
// 如果请求顺序和返回顺序不一致
// 可能把A股票的数据显示在B股票的位置上
document.getElementById(symbol).textContent = data.price;
});
}
// 用户快速切换股票代码时,问题就暴露了
fetchStock('AAPL'); // 请求1
fetchStock('GOOG'); // 请求2
fetchStock('MSFT'); // 请求3
// 可能MSFT先返回,但GOOG的数据覆盖到了MSFT的显示区域
这就是典型的竞态条件,数据错乱就是这么来的。
超时问题有多常见
超时问题比你想象的存在感更强。很多人写代码时只考虑了”成功”和”失败”两种情况,完全忽略了”超时”这个分支。
实际开发中,慢请求可能来自:
- 服务器负载高,响应慢
- 网络不稳定,请求慢
- 数据量大,处理慢
- 跨域请求多一次DNS解析
看下面这个案例,某电商项目的订单列表页:
// 用户切换筛选条件时,频繁发送请求
function loadOrders(filter) {
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/orders?filter=' + filter);
// 没有设置超时!
xhr.onload = function() {
// 如果请求慢,用户可能已经切换了三次条件
// 最终显示的是很久之前的旧数据
renderOrders(JSON.parse(xhr.responseText));
};
xhr.send();
}
这个bug上线半年才被发现,因为慢请求只出现在晚高峰,而且不是每次都复现。产品经理都懵了,用户反馈”数据有时候对有时候不对”,排查起来简直噩梦。
fetch的AbortController方案
现代浏览器有一个神器:AbortController。它可以让我们在请求还没回来时主动取消它。
原理很简单:创建控制器 → 把控制器传给请求 → 需要时调用abort()。
class RequestManager {
constructor() {
// 用一个Map存储当前所有活跃请求
// key是请求标识,value是控制器
this.activeRequests = new Map();
}
// 核心方法:发送请求,自动管理
async request(id, url, options = {}) {
// 如果这个id的请求还在进行中,先取消它
if (this.activeRequests.has(id)) {
this.activeRequests.get(id).abort();
this.activeRequests.delete(id);
}
// 创建新的控制器
const controller = new AbortController();
this.activeRequests.set(id, controller);
try {
const response = await fetch(url, {
...options,
signal: controller.signal // 把信号传进去
});
// 请求成功,从Map中移除
this.activeRequests.delete(id);
return await response.json();
} catch (error) {
// 如果是主动取消的,不报错
if (error.name === 'AbortError') {
console.log(`请求 ${id} 已被取消`);
return null;
}
// 其他错误正常抛出
throw error;
}
}
// 取消某个请求
cancel(id) {
if (this.activeRequests.has(id)) {
this.activeRequests.get(id).abort();
this.activeRequests.delete(id);
}
}
// 取消所有请求
cancelAll() {
for (const [id, controller] of this.activeRequests) {
controller.abort();
}
this.activeRequests.clear();
}
}
// 使用示例
const mgr = new RequestManager();
// 搜索输入框,每输入一个字符就发请求,但只有最新的那次有效
const searchInput = document.getElementById('search');
let debounceTimer;
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const query = e.target.value;
if (!query) return;
// 用查询词作为id,重复发送时会自动取消旧请求
const results = await mgr.request(
'search:' + query,
`/api/search?q=${encodeURIComponent(query)}`
);
renderSearchResults(results);
}, 300);
});
这个方案解决了竞态条件,因为同一时刻同一个id只有一个请求在运行。旧的请求被自动取消,不会干扰新的请求。
XMLHttpRequest的兼容方案
如果项目要兼容老旧浏览器(比如IE11),AbortController不能用,怎么办?
其实思路一样,只是用XHR的abort()方法:
class XHRRequestManager {
constructor() {
this.activeRequests = new Map();
}
request(id, url, callback, options = {}) {
// 取消旧的请求
if (this.activeRequests.has(id)) {
this.activeRequests.get(id).abort();
this.activeRequests.delete(id);
}
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
// 设置超时时间,单位毫秒
const timeout = options.timeout || 5000;
xhr.timeout = timeout;
// 请求完成的回调
const onComplete = () => {
this.activeRequests.delete(id);
// 检查请求是否被取消
if (xhr.status >= 200 && xhr.status < 300) {
callback(null, JSON.parse(xhr.responseText));
} else {
callback(new Error(`HTTP ${xhr.status}`));
}
};
xhr.onload = onComplete;
xhr.onerror = () => callback(new Error('网络错误'));
// 超时回调
xhr.ontimeout = () => {
this.activeRequests.delete(id);
callback(new Error(`请求超时(${timeout}ms)`));
};
// 取消时触发onabort
xhr.onabort = () => {
this.activeRequests.delete(id);
callback(new Error('请求已取消'));
};
xhr.send();
this.activeRequests.set(id, xhr);
return xhr; // 返回xhr对象,方便外部取消
}
cancel(id) {
if (this.activeRequests.has(id)) {
this.activeRequests.get(id).abort();
this.activeRequests.delete(id);
}
}
}
// 使用
const mgr = new XHRRequestManager();
// 下拉选择框,每次change发请求
const select = document.getElementById('category');
select.addEventListener('change', (e) => {
const categoryId = e.target.value;
mgr.request(
'products:' + categoryId,
`/api/products?category=${categoryId}`,
(err, data) => {
if (err) {
if (err.message !== '请求已取消') {
console.error(err);
}
return;
}
renderProducts(data);
},
{ timeout: 8000 }
);
});
请求去重,避免重复发送
有时候竞态条件不是顺序问题,而是重复发送相同请求。比如用户快速点击同一个按钮两次,两个请求都发出去了,最后只处理第一个返回的,第二个覆盖掉,看起来数据对了,但实际上做了无用功。
去重的方法是用一个已发请求的缓存:
class DedupRequestManager {
constructor() {
this.cache = new Map(); // URL → Promise
this.activeRequests = new Map(); // URL → AbortController
}
async request(url, options = {}) {
// 如果这个URL已经有正在进行的请求,直接复用Promise
if (this.cache.has(url)) {
console.log(`复用缓存请求: ${url}`);
return this.cache.get(url);
}
// 创建控制器
const controller = new AbortController();
this.activeRequests.set(url, controller);
// 创建Promise并缓存
const promise = fetch(url, { ...options, signal: controller.signal })
.then(res => res.json())
.finally(() => {
// 无论成功失败,都清理
this.activeRequests.delete(url);
this.cache.delete(url);
});
this.cache.set(url, promise);
return promise;
}
cancel(url) {
if (this.activeRequests.has(url)) {
this.activeRequests.get(url).abort();
this.activeRequests.delete(url);
this.cache.delete(url);
}
}
}
这个方案在聊天应用、数据表格等场景特别有用,用户快速切换筛选条件时,不会重复请求相同的数据。
请求优先级与取消策略
在实际项目中,请求是有优先级的。比如页面加载时:
- 首屏数据必须等
- 下方折叠区域的次之
- 日志上报可以随便
如果全部同时发,带宽被挤占,重要的请求反而变慢了。看这个策略:
class PriorityRequestManager {
constructor() {
this.activeRequests = new Map();
this.pendingQueue = []; // 待处理请求队列
this.maxConcurrent = 3; // 最多同时3个请求
}
// 优先级: 1(最高) - 5(最低)
async request(id, url, options = {}, priority = 3) {
// 先检查是否有同id的旧请求,有就取消
if (this.activeRequests.has(id)) {
this.activeRequests.get(id).abort();
this.activeRequests.delete(id);
}
// 插入队列(按优先级排序)
const task = { id, url, options, priority, controller: null };
this.pendingQueue.push(task);
this.pendingQueue.sort((a, b) => a.priority - b.priority);
this._processQueue();
// 等待当前这个请求完成
return new Promise((resolve, reject) => {
task.resolve = resolve;
task.reject = reject;
});
}
async _processQueue() {
// 如果已达并发上限,不处理
if (this.activeRequests.size >= this.maxConcurrent) return;
// 取出优先级最高的任务
const task = this.pendingQueue.find(t => t.id === undefined);
if (!task) return;
// 从队列移除
this.pendingQueue = this.pendingQueue.filter(t => t !== task);
// 执行请求
const controller = new AbortController();
task.controller = controller;
this.activeRequests.set(task.url, controller);
try {
const response = await fetch(task.url, { ...task.options, signal: controller.signal });
const data = await response.json();
this.activeRequests.delete(task.url);
task.resolve(data);
} catch (error) {
this.activeRequests.delete(task.url);
if (error.name === 'AbortError') {
task.reject(new Error('请求已取消'));
} else {
task.reject(error);
}
}
// 继续处理队列
if (this.pendingQueue.length > 0) {
this._processQueue();
}
}
cancel(id, url) {
if (this.activeRequests.has(url)) {
this.activeRequests.get(url).abort();
this.activeRequests.delete(url);
}
}
}
// 使用
const mgr = new PriorityRequestManager();
// 首屏数据,高优先级
mgr.request(null, '/api/user-profile', {}, 1);
mgr.request(null, '/api/dashboard-data', {}, 1);
// 下方推荐,普通优先级
mgr.request(null, '/api/recommendations', {}, 3);
// 日志上报,低优先级,随时可以取消
mgr.request(null, '/api/log', { method: 'POST', body: JSON.stringify({event: 'pageview'}) }, 5);
完整的实战案例:搜索联想功能
把以上方案整合到一个真实场景中。搜索联想是最容易出竞态条件bug的地方:
class SmartSearch {
constructor(inputEl, resultEl, apiEndpoint) {
this.input = inputEl;
this.resultEl = resultEl;
this.endpoint = apiEndpoint;
this.manager = new RequestManager();
this.currentId = 0;
this._bindEvents();
}
_bindEvents() {
this.input.addEventListener('input', (e) => {
this._handleInput(e.target.value);
});
// 页面卸载时取消所有请求
window.addEventListener('beforeunload', () => {
this.manager.cancelAll();
});
}
async _handleInput(query) {
query = query.trim();
// 清空结果
this.resultEl.innerHTML = '';
if (!query) return;
// 生成唯一请求ID,防止竞态
const requestId = ++this.currentId;
this.resultEl.dataset.requestId = requestId;
// 显示加载状态
this.resultEl.innerHTML = '<div class="loading">搜索中...</div>';
try {
// 发请求,传入requestId
const results = await this.manager.request(
`search:${query}`,
`${this.endpoint}?q=${encodeURIComponent(query)}`
);
// 关键检查:这个返回是不是对应最新的请求?
if (this.resultEl.dataset.requestId != requestId) {
console.log('返回的是旧请求数据,忽略');
return;
}
if (!results || results.length === 0) {
this.resultEl.innerHTML = '<div class="empty">没有找到相关结果</div>';
return;
}
// 渲染结果
this._renderResults(results);
} catch (error) {
if (error.message !== '请求已取消') {
console.error('搜索失败:', error);
this.resultEl.innerHTML = `<div class="error">搜索失败: ${error.message}</div>`;
}
}
}
_renderResults(results) {
const html = results.map(item => `
<div class="result-item" data-id="${item.id}">
<span class="title">${this._escapeHtml(item.title)}</span>
<span class="desc">${this._escapeHtml(item.description)}</span>
</div>
`).join('');
this.resultEl.innerHTML = html;
// 点击事件
this.resultEl.querySelectorAll('.result-item').forEach(el => {
el.addEventListener('click', () => {
window.location.href = `/item/${el.dataset.id}`;
});
});
}
_escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// 初始化
const search = new SmartSearch(
document.getElementById('search-input'),
document.getElementById('search-results'),
'/api/search'
);
这个例子里有两个关键点:
- 用
requestId双重校验,即使请求被取消后仍然返回了数据,也会因为ID不匹配而被忽略 - 页面卸载时取消所有请求,避免内存泄漏
几个容易被忽略的细节
错误处理的边界情况
很多开发者只处理了onload,忘了onerror和ontimeout的优先级。XHR中,如果请求超时,ontimeout会触发,但onerror也可能同时触发(取决于浏览器)。需要明确区分:
xhr.ontimeout = () => {
// 超时,给用户友好提示
showTip('网络响应超时,请重试');
};
xhr.onerror = () => {
// 网络错误(断网、DNS失败等)
showTip('网络连接失败');
};
取消请求后要清理资源
取消请求只是中断了网络传输,但如果请求的回调里还引用了DOM元素或者定时器,这些资源不会自动释放。特别是如果你在用Vue/React,组件卸载时要记得取消请求,否则会发生状态更新在已卸载组件上的警告:
// React组件
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(data => {
if (!controller.signal.aborted) {
setData(data);
}
});
// 清理函数,组件卸载时执行
return () => controller.abort();
}, []);
并发请求的带宽竞争
浏览器对同一域名的并发连接数有限制(HTTP/1.1通常是6个)。如果一次性发太多请求,后面的请求会被排队,反而变慢。适当控制并发数,或者用HTTP/2来突破限制。
竞态条件这个东西,不炸的时候你觉得代码没问题,一炸就是数据全乱。我的建议是:凡是涉及并发请求的地方,从一开始就用AbortController管理,顺手加个requestId校验,成本不高,但能省去大量调试时间。
希望这些方案能帮到你。如果有具体的场景不确定怎么处理,可以留言,咱们一起分析。
