嘿,朋友,你是不是也曾在项目开发中遇到过这种令人抓狂的场景:前端页面上同时发起了十几个请求,结果浏览器直接“罢工”,后面的请求一直卡在 pending 状态,数据就是加载不出来,或者页面渲染卡顿得像在看 PPT 一帧一帧地动?
别急着骂浏览器不行,这其实是一个经典的“并发限制”问题。今天咱们就坐下来,像老朋友聊天一样,把这背后的原理掰开揉碎了讲清楚,顺便给你几个在实战中真正能用的解决方案。
为什么浏览器要限制并发?这背后的“交通法规”
首先,你得理解,浏览器限制并发并不是为了刁难开发者,而是为了保护你和用户的设备资源。
想象一下,你的浏览器是一个繁忙的十字路口,而网络请求就是在这里穿梭的车辆。如果所有的车都同时涌入路口,会发生什么?交通瘫痪,对吧?同样的,如果浏览器同时向同一个域名发起几十上百个 HTTP 请求,服务器的带宽、内存、连接池都会瞬间被打满,网络链路也会拥堵。
更具体地说,现代浏览器(如 Chrome、Firefox、Safari)都遵循着一个古老的 RFC 规范限制。对于 HTTP/1.1,大多数浏览器对同一个域名的并发连接数限制在 6 个左右。这意味着,即使你写了 100 个 fetch 或者 $.ajax,在 HTTP/1.1 环境下,同一时刻只有前 6 个请求在发送,剩下的 94 个都在排队。
到了 HTTP/2,虽然引入了多路复用(Multiplexing),理论上可以在一个 TCP 连接上并行传输多个数据流,但浏览器依然会保守地限制并发连接数,通常在 10-100 之间,具体取决于浏览器版本和设置。
所以,所谓的“并发限制”,本质上就是浏览器和网络协议共同制定的一道“流量闸”。作为开发者,我们不仅要知其然,还要知其所以然,这样才能在必要时绕过它,或者更优雅地顺应它。
深入底层:XMLHttpRequest 的那些“老规矩”
虽然现在的开发趋势已经转向了 Fetch API 和 Axios,但 XMLHttpRequest(XHR)依然是很多老项目的基石,甚至在一些特定场景下(比如上传进度监控、大文件分片),它依然是不可替代的。
XHR 的并发瓶颈
在早期的 Web 开发中,XHR 的并发问题尤为明显。因为 XHR 是基于线程设计的,每一个 XHR 实例在发起请求时,浏览器都会为其分配一定的资源。如果短时间内创建大量 XHR 对象,不仅会触发浏览器的并发限制,还可能导致主线程阻塞,引起页面假死。
举个例子,假设你正在开发一个图片画廊,有 50 张图片需要预加载。如果你用 for 循环直接发起 50 个 XHR 请求:
for (let i = 0; i < 50; i++) {
const xhr = new XMLHttpRequest();
xhr.open('GET', `/images/pic${i}.jpg`);
xhr.send();
}
在 Chrome 中,你打开 DevTools 的 Network 面板,会发现前 6 个请求立即进入“Pending”或“Loading”状态,而剩下的 44 个请求会一直排队,直到前面的请求完成。这不仅仅是性能问题,更是用户体验的灾难——用户会看到屏幕一片空白,直到所有图片都加载完才一次性显示,或者闪烁得非常厉害。
如何解决 XHR 的并发问题?
对于 XHR,最直接的思路是串行化或者控制并发池。串行化最简单,但最慢,因为下一个请求必须等上一个完成才能开始。而控制并发池则是更优雅的方案:我们维护一个“信号量”,允许最多 N 个请求同时存在,当有请求完成时,再从队列中取出下一个请求。
下面是一个用原生 XHR 实现的并发控制器示例:
function createXHRPool(requests, concurrency = 6) {
let index = 0;
let activeCount = 0;
const results = [];
return new Promise((resolve) => {
function startNext() {
// 如果所有请求都已完成,或者当前活跃请求数达到上限,则停止
if (index >= requests.length || activeCount >= concurrency) {
if (activeCount === 0) {
resolve(results);
}
return;
}
const req = requests[index++];
activeCount++;
const xhr = new XMLHttpRequest();
xhr.open('GET', req.url);
xhr.onload = function() {
activeCount--;
results.push({ url: req.url, status: xhr.status, data: xhr.responseText });
startNext(); // 触发下一个请求
};
xhr.onerror = function() {
activeCount--;
results.push({ url: req.url, status: 'error', data: null });
startNext(); // 即使出错,也要触发下一个
};
xhr.send();
}
// 初始启动
startNext();
});
}
// 使用示例
const imageUrls = Array.from({ length: 50 }, (_, i) => `/images/pic${i}.jpg`);
createXHRPool(imageUrls.map(url => ({ url })), 6)
.then(results => console.log('所有图片加载完成', results));
这段代码的核心在于 startNext 函数。它就像一个调度员,每次只放行 6 个请求(concurrency 参数),每当一个请求完成(onload)或失败(onerror),活跃计数减一,然后立即调度下一个。这样,你既能充分利用浏览器的并发能力,又不会把服务器压垮,也不会让浏览器卡死。
Promise 与异步编程:现代开发的“指挥官”
既然提到了 XHR 的繁琐,我们就不得不聊聊现代 JavaScript 的福音——Promise。Promise 让异步代码变得可读,也让并发控制变得更加直观和强大。
原生 Promise.all 的陷阱
很多初学者会用 Promise.all 来并行执行多个请求,觉得这样最省事:
const urls = ['/api/user', '/api/posts', '/api/comments'];
Promise.all(urls.map(url => fetch(url)))
.then(results => console.log(results))
.catch(err => console.error(err));
这在请求数量很少(比如 3 个)时完全没问题。但是,如果 urls 数组有 100 个元素呢?Promise.all 会尝试同时发起 100 个请求。如前所述,浏览器只会执行前 6 个,剩下的会排队。更重要的是,如果其中任何一个请求失败,Promise.all 会立即拒绝,导致其他已经成功的请求结果也被丢弃,或者你需要额外的逻辑来捕获和处理部分成功的情况。
这就是为什么我们需要更精细的并发控制工具。
实现一个通用的并发控制器
我们可以封装一个工具函数,类似于前面 XHR 的例子,但这次我们用更现代的 Async/Await 和 Promise 组合来实现。这个函数可以接收任意数量的异步任务,并限制同时执行的数量。
async function concurrentAsync(tasks, concurrency) {
const results = [];
const executing = new Set();
for (const task of tasks) {
// 创建一个新的 Promise 来执行当前任务
const p = task().then(res => {
executing.delete(p);
return res;
});
results.push(p);
// 如果当前执行中的任务数达到了限制,就等待其中一个完成
if (executing.size >= concurrency) {
await Promise.race(executing);
}
executing.add(p);
}
return Promise.all(results);
}
// 使用示例:假设我们有 20 个图片加载任务
const loadImages = () => {
const urls = Array.from({ length: 20 }, (_, i) => `/api/image/${i}`);
return concurrentAsync(
urls.map(url => () => fetch(url).then(res => res.json())),
5 // 限制并发为 5
);
};
这段代码的精妙之处在于 Promise.race(executing)。executing 集合里装的是当前正在运行的 Promise。当并发数达到上限时,我们调用 Promise.race,它会等待集合中第一个完成的 Promise。一旦有一个完成,executing 集合的大小就减小了,循环就会继续,推入下一个任务。
这种模式非常灵活,它不仅适用于 fetch,也适用于任何返回 Promise 的异步操作,比如数据库查询、API 调用,甚至是复杂的计算任务。
Fetch API 实践:从入门到进阶
Fetch API 是目前浏览器原生支持的现代 HTTP 请求方式,它比 XHR 更简洁,基于 Promise,并且已经被广泛支持。在实际项目中,我们几乎都会用到它。
基础用法与错误处理
很多开发者在使用 Fetch 时忽略了一个重要的陷阱:Fetch 只有在网络故障或请求被中断时才会 Reject,而对于 HTTP 错误状态码(如 404、500),Fetch 依然会 Resolve。这意味着你需要手动检查 response.ok。
async function safeFetch(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(`Failed to fetch ${url}:`, error);
return null;
}
}
这个 safeFetch 函数是一个很好的实践模板。它封装了错误处理,让调用方不需要每次都要写 try-catch。在你的项目中,你可以把它当作一个基础工具函数来使用。
并发控制的高级场景:动态数据流
有时候,我们的请求并不是静态的。比如,你需要先获取一个用户列表,然后根据每个用户 ID 去获取他们的详细信息。这种场景下,并发控制就更重要了。
假设你有 100 个用户,如果一次性发起 100 个请求获取详情,服务器可能扛不住。我们可以分两阶段处理:
- 首先并发获取用户列表(数量少,风险低)。
- 然后对每个用户 ID,使用并发控制器获取详情。
async function loadUserDetails() {
// 第一步:获取用户列表
const usersResponse = await fetch('/api/users');
const users = await usersResponse.json();
// 第二步:并发获取每个用户的详情,限制并发数为 10
const concurrencyLimit = 10;
const userDetailsPromises = users.map(user =>
() => fetch(`/api/users/${user.id}/details`).then(res => res.json())
);
const details = await concurrentAsync(userDetailsPromises, concurrencyLimit);
return details;
}
这里我们复用了前面定义的 concurrentAsync 函数。这种写法非常清晰:逻辑被分成了明确的步骤,并发限制也一目了然。
取消请求:AbortController
除了并发控制,现代 Fetch 还有一个强大的特性:请求取消。在旧版的 XHR 中,取消请求需要调用 xhr.abort(),但在 Promise 和 Fetch 的链式调用中,取消操作往往比较麻烦。现在,我们可以使用 AbortController。
async function fetchWithCancel(url) {
const controller = new AbortController();
const signal = controller.signal;
// 2秒后自动取消
const timeoutId = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(url, { signal });
clearTimeout(timeoutId);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was aborted');
} else {
console.error('Fetch failed:', error);
}
}
}
这个功能在处理大型数据加载、用户快速切换页面或者防止重复点击导致的多重请求时非常有用。你可以把它和并发控制器结合起来,为每个并发任务都设置一个超时取消机制,从而构建出一个非常健壮的网络请求层。
实战案例:一个图片画廊的优化
让我们把前面学到的知识整合起来,解决一个实际的案例。假设你要开发一个图片画廊,用户打开页面时,需要加载 100 张高分辨率图片。
问题: 直接发起 100 个请求会导致浏览器卡顿,页面白屏。
解决方案:
- 使用懒加载:只有当图片进入视口时才加载。
- 并发控制:对于需要预加载的图片,限制并发数为 6-10。
- 使用 Intersection Observer:现代浏览器提供了
IntersectionObserverAPI,可以高效地检测元素是否进入视口。
下面是一个完整的实现思路:
class ImageGallery {
constructor(containerSelector, imageConfig) {
this.container = document.querySelector(containerSelector);
this.images = imageConfig; // 假设是 [{ src: '...', alt: '...' }, ...]
this.concurrency = 6;
this.loadQueue = [];
}
init() {
// 创建图片占位符
this.images.forEach((img, index) => {
const imgEl = document.createElement('img');
imgEl.dataset.src = img.src; // 懒加载源
imgEl.alt = img.alt;
imgEl.className = 'lazy';
this.container.appendChild(imgEl);
// 加入加载队列
this.loadQueue.push({ el: imgEl, index: index });
});
// 使用 IntersectionObserver 监听懒加载
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const item = this.loadQueue.find(i => i.el === entry.target);
if (item) {
obs.unobserve(entry.target);
this.loadImage(item);
}
}
});
}, { rootMargin: '50px' }); // 提前 50px 开始加载
this.container.querySelectorAll('img.lazy').forEach(el => observer.observe(el));
}
loadImage({ el, index }) {
// 这里可以使用并发控制器,确保同一时间只有少量请求在进行
// 为了简化,我们直接在这里调用一个带并发控制的函数
this.loadWithConcurrency(el, this.images[index].src);
}
loadWithConcurrency(imgEl, url) {
// 复用我们的 concurrentAsync 逻辑,但这里我们只处理单个请求的队列管理
// 实际上,更好的做法是维护一个全局的并发队列
// 这里为了演示,我们假设有一个全局的队列管理器
// 简单起见,我们直接 fetch,但在实际项目中,你应该包装一层并发控制
fetch(url)
.then(res => res.blob())
.then(blob => {
const objectUrl = URL.createObjectURL(blob);
imgEl.src = objectUrl;
imgEl.classList.remove('lazy');
imgEl.classList.add('loaded');
})
.catch(err => {
console.error('Failed to load image', err);
imgEl.classList.add('error');
});
}
}
// 使用
const gallery = new ImageGallery('#gallery-container', [
{ src: '/images/1.jpg', alt: 'Image 1' },
{ src: '/images/2.jpg', alt: 'Image 2' },
// ... 100 个图片配置
]);
gallery.init();
在这个案例中,我们并没有对所有 100 个图片同时发起请求,而是利用 IntersectionObserver 实现了懒加载。只有当图片即将进入视口时,才会触发加载。这样,即使用户快速滚动,浏览器的并发压力也被大大降低了,因为同一时间只有少数几个在视口边缘的图片需要加载。
如果你确实需要预加载一批图片(比如当前视口内的所有图片),你可以结合前面的 concurrentAsync 函数,只预加载视口内的图片,而不是全部 100 张。
总结与最佳实践建议
回顾一下,我们今天讨论了浏览器 AJAX 并发限制的多个层面:
- 理解限制:浏览器对同一域名的并发连接数有限制(HTTP/1.1 约 6 个,HTTP/2 更多但仍有上限),这是为了资源保护。
- XHR 时代:虽然老旧,但通过手动管理并发池,依然可以高效工作。核心是“信号量”模式,控制同时进行的请求数。
- Promise 与现代异步:
Promise.all适合少量并行,但大量请求时需要自定义并发控制器。我们提供了一个基于Promise.race和集合管理的通用并发控制器。 - Fetch API:原生支持且更现代,结合
AbortController可以实现更精细的请求管理(如超时、取消)。 - 实战应用:懒加载和并发控制相结合,是解决图片画廊等场景性能问题的黄金组合。
给你的几点建议:
- 不要盲目使用
Promise.all:当请求数量超过 10 个时,考虑使用并发控制器,避免瞬时压力。 - 善用懒加载:对于图片、视频等重型资源,懒加载是减少初始并发压力的最有效手段。
- 监控与调试:使用浏览器的 DevTools Network 面板,观察请求的排队情况。如果看到大量请求处于“Pending”状态,说明并发限制正在起作用,考虑优化策略。
- 保持代码简洁:将并发控制逻辑封装成独立的工具函数,不要散落在业务代码中。这样既易于维护,也便于在不同项目中复用。
希望这篇文章能帮你彻底搞定浏览器 AJAX 并发问题。记住,理解底层原理是写出高性能代码的第一步。如果你在实践中遇到任何奇怪的问题,欢迎随时回来查阅这些方案,或者进一步探索现代前端框架(如 React、Vue)中自带的请求管理库,它们通常已经内置了更高级的并发和缓存策略。
祝编码愉快,页面流畅!
