你有没有遇到过这种情况:点击一个“加载更多”或者“导出报表”的按钮,整个浏览器直接卡死,风扇狂转,甚至标签页直接白屏崩溃?这通常不是代码逻辑错了,而是你太“热心”了——一次性发起了太多网络请求,把浏览器和服务器都压垮了。
作为一名在这个领域摸爬滚打多年的开发者,我见过太多因为无视浏览器限制而导致的“生产环境事故”。今天,我们不讲枯燥的理论,而是像老朋友聊天一样,把这些坑填平,顺便聊聊怎么优雅地控制并发,让页面丝般顺滑。
为什么浏览器会“罢工”?先搞懂那些隐形天花板
很多人觉得,只要网速够快,我想发多少请求就发多少。这是一个巨大的误区。浏览器并不是为了让你随意轰炸服务器而设计的,它有一套严格的资源管理策略,尤其是针对同源请求的限制。
1. TCP连接数的硬性约束
这是最基础的物理限制。以Chrome为例(这也是目前市场占有率最高的浏览器),对于同一个域名(Origin),浏览器默认同时建立的TCP连接数是有上限的。
- HTTP/1.1:通常限制为 6个。这意味着,如果你在一个页面上同时发起7个对同一域名的XHR或Fetch请求,第7个请求必须等待前6个中有一个完成,才能建立连接。
- HTTP/2:虽然HTTP/2支持多路复用,理论上可以在一个连接上传输多个请求,但浏览器仍然会对每个域名的并发连接数保持一定的限制(通常是6-100个不等,取决于具体实现和配置),以防止单个域名占用过多带宽影响其他资源加载。
2. 内存与线程的消耗
每一个AJAX请求在JavaScript层面都会创建一个XMLHttpRequest对象或Promise对象。这些对象需要占用堆内存。更重要的是,如果请求涉及大量的数据处理、DOM更新或者复杂的回调函数,主线程会被阻塞。
当并发请求过多时:
- 事件循环(Event Loop)拥堵:大量的
onload、onreadystatechange回调堆积在任务队列中,主线程忙于处理这些回调,没有时间去响应用户的点击、滚动等操作。 - 内存泄漏风险:如果请求失败或未正确清理,旧的请求对象可能无法被垃圾回收,导致内存持续上升,最终触发浏览器的OOM(Out of Memory)崩溃。
3. 服务器的拒绝服务
除了浏览器端,服务端也有连接池限制。如果你的前端瞬间发送1000个请求,后端应用服务器(如Nginx, Tomcat, Node.js)的连接数可能会瞬间打满,导致502 Bad Gateway或504 Gateway Timeout错误。这不仅让用户看到报错,还可能触发服务端的熔断机制,导致整个服务不可用。
实战:如何优雅地控制并发?
既然知道了痛点,我们就来解决它。核心思路只有一个:限速。我们要像一个交通指挥官,而不是一个只会按喇叭的司机。
下面我将提供几种不同场景下的解决方案,从简单的队列到高级的并发控制器。
方案一:基于Promise的并发控制器(推荐大多数场景)
这是最通用、最优雅的解决方案。我们创建一个工具函数,允许用户指定最大并发数。当请求超过这个数量时,新的请求会自动进入等待队列,直到有位置空闲出来。
/**
* 并发请求控制器
* @param {Array} tasks - 包含返回Promise的函数数组
* @param {Number} concurrency - 最大并发数
* @returns {Promise<Array>} - 所有任务完成后的结果数组
*/
function limitConcurrency(tasks, concurrency) {
if (!tasks.length) return Promise.resolve([]);
const results = [];
let index = 0;
// 维护当前正在运行的任务数量
let runningCount = 0;
// 用于追踪哪些任务已经完成,以便resolve最终的Promise
const allTasksCompleted = new Promise((resolve) => {
const checkCompletion = () => {
if (results.length === tasks.length) {
resolve(results);
}
};
// 重写push方法,每完成一个任务就检查
results.push = function(...items) {
Array.prototype.push.apply(this, items);
checkCompletion();
return this.length;
};
});
const executeTask = async (task, taskIndex) => {
try {
// 执行具体的请求任务
const result = await task();
results[taskIndex] = result;
} catch (error) {
// 即使出错,也要记录结果,避免阻塞后续逻辑,或者根据业务需求决定是否抛出
console.error(`Task ${taskIndex} failed:`, error);
results[taskIndex] = null;
} finally {
runningCount--;
// 无论成功失败,都尝试启动下一个等待的任务
runNext();
}
};
const runNext = () => {
while (runningCount < concurrency && index < tasks.length) {
const task = tasks[index];
index++;
runningCount++;
// 异步执行,不阻塞当前循环
executeTask(task, index - 1);
}
};
// 启动初始任务
runNext();
return allTasksCompleted;
}
// --- 使用示例 ---
// 模拟10个API请求
const apiRequests = Array.from({ length: 10 }, (_, i) => {
return () => {
console.log(`Start request ${i + 1}`);
// 模拟网络延迟 1-3秒
const delay = Math.floor(Math.random() * 2000) + 1000;
return new Promise(resolve => {
setTimeout(() => {
console.log(`End request ${i + 1}`);
resolve({ id: i + 1, data: `Result ${i + 1}` });
}, delay);
});
};
});
// 设置最大并发数为3
limitConcurrency(apiRequests, 3)
.then(results => {
console.log('All requests finished:', results);
});
为什么这个方案好?
- 非阻塞:它不会冻结UI,因为所有的操作都是异步的。
- 可控性:你可以轻松调整
concurrency参数来适应不同的网络环境或服务器承受能力。 - 容错性:单个请求失败不会影响其他请求的执行。
方案二:基于RxJS的高级流控(适合复杂数据流)
如果你的项目已经引入了RxJS,那么利用其内置的操作符是更强大的选择。RxJS提供了mergeMap(以前叫flatMap)和concatMap等操作符,天生就是为处理异步流设计的。
import { from, of } from 'rxjs';
import { mergeMap, catchError } from 'rxjs/operators';
// 假设 fetchApi 是一个返回 Observable 的函数
const fetchApi = (id: number) => {
console.log(`Fetching ID: ${id}`);
return of({ id, data: `Data for ${id}` }).pipe(
// 模拟网络延迟
delay(1000),
catchError(err => {
console.error(`Error fetching ${id}`, err);
return of(null);
})
);
};
// 创建10个请求ID
const ids$ = from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
// 使用 mergeMap 控制并发数为 3
ids$.pipe(
mergeMap(id => fetchApi(id), 3) // 第二个参数就是并发限制
).subscribe({
next: (result) => console.log('Received:', result),
complete: () => console.log('All streams completed!')
});
关键点解析:
mergeMap(project, concurrent):这里的concurrent参数就是我们需要的并发限制。它会在内部维护一个计数器,只有当活跃的子Observable数量小于concurrent时,才会订阅新的源数据。- 响应式优势:如果后续你需要添加重试机制、去重、防抖等功能,RxJS的管道式处理会让代码非常清晰。
方案三:简单的队列模式(串行或轻度并行)
如果业务逻辑要求严格的前后依赖,或者只需要简单的顺序执行,可以使用一个简单的队列。
class RequestQueue {
constructor(concurrency = 5) {
this.queue = [];
this.running = 0;
this.concurrency = concurrency;
}
add(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject });
this.process();
});
}
process() {
if (this.running >= this.concurrency || this.queue.length === 0) {
return;
}
const { task, resolve, reject } = this.queue.shift();
this.running++;
task()
.then(res => resolve(res))
.catch(err => reject(err))
.finally(() => {
this.running--;
this.process(); // 任务完成后,继续处理队列中的下一个
});
}
}
// 使用示例
const queue = new RequestQueue(2); // 最大并发2
const promises = [1, 2, 3, 4, 5].map(id => {
return queue.add(() => {
console.log(`Start ${id}`);
return new Promise(resolve => setTimeout(() => {
console.log(`Done ${id}`);
resolve(id);
}, 1000));
});
});
Promise.all(promises).then(results => {
console.log('Final Results:', results);
});
进阶技巧:不仅仅是并发控制
除了控制并发数量,还有一些其他技巧可以显著提升用户体验和系统稳定性。
1. 请求去重(Request Deduplication)
在SPA(单页应用)中,由于路由切换或组件重新渲染,可能会在短时间内发出完全相同的请求。例如,用户快速点击“刷新”按钮,或者两个组件同时请求用户信息。
我们可以实现一个简单的缓存层:
const requestCache = new Map();
function deduplicatedFetch(url, options = {}) {
// 生成唯一的缓存键,可以根据URL和options生成
const cacheKey = `${url}_${JSON.stringify(options)}`;
if (requestCache.has(cacheKey)) {
console.log('Cache hit:', cacheKey);
return requestCache.get(cacheKey);
}
const promise = fetch(url, options)
.then(response => response.json())
.catch(error => {
requestCache.delete(cacheKey); // 失败则删除缓存,允许重试
throw error;
});
requestCache.set(cacheKey, promise);
// 可选:设置过期时间,定期清理缓存
// setTimeout(() => requestCache.delete(cacheKey), 60000);
return promise;
}
2. 取消未完成的请求
当用户快速切换页面或关闭模态框时,之前发出的请求可能已经不再需要了。继续处理这些请求的结果不仅浪费资源,还可能导致状态不一致(比如A页面请求的数据渲染到了B页面)。
现代浏览器推荐使用AbortController:
let controller;
function fetchDataWithAbort(url) {
// 如果存在之前的请求,先取消
if (controller) {
controller.abort();
}
controller = new AbortController();
const signal = controller.signal;
return fetch(url, { signal })
.then(res => res.json())
.catch(err => {
if (err.name === 'AbortError') {
console.log('Request was cancelled');
} else {
throw err;
}
});
}
// 在组件卸载或切换时调用
// controller.abort();
3. 分页加载与虚拟列表
如果数据量巨大,不要一次性拉取所有数据。采用分页(Pagination)或无限滚动(Infinite Scroll)的方式,每次只加载可见部分或下一页数据。配合虚拟列表(Virtual List)技术,只渲染可视区域内的DOM节点,极大减轻浏览器压力。
给小朋友也能听懂的比喻
想象一下,你(浏览器)是一个小厨师,服务器是一个大仓库。
- 并发无限制:就像你一下子派出了100个小助手去仓库搬货。结果仓库门口堵死了,小助手们挤在一起谁也进不去,最后大家都累趴下了(页面卡死)。
- 并发控制:你只派3个小助手去。他们进去搬一箱货回来,你再派下一个。这样仓库门口畅通无阻,你的厨房(页面)也能有条不紊地处理食材,做出美味的蛋糕(展示内容)。
- 请求去重:如果两个小助手同时想去搬同一箱货,你告诉其中一个:“别去了,那个已经在路上了。” 省下的力气可以去搬别的货。
- 取消请求:如果你突然决定不做蛋糕了,赶紧喊停那些还在路上的小助手,让他们别白费力气了。
总结与建议
前端并发请求导致的卡顿问题,本质上是资源竞争和同步阻塞的问题。解决它需要我们从以下几个维度入手:
- 理解限制:清楚浏览器的TCP连接数和内存限制。
- 实施控制:使用并发控制器(如
limitConcurrency或RxJS的mergeMap)来限制同时进行的请求数量。 - 优化体验:结合请求去重、取消机制和分页加载,减少不必要的网络开销和DOM操作。
- 监控反馈:在生产环境中,监控请求失败率和页面性能指标,及时发现潜在的并发瓶颈。
记住,好的前端体验不是“最快”地拿到所有数据,而是“最流畅”地呈现用户需要的内容。通过合理的异步处理和并发控制,我们可以让网页像流水一样自然运作,而不是像堵车一样让人抓狂。
希望这篇文章能帮你彻底解决并发请求带来的困扰!如果有具体的代码问题,欢迎随时交流。
