你大概有过这种经历:页面上有个按钮,点击后需要一次性拉取用户信息、订单历史、推荐列表这三块数据。如果你按顺序写三个请求,用户得干等着;如果一股脑全发出去,页面可能直接卡死或者被浏览器拦截。这不仅仅是代码写得顺不顺的问题,而是涉及到底层网络协议、浏览器引擎设计哲学以及现代前端工程化的核心能力。
咱们今天不聊教科书式的定义,就聊聊在实际项目里,你是怎么被“并发限制”教做人,最后又是怎么优雅地解决它的。我会把 XMLHttpRequest(XHR)和 Fetch API 两种时代的经典坑都给你铺开,配合真实的代码和浏览器行为分析,让你彻底搞懂这块。
浏览器底层的“交通限流”:你以为你能无限并发?
首先得打破一个常见的误区:HTTP 协议本身并没有硬性规定“一个域名最多只能有 2 个并发连接”。这个限制是浏览器厂商为了公平起见,防止某个恶意的标签页把用户的带宽全吃光、导致其他标签页完全无法加载而设定的。
这就好比高速公路有 8 条车道,但你这个车道(域名)里的车不能超过一定数量,否则后面的车(其他请求)就得堵死。
不同的浏览器,不同的“路宽”
在 Chrome、Edge 以及大多数现代 Chromium 内核浏览器中,针对同一个源(Origin,即协议+域名+端口)的 TCP 连接并发限制通常是 6 个。这意味着,如果你在同一个页面上发起了 10 个请求,前 6 个会立刻进入“发送队列”,剩下的 4 个会进入“等待队列”,直到前面的某个请求返回或失败,后面的才会被调度出去。
而在老旧的 Firefox 或 IE 时代,这个限制曾是 2 个(这是最痛苦的经历,老项目维护者的噩梦)。即使到了现代,某些特定的 HTTP/2 多路复用场景下,浏览器的实现也会保守地限制并发连接数,通常在 6 到 100 之间浮动,具体取决于版本和配置。
实战影响:
当你写 for 循环发起 50 个 fetch 请求时,你以为它们在“同时”跑,但实际上只有前 6 个在真正占用网络带宽,其余 44 个都在排队。这不是代码 bug,这是浏览器为了保护你而设的护栏。
XMLHttpRequest 时代:回调地狱里的并发控制
在 2024 年的今天,虽然新项目很少直接用 XHR,但理解它至关重要。因为很多老旧的大型后台管理系统、ERP 系统还在用这套逻辑。更重要的是,XHR 的并发处理逻辑是理解后续 Promise 异步流的基础。
简单的并发陷阱
假设你要并发请求 5 个接口,传统的 XHR 写法大概是这样的:
const urls = ['/api/user', '/api/order', '/api/recommend', '/api/news', '/api/settings'];
urls.forEach(url => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true); // true 表示异步
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
console.log(`${url} 请求成功:`, xhr.responseText);
} else {
console.error(`${url} 请求失败:`, xhr.status);
}
};
xhr.onerror = function() {
console.error(`${url} 网络错误`);
};
xhr.send();
});
这段代码看着没问题吧?逻辑清晰,简单直接。但是,如果你把 urls 数组改成 20 个 URL 呢?
你会看到控制台瞬间刷出 6 条成功日志(因为浏览器限制了 6 个并发),然后其他 14 个请求处于 pending 状态。更糟糕的是,你无法知道这 20 个请求什么时候全部完成。XHR 没有原生的 Promise 支持,没有 Promise.all,你只能靠手动计数器来追踪:
const urls = ['/api/user', '/api/order', '/api/recommend', '/api/news', '/api/settings'];
let completedCount = 0;
const results = {};
urls.forEach((url, index) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
results[url] = xhr.responseText;
}
completedCount++;
// 关键判断:所有请求都完成了吗?
if (completedCount === urls.length) {
console.log('所有数据加载完毕,开始渲染页面', results);
}
};
xhr.onerror = function() {
console.error(`${url} 失败`);
completedCount++; // 失败也要计入,否则永远等不完
if (completedCount === urls.length) {
console.error('部分请求失败,但所有请求已响应');
}
};
xhr.send();
});
这个“手动计数器”模式就是早期前端工程师处理并发完成状态的标配。它丑陋、容易出错(比如忘记处理错误情况),但非常有效。
使用 AbortController 取消 XHR(现代浏览器支持)
在 XHR 的后期版本中,引入了 AbortController,让我们可以主动取消请求。这在并发控制中非常有用——比如用户快速点击了两次搜索按钮,第一次的请求可能还在路上,但已经没用了,我们需要取消它。
const controller = new AbortController();
const signal = controller.signal;
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/search?q=test', true);
// 监听中断事件
signal.addEventListener('abort', () => {
console.log('请求被取消');
});
xhr.onload = function() {
// 处理结果
};
xhr.send();
// 10秒后自动取消
setTimeout(() => {
controller.abort();
}, 10000);
Fetch API 时代:原生 Promise 带来的范式转变
Fetch API 的出现是前端异步编程的分水岭。它不再是回调函数嵌套,而是返回 Promise。这意味着我们可以直接使用 Promise.all、Promise.race 等强大的工具来管理并发。
基础并发:Promise.all
const urls = ['/api/user', '/api/order', '/api/recommend'];
Promise.all(urls.map(url => fetch(url)))
.then(responses => {
// responses 是一个数组,顺序与 urls 对应
return Promise.all(responses.map(res => res.json()));
})
.then(data => {
console.log('所有数据:', data);
})
.catch(error => {
console.error('至少有一个请求失败:', error);
});
Promise.all 的行为是:所有请求都成功,它才成功;只要有一个失败,它立刻 reject。这对于“整体加载”场景很有用,但如果有一个数据源偶尔不稳定,整个页面就可能白屏。
容错并发:Promise.allSettled
如果你更关心“所有请求是否都结束了”,而不关心中间有没有失败的,Promise.allSettled 是更好的选择。它会等待所有 Promise settle(无论成功还是失败),并返回每个请求的状态。
const urls = ['/api/user', '/api/order', '/api/recommend'];
Promise.allSettled(urls.map(url => fetch(url).then(res => res.json())))
.then(results => {
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`请求 ${urls[index]} 成功:`, result.value);
} else {
console.error(`请求 ${urls[index]} 失败:`, result.reason);
}
});
console.log('所有请求(无论成败)都已完成');
});
这种模式在生成报表、大数据看板等场景中非常常见——哪怕有一两个接口挂了,其他数据还是能渲染出来。
浏览器并发限制下的实战策略:分批与限流
既然浏览器限制了每个源最多 6 个并发连接,那如果我们真的有 100 个请求要发,怎么办?硬发?那 94 个都会在队列里干等,用户体验极差。
正确的做法是自定义并发控制,也就是我们常说的“信号量”(Semaphore)模式。
实现一个通用的并发控制器
我们可以写一个简单的工具函数,限制同时进行的 Promise 数量。
/**
* 并发控制器
* @param {Array} items - 需要处理的任务数组
* @param {Function} taskFn - 执行任务的函数,接收单个 item
* @param {number} concurrency - 最大并发数
* @returns {Promise<Array>} - 所有任务完成后的结果数组
*/
async function concurrencyLimit(items, taskFn, concurrency = 5) {
const results = new Array(items.length);
let currentIndex = 0;
// 辅助函数:处理单个任务
const processNext = async () => {
while (currentIndex < items.length) {
const index = currentIndex++;
const item = items[index];
try {
// 执行实际任务,传入 item 和 index
results[index] = await taskFn(item, index);
} catch (error) {
console.error(`任务 ${index} 失败:`, error);
results[index] = null; // 或者设为错误标记
}
}
};
// 启动最多 concurrency 个并行任务
const workers = [];
for (let i = 0; i < Math.min(concurrency, items.length); i++) {
workers.push(processNext());
}
await Promise.all(workers);
return results;
}
使用示例:
const urls = [
'/api/user', '/api/order', '/api/recommend', '/api/news',
'/api/settings', '/api/profile', '/api/billing', '/api/logs'
];
// 限制并发数为 3
concurrencyLimit(urls, (url) => {
return fetch(url).then(res => res.json());
}, 3)
.then(results => {
console.log('所有请求完成,结果:', results);
// 即使浏览器允许 6 个并发,我们也只让它用 3 个
// 这样可以避免占用过多带宽,给其他页面任务留出空间
});
为什么手动限制并发数比依赖浏览器默认值更好?
- 带宽管理:在移动网络或弱网环境下,一次性打满 6 个连接可能导致所有请求都变慢。限制并发可以让请求更平滑地完成。
- 用户体验:对于数据看板,你可能希望先渲染前 3 个关键数据,其余的后台加载。通过分批控制,你可以优先处理关键数据。
- 防止 API 限流:后端服务通常有 QPS(每秒查询率)限制。如果你的前端一次性发起 50 个请求,可能会触发后端的 429 Too Many Requests 错误。自定义并发可以避免这个问题。
高级策略:请求去重与缓存
在并发场景下,还有一个高频问题:重复请求。
假设用户快速点击了“刷新数据”按钮,触发了 5 个相同的请求。如果不对这些请求做去重,就会有多余的网络开销。更糟糕的是,如果这 5 个请求中有的快、有的慢,你可能用慢的结果覆盖了快的结果,导致数据不一致。
使用 Map 进行请求去重
const pendingRequests = new Map();
function deduplicatedFetch(url) {
if (pendingRequests.has(url)) {
return pendingRequests.get(url);
}
const promise = fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.finally(() => {
pendingRequests.delete(url); // 请求完成后移除,允许后续再次请求
});
pendingRequests.set(url, promise);
return promise;
}
这个简单的 deduplicatedFetch 函数确保了在同一个 URL 的请求未完成时,后续的请求不会重复发送,而是直接复用同一个 Promise。
结合并发控制器使用
const urls = ['/api/user', '/api/user', '/api/order', '/api/user']; // 有重复
concurrencyLimit(urls, (url) => deduplicatedFetch(url), 3)
.then(results => {
console.log('去重后的并发请求结果:', results);
// 结果数组长度与输入相同,但底层网络请求数减少了
});
AbortController 在并发取消中的妙用
在并发请求中,有时候我们需要“整体取消”或者“部分取消”。比如,用户在浏览数据列表时,快速切换到下一个页面,之前的数据请求应该被取消,以免浪费资源或更新已卸载组件的状态。
全局取消所有并发请求
class CancellableQueue {
constructor() {
this.controller = new AbortController();
this.pendingPromises = [];
}
addTask(taskFn) {
const taskPromise = taskFn(this.controller.signal);
this.pendingPromises.push(taskPromise);
return taskPromise;
}
cancelAll() {
this.controller.abort();
// 清理待完成的 Promise
this.pendingPromises = [];
}
async waitForAll() {
try {
return await Promise.all(this.pendingPromises);
} finally {
this.cancelAll(); // 确保清理
}
}
}
// 使用示例
const queue = new CancellableQueue();
// 添加 5 个并发任务
const tasks = [
queue.addTask(signal => fetch('/api/data1', { signal }).then(r => r.json())),
queue.addTask(signal => fetch('/api/data2', { signal }).then(r => r.json())),
queue.addTask(signal => fetch('/api/data3', { signal }).then(r => r.json())),
];
// 用户点击取消按钮
queue.cancelAll();
// 或者等待所有完成
queue.waitForAll().then(results => {
console.log('数据加载完成', results);
});
HTTP/2 与并发限制的变化
很多人以为升级到 HTTP/2 就可以无视并发限制了,这是错误的。
HTTP/2 引入了多路复用(Multiplexing),允许在单个 TCP 连接上同时传输多个请求和响应。理论上,这消除了 TCP 连接建立的开销,并且不再需要多个连接来隐藏延迟。
但是,浏览器的并发限制依然存在,只是限制的方式可能不同:
- 连接层面:Chrome 可能会为同一来源维持多个 HTTP/2 连接(通常 6 个),每个连接内部可以复用多个流。
- 流层面:HTTP/2 协议本身对单个连接内的并发流数也有默认限制(通常 100 个),但这远大于浏览器层面的连接限制。
实战意义:在 HTTP/2 环境下,由于连接复用效率高,你可能不需要像 HTTP/1.1 那样小心翼翼地限制并发数。因为即使有 100 个请求,它们可以在少数几个长连接上高效地并行传输,不会像 HTTP/1.1 那样造成队头阻塞(Head-of-line blocking)。
然而,带宽竞争依然存在。即使是 HTTP/2,过多的并发请求仍然会竞争带宽,导致每个请求的吞吐量下降。因此,合理的并发控制(如限制在 10-20 个)在现代 Web 开发中仍然是一个好习惯,尤其是在移动设备上。
真实场景案例:图片懒加载的并发控制
假设你在做一个电商详情页,需要加载 20 张商品图片。如果一次性发起 20 个 fetch 或 img 请求,可能会挤占其他关键资源(如 CSS、JS)的带宽。
方案一:简单的并发限制
const imageUrls = [
'https://example.com/img1.jpg',
'https://example.com/img2.jpg',
// ... 20 张图片
];
async function loadImages(images, concurrency = 4) {
const results = [];
const executing = [];
for (const imgSrc of images) {
const loadImg = () => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(imgSrc);
img.onerror = () => reject(new Error(`Failed to load ${imgSrc}`));
img.src = imgSrc;
});
};
const p = loadImg().then(() => {
executing.splice(executing.indexOf(p), 1);
});
executing.push(p);
if (executing.length >= concurrency) {
await Promise.race(executing);
}
}
await Promise.all(executing);
return results;
}
loadImages(imageUrls, 4).then(() => {
console.log('所有图片加载完成');
});
这个例子虽然用了 Image 对象而不是 fetch,但原理相同。限制并发数为 4,可以确保图片加载不会阻塞其他关键资源的下载。
方案二:结合 Intersection Observer 的分批加载
更高级的做法是结合滚动位置,只在图片进入视口时才发起请求,并且对发起的请求进行并发控制。
”`javascript function createLazyLoader(imageUrls, concurrency = 3) {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
observer.unobserve(img); // 停止观察,只加载一次
// 这里可以加入并发控制队列
loadImageWithConcurrency(img.src, concurrency);
}
});
});
imageUrls.forEach(src => {
const img = document.createElement('img');
img.dataset.src = src;
img.style.opacity = '0';
document.body.appendChild(img);
imageObserver.observe(img);
});
}
function loadImageWithConcurrency(src, concurrency) {
// 复用之前的 concurrencyLimit 逻辑
return concurrencyLimit([src], (url) => {
