咱们今天不聊虚的,直接切入正题。做前端开发的都知道,网络请求是页面的“血液”,而并发控制则是维持心脏跳动的节奏器。想象一下,如果你在页面上点击了一个按钮,触发了10个数据请求,结果这10个请求像无头苍蝇一样同时冲向服务器,不仅服务器可能扛不住,浏览器也会因为资源竞争导致页面卡顿甚至崩溃。
这就是为什么我们需要深入理解 XMLHttpRequest (XHR) 和 Fetch API 在处理并发时的细微差别,以及如何优雅地绕过浏览器的并发限制。我会结合真实的代码场景,把这些坑一个个填平,顺便给想学编程的小朋友打个样——毕竟,把复杂的事情讲简单,才是真本事。
浏览器的“隐形天花板”:为什么我们不能无限并发?
首先,得搞清楚一个核心概念:浏览器对同源(Same-Origin)请求是有并发限制的。
这不是浏览器故意刁难我们,而是出于稳定性的考虑。如果一个标签页瞬间发起几百个HTTP请求,会耗尽服务器的带宽,也会让浏览器的UI线程忙不过来,导致页面假死。
不同浏览器和不同协议的限制略有差异,但大体规律如下:
| 浏览器/协议 | 同源并发限制 (近似值) | 备注 |
|---|---|---|
| Chrome / Edge (HTTP/1.1) | 6 个 | 这是最常见的瓶颈 |
| Firefox (HTTP/1.1) | 6 个 | 早期版本较多,现代版本趋于一致 |
| Safari (HTTP/1.1) | 6 个 | 遵循 WebKit 标准 |
| HTTP/2 | 理论上无限制 | 依靠多路复用技术,但实际仍受连接数限制 |
关键点:如果你的网站还在用 HTTP/1.1,那么无论你怎么写代码,浏览器底层最多只会并行处理来自同一域名的 6 个请求。第 7 个请求必须排队等待前 6 个完成。
XMLHttpRequest (XHR):老当益壮的“老兵”
虽然 Fetch 和 Axios 很流行,但 XMLHttpRequest 依然是很多遗留系统的基石,也是理解异步请求底层逻辑的最佳教材。
1. XHR 的基本并发表现
在 XHR 中,每个请求实例都是独立的。如果你手动创建 10 个 XHR 对象并立即发送,浏览器会根据上述限制,只并行执行前 6 个,剩下的进入队列。
// 模拟10个并发请求
const urls = Array.from({ length: 10 }, (_, i) => `/api/data/${i}`);
urls.forEach((url, index) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true); // true 表示异步
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(`Request ${index + 1} completed`);
}
};
xhr.send();
});
现象分析: 当你运行这段代码时,你会发现控制台并不是同时打印出10条日志。浏览器会在后台默默管理这个队列。如果你需要在 XHR 层面实现更精细的控制(比如限制最大并发数为3),你需要自己维护一个“信号量”或者使用递归/循环调度。
2. XHR 的优势与劣势
优势:
- 进度监听:这是 XHR 的杀手锏。你可以实时知道上传或下载了多少字节。
xhr.upload.onprogress和xhr.onprogress提供了细粒度的控制。 - 兼容性:几乎支持所有浏览器,包括 IE6+。
- Abort 控制:可以通过
xhr.abort()随时取消请求。
- 进度监听:这是 XHR 的杀手锏。你可以实时知道上传或下载了多少字节。
劣势:
- 回调地狱:虽然可以使用 Promise 封装,但原生 API 基于事件驱动,代码可读性较差。
- 错误处理分散:需要区分
readyState变化和status错误,逻辑稍显繁琐。
Fetch API:现代开发的“新贵”
Fetch 是基于 Promise 的,语法更简洁,更符合现代 JavaScript 的开发习惯。它默认不使用 Cookie(除非设置 credentials: 'include'),并且错误处理机制与 XHR 有所不同。
1. Fetch 的并发表现
Fetch 同样受制于浏览器的并发限制。但由于它是基于 Promise 的,我们可以更容易地组合多个请求。
const urls = Array.from({ length: 10 }, (_, i) => `/api/data/${i}`);
// 直接并行发起
Promise.all(urls.map(url => fetch(url)))
.then(responses => Promise.all(responses.map(r => r.json())))
.then(data => console.log('All data loaded:', data))
.catch(err => console.error('Failed:', err));
注意:这里的 Promise.all 只是 JavaScript 层面的同步等待,它不会改变浏览器底层的并发行为。如果浏览器限制是6,那么这10个请求依然会分两批执行(先6后4)。
2. Fetch 的独特之处:AbortController
Fetch 引入了 AbortController 来取消请求,这是一个巨大的进步。
const controller = new AbortController();
const signal = controller.signal;
fetch('/api/data', { signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('Request aborted');
} else {
console.error('Fetch error:', error);
}
});
// 几秒后取消请求
setTimeout(() => {
controller.abort();
}, 2000);
3. Fetch vs XHR:谁更适合并发控制?
从代码结构上看,Fetch 更容易与现代异步编程模式(async/await, Promise 链式调用)结合,因此在实现自定义并发控制器时,Fetch 的代码通常比 XHR 更干净、更易读。
实战:如何手动实现并发限制控制器?
既然浏览器限制了最大并发数,而我们有时需要更灵活的控制(例如:限制最大并发数为 5,或者优先加载关键数据),我们就需要自己写一个“交通指挥官”。
下面是一个通用的并发限制工具函数,它既适用于 Fetch,也可以轻松适配 XHR。
场景设定
假设我们要从 API 获取 20 张图片的元数据,但我们希望同时只发起 3 个请求,避免占用过多带宽。
代码实现 (基于 Fetch)
/**
* 并发请求控制器
* @param {Array} tasks - 包含异步函数的数组
* @param {number} limit - 最大并发数
* @returns {Promise<Array>} - 所有任务完成后的结果数组
*/
async function runWithConcurrencyLimit(tasks, limit) {
const results = [];
const executing = []; // 当前正在执行的 Promise 列表
for (const task of tasks) {
// 创建一个任务包装器
const p = Promise.resolve().then(() => task());
results.push(p);
// 如果当前执行的任务数达到上限,则等待其中一个完成
if (limit <= results.length) {
// race 会让 promise 数组中第一个完成的 resolve/reject
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
}
return Promise.all(results);
}
// --- 使用示例 ---
// 模拟10个请求
const tasks = Array.from({ length: 10 }, (_, i) => {
return async () => {
console.log(`Start request ${i + 1}`);
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, Math.random() * 1000));
console.log(`End request ${i + 1}`);
return { id: i + 1, status: 'success' };
};
});
// 限制最大并发数为 3
runWithConcurrencyLimit(tasks, 3)
.then(allResults => {
console.log('All requests finished:', allResults.length);
});
代码解析(给小朋友看的版本)
想象一下,你是一个餐厅经理,厨房只有 3 个灶台(limit = 3)。
- 顾客下单:有 10 个顾客(
tasks)同时点菜。 - 分配灶台:你先让前 3 个顾客开始炒菜(执行前 3 个 Promise)。
- 等待空位:剩下的 7 个顾客必须在外面等着。
- 释放资源:一旦第一个顾客炒完菜(Promise resolve),灶台就空出来了。
- 接力:你立刻让下一个等待的顾客进来炒菜。
- 最终结果:所有 10 个顾客都吃上了饭,而且厨房从未超负荷运转。
这个算法的核心在于 Promise.race,它像一个裁判,只要有一个灶台空出来,就立刻安排下一道菜。
进阶策略:针对 XHR 的并发控制
如果你必须使用 XHR(比如在老旧项目中),实现方式类似,但需要手动管理 XHR 实例的生命周期。
function runXhrWithLimit(urls, limit) {
let index = 0;
let activeCount = 0;
const results = [];
function next() {
if (index >= urls.length || activeCount >= limit) {
return;
}
const url = urls[index++];
activeCount++;
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function() {
activeCount--;
results.push({ url, status: xhr.status, data: xhr.responseText });
next(); // 递归调用,尝试发起下一个请求
};
xhr.onerror = function() {
activeCount--;
results.push({ url, status: 'error' });
next();
};
xhr.send();
}
// 启动初始批次
for (let i = 0; i < limit; i++) {
next();
}
// 返回一个 Promise,在所有请求完成后 resolve
return new Promise((resolve) => {
const checkCompletion = setInterval(() => {
if (results.length === urls.length) {
clearInterval(checkCompletion);
resolve(results);
}
}, 100);
});
}
为什么不用 Promise.all 包裹 XHR?
因为 XHR 的 onload 是事件触发,而不是 Promise 的自然完成。使用递归调用的方式可以更直观地控制“队列”的流动。
浏览器限制之外的优化技巧
除了代码层面的并发控制,还有一些架构层面的策略可以缓解压力:
HTTP/2 多路复用: 如果可能,升级到 HTTP/2。它允许在一个 TCP 连接上并行发送多个请求,互不阻塞。这意味着浏览器的“6个请求限制”在 HTTP/2 下几乎不再存在,因为它们是复用在同一个连接上的流(Stream)。
DNS 预取与预连接: 使用
<link rel="dns-prefetch" href="//api.example.com">提前解析域名。 使用<link rel="preconnect" href="//api.example.com">提前建立 TCP 连接和 TLS 握手。 这样可以减少后续请求的建立时间,让并发请求更快地进入“数据传输”阶段。请求合并(Debouncing & Batching): 如果用户快速滚动列表,可能会触发多次“加载更多”的请求。使用防抖(Debounce)或节流(Throttle)来合并请求,或者使用 Intersection Observer 来按需加载,从根本上减少不必要的并发请求数量。
Service Worker 缓存: 对于静态资源或不常变化的数据,使用 Service Worker 进行缓存。这样大部分请求根本不需要发出网络请求,直接从本地读取,极大地降低了并发压力。
总结与建议
- 新项目首选 Fetch:语法简洁,原生支持
AbortController,易于与 Promise 生态集成。 - 需要进度监控选 XHR:如果你需要显示上传/下载的百分比进度条,XHR 依然是目前最可靠的选择(尽管 Fetch 也在逐步完善相关 API,但成熟度不如 XHR)。
- 永远不要信任浏览器的默认并发:即使浏览器允许 6 个并发,在高负载场景下,手动限制为 3-5 个通常能获得更好的用户体验和服务器稳定性。
- 代码即文档:在实现并发控制器时,加上清晰的注释,就像我给小朋友解释餐厅例子那样,让维护者也能一眼看懂你的意图。
希望这篇指南能帮你彻底搞定 AJAX 并发难题。记住,好的前端工程师不仅要写出能跑的代码,更要写出对服务器和用户友好的代码。如果有具体的业务场景需要定制并发策略,欢迎随时探讨!
