记得有一次,我们团队接到了一个看起来很简单的需求:在一个后台管理系统里,做一个“批量数据同步”的功能。前端需要一次性把几百条数据推到后端。开发同学很自信,觉得也就是几个循环发请求的事儿。结果上线那天,用户随便点一下按钮,整个浏览器直接假死,风扇狂转,Chrome 甚至弹出了“脚本运行时间过长”的警告框。
那天下午,我们对着一个看似无害的 forEach 循环,研究了整整四个小时。这就是今天要聊的话题——在高并发场景下,前端是如何被自己发出的请求“卡死”的,以及我们如何用队列和并发限制把页面从崩溃边缘拉回来。
为什么会“卡死”?先别急着怪服务器
很多人第一反应是:“后端扛不住了,所以前端卡了。” 其实不然。在现代前端架构中,真正的瓶颈往往在前端自身。
想象一下,如果你在一次点击事件中,用 for 循环一口气发起了 500 个 fetch 或 axios 请求,会发生什么?
浏览器是有“并发上限”的。虽然这个上限因浏览器和域名而异(通常在 6 到 10 个左右),但更重要的是,JavaScript 是单线程的。当你发起这 500 个请求时,浏览器需要为每一个请求维护网络连接、内存缓冲区、以及对应的回调函数栈。
更糟糕的是,如果这些请求使用了 async/await 且没有被正确拆分,或者你使用了 Promise.all 这种“全有或全无”的策略,浏览器会在极短的时间内堆积大量的待处理任务。这时候,主线程被这些 I/O 操作的网络回调和内存分配占满,UI 渲染(比如你看到的“加载中”动画)就被迫排队等待。结果就是:页面上看起来没动,但其实浏览器已经累瘫了。
我曾经见过一个案例,开发者在 Vue 的 created 钩子里直接写了一个 Promise.all 去拉取 300 个组件需要的数据。页面打开的一瞬间,CPU 占用率飙升到 100%,整个 tab 卡住不动,直到所有数据都拉回来才恢复响应。用户的感觉就是:“这个网站怎么这么卡?”
所以,问题的核心不在于请求本身,而在于“无序的、无限制的、同步阻塞式”的并发管理。
第一道防线:并发数限制(并发桶)
处理高并发请求,最经典且有效的策略就是控制并发数。这就好比银行柜台,不管前面排队的人有多少,同时只能有 3 个窗口在办理业务。后面的请求必须排队,等前面的办完了再上。
我们可以用一个简单的“桶”模型来实现这个逻辑。
思路解析
- 限制并发数:设定一个最大并发值(比如 5)。
- 维护一个活跃请求计数:每当发起一个新请求,计数 +1;每当请求结束,计数 -1。
- 任务队列:将所有需要执行的请求放入一个队列中。
- 循环调度:每当有一个请求位置空闲(计数 < 最大并发数),就从队列中取出下一个任务执行。
代码实现
下面是一个原生 JavaScript 实现的并发控制类,它不依赖任何第三方库,你可以直接复制使用:
class ConcurrencyLimiter {
constructor(maxConcurrency) {
// 最大并发数,默认为 5
this.maxConcurrency = maxConcurrency || 5;
// 当前正在进行的请求数量
this.currentConcurrency = 0;
// 任务队列,存储每一个需要执行的异步函数
this.queue = [];
}
/**
* 向队列中添加一个任务
* @param {Function} task - 一个返回 Promise 的函数
* @returns {Promise} 该任务执行完成的 Promise
*/
add(task) {
return new Promise((resolve, reject) => {
this.queue.push({
task,
resolve,
reject
});
// 每次添加任务后,尝试执行队列中的下一个任务
this.processNext();
});
}
/**
* 处理队列中的下一个任务
*/
processNext() {
// 如果当前并发数已经达到上限,或者队列为空,则停止执行
if (this.currentConcurrency >= this.maxConcurrency || this.queue.length === 0) {
return;
}
// 从队列头部取出一个任务
const { task, resolve, reject } = this.queue.shift();
// 增加并发计数
this.currentConcurrency++;
// 执行任务,并在结束后回调
// 注意:这里用 try-catch 包裹是为了兼容同步抛出错误的任务
try {
task().then(resolve, reject).finally(() => {
// 无论成功还是失败,并发计数减一
this.currentConcurrency--;
// 继续处理队列中的下一个任务
this.processNext();
});
} catch (error) {
// 如果任务同步抛出错误
this.currentConcurrency--;
reject(error);
this.processNext();
}
}
/**
* 获取队列中剩余的任务数
*/
get pendingCount() {
return this.queue.length;
}
/**
* 获取当前正在进行的任务数
*/
get activeCount() {
return this.currentConcurrency;
}
}
如何使用?
假设你有一组用户 ID,需要对每个 ID 发起一个更新请求:
const userIds = [101, 102, 103, 104, 105, 106, 107, 108]; // 假设有很多 ID
// 创建并发限制器,最大同时 3 个请求
const limiter = new ConcurrencyLimiter(3);
// 执行所有任务
const results = userIds.map(id => {
return limiter.add(() => {
console.log(`开始请求 ID: ${id}, 当前并发: ${limiter.activeCount}`);
// 模拟一个网络请求
return fetch(`/api/update-user/${id}`, { method: 'POST' })
.then(res => res.json());
});
});
// 等待所有任务完成(注意:这里的 Promise.all 是在并发控制之后)
Promise.all(results).then(finalResults => {
console.log('所有数据更新完成', finalResults);
}).catch(err => {
console.error('部分请求失败', err);
});
你看,即使 userIds 有 1000 个元素,同时发生的请求也永远不会超过 3 个。浏览器的压力被均匀分散到了整个时间段,而不是集中在某一个瞬间。
第二道防线:请求去重与缓存
有时候,卡顿不是因为并发数多,而是因为重复请求。比如在复杂的表单页面中,用户快速切换 tab,每个 tab 都会触发一次相同的数据加载。如果没有任何去重机制,这些重复的请求会像雪片一样飞出去,进一步加剧积压。
一个简单的去重 Map 可以解决这个问题:
const requestCache = new Map();
function deduplicatedFetch(url, options = {}) {
// 如果这个 URL 已经有请求在进行中,直接返回那个 Promise
if (requestCache.has(url)) {
return requestCache.get(url);
}
// 发起请求,并缓存 Promise
const promise = fetch(url, options)
.then(res => res.json())
.finally(() => {
// 请求结束后,从缓存中移除(避免内存泄漏)
requestCache.delete(url);
});
requestCache.set(url, promise);
return promise;
}
这个技巧在 Vue 或 React 的列表渲染中特别有用。当组件重新挂载时,如果数据已经在缓存中,就不会再发起多余的请求,从而减少队列的积压。
第三道防线:优先级队列与取消机制
在实际业务中,并不是所有的请求都同等重要。比如,页面的“核心数据”必须优先加载,而“相关推荐”、“历史记录”等可以稍后加载。如果让所有请求平级排队,用户体验会很差。
我们可以给任务添加优先级,并结合 AbortController 来取消不需要的请求。
优先级队列的实现思路
- 每个任务有一个优先级(数值越小,优先级越高)。
- 队列按优先级排序,高优先级的任务先被调度。
- 对于低优先级的任务,我们可以提供“取消”功能。
class PriorityQueueLimiter extends ConcurrencyLimiter {
add(task, priority = 5) {
// 这里简单起见,我们还是在普通队列中插入,但你可以扩展为真正的优先队列结构
// 为了演示取消机制,我们需要保存一个 abortController
const controller = new AbortController();
// 包装原始 task,传入 signal
const wrappedTask = () => task(controller.signal);
const promise = super.add(wrappedTask);
// 返回一个对象,包含取消方法
return {
promise,
cancel: () => {
if (!controller.signal.aborted) {
controller.abort();
console.log(`任务已取消`);
}
}
};
}
}
在实际使用中,用户可以取消低优先级的请求:
const highPriorityTask = limiter.add(() => fetch('/api/core-data'));
const lowPriorityTask = limiter.add(() => fetch('/api/suggestions'), priority: 10);
// 如果用户快速滚动离开当前页面,可以取消低优先级任务
lowPriorityTask.cancel();
这样,不仅控制了并发数,还避免了无效的网络请求占用宝贵的连接资源。
更现代的方案:利用浏览器原生能力
除了手写队列,现代浏览器和框架还提供了一些更高级的工具。
1. RxJS 操作符:concatMap, mergeMap, exhaustMap
如果你已经在项目中使用了 RxJS,那么这些操作符就是为你量身定制的。
concatMap: 按顺序执行,前一个完成后再执行下一个(并发数为 1)。mergeMap: 并发执行,但同时进行的数量没有限制(注意风险)。mergeMap(project, projectResultSelector, concurrency): 这个才是我们想要的! 它可以指定并发数。
import { from } from 'rxjs';
import { mergeMap, toArray } from 'rxjs/operators';
const userIds$ = from([101, 102, 103, 104, 105]);
userIds$.pipe(
mergeMap(id =>
fetch(`/api/update-user/${id}`).then(res => res.json()),
3 // 最大并发数为 3
),
toArray()
).subscribe(results => {
console.log('完成', results);
});
2. React 中的 useSuspend 或自定义 Hook
在 React 18 之前,我们通常用 useState 来管理请求状态,并结合上面的 ConcurrencyLimiter 类。但在 React 18 中,你可以考虑使用 useTransition 来将低优先级的更新标记为“非紧急”,让浏览器有喘息之机。
import { useState, useTransition } from 'react';
function MyComponent({ userIds }) {
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleLoad = async () => {
// 使用并发限制器
const limiter = new ConcurrencyLimiter(5);
const promises = userIds.map(id => limiter.add(() => fetchData(id)));
startTransition(() => {
// 这个更新会被视为低优先级,浏览器可以先处理其他任务(如动画、用户交互)
setResults(await Promise.all(promises));
});
};
return (
<div>
<button onClick={handleLoad}>加载数据</button>
{isPending && <span>加载中...</span>}
</div>
);
}
为什么“看起来简单”的代码会引发大问题?
回到我们最初的那个案例。那个开发同学写的代码大概是这样的:
async function syncAllData() {
const tasks = userList.map(async (user) => {
await fetch('/api/sync', { body: JSON.stringify(user) });
});
await Promise.all(tasks);
}
这段代码看起来很简洁,逻辑也没错。但它犯了两个错误:
- 没有并发限制:
Promise.all会同时发起所有请求。如果userList有 1000 人,就是 1000 个并发请求。 - 同步阻塞感:虽然
fetch是异步的,但Promise.all会让主线程在等待所有结果时处于“忙碌”状态,尤其是在解析响应体的时候。
更危险的是,如果某个请求失败,整个 Promise.all 就会 reject,其他已经成功的请求会被忽略,用户体验极差。
而我们的 ConcurrencyLimiter 方案,不仅限制了并发,还允许你分别处理每个请求的成功和失败,甚至可以设置超时、重试等策略。
总结:从“暴力并发”到“优雅调度”
处理前端高并发 AJAX 请求,核心思想不是“阻止请求”,而是“有序地请求”。
- 限制并发数:使用桶模型或 RxJS 操作符,将并发数控制在浏览器和服务器都能承受的范围内(通常 3-10 个)。
- 去重与缓存:避免重复请求浪费资源。
- 优先级管理:区分核心数据和非核心数据,允许取消低优先级任务。
- 利用现代 API:如
AbortController和useTransition,让浏览器更好地调度资源。
下次当你再写一个批量请求的逻辑时,不妨先问自己一句:“我一次发了多少个请求?浏览器承受得住吗?” 这个问题,可能比写出复杂的算法更能提升用户体验。
毕竟,前端开发的终极目标,不是让代码看起来很酷,而是让用户感觉“丝般顺滑”。
