说实话,我以前做项目的时候也踩过不少坑。那时候后端数据量大,前端需要一次性拉取好多接口数据拼成页面,我就傻乎乎地写了一堆 $.ajax 或者 fetch,结果页面加载的时候,浏览器直接卡死,甚至有的请求莫名其妙失败了,排查半天才发现是并发太多被浏览器限制住了。
今天就把这套东西掰开揉碎了讲清楚,咱不搞那些虚头巴脑的理论,直接上干货,顺便看看怎么优雅地解决这些问题。
浏览器对并发请求的“隐形天花板”
咱们先得明白一个问题:为什么不能无脑发请求?其实浏览器是有并发限制的,这主要是为了保护服务器,也避免客户端资源耗尽。
不同的浏览器、甚至同一个浏览器的不同域名下,限制都不太一样。一般来说:
- 同源限制:大多数现代浏览器对同一个域名(同协议+同域名+同端口)的并发连接数限制在 6 到 10 个之间。比如 Chrome 和 Firefox 通常限制为 6 个,IE 可能更少。
- 跨域请求:如果是不同的域名,限制会宽松一些,但也不是无限的。
这意味着什么?想象一下,你要加载一个商品详情页,需要同时请求:
- 商品信息
- 用户评价
- 推荐商品列表
- 商家信息
- 库存状态
- 优惠活动
- 物流信息
如果你一口气发出 7 个请求,前 6 个可能立马发出去,第 7 个就得排队等。更糟糕的是,如果你在一个页面上有几十个这样的组件,每个都自己发请求,那并发数瞬间就能爆表,导致请求被挂起、超时,甚至被浏览器直接拦截。
所以,控制并发数不是可选项,而是必选项。
如何实现并发控制?核心思路
解决并发控制,核心思路其实很简单:池化思想。
想象你是一个包工头,手里只有 6 个工人(浏览器并发限制),但有 100 个活要干。你不能让 100 个人同时开工,而是应该:
- 先让 6 个工人干活
- 每完成一个活,就派下一个工人去
- 直到所有活都干完
在前端开发里,我们可以用几种方式来实现:
方式一:使用现成的库(推荐生产环境用)
如果你不想重复造轮子,可以直接用成熟的库:
async.js 的 parallelLimit
const async = require('async');
const tasks = [
(callback) => fetchData('/api/user', callback),
(callback) => fetchData('/api/orders', callback),
(callback) => fetchData('/api/products', callback),
// ... 更多任务
];
async.parallelLimit(tasks, 6, (err, results) => {
if (err) {
console.error('部分请求失败', err);
}
console.log('所有数据获取完成', results);
});
Axios + p-limit(现代前端常用组合)
import pLimit from 'p-limit';
const limit = pLimit(6); // 同时最多 6 个并发
const tasks = [
() => axios.get('/api/user'),
() => axios.get('/api/orders'),
() => axios.get('/api/products'),
];
const promises = tasks.map(task => limit(task));
Promise.all(promises)
.then(results => console.log('全部完成', results))
.catch(err => console.error('部分失败', err));
方式二:手写一个并发控制器(理解原理必备)
如果你想彻底搞懂底层逻辑,或者项目里不能引入额外依赖,可以自己写一个:
class ConcurrentAjax {
constructor(maxConcurrent = 6) {
this.maxConcurrent = maxConcurrent;
this.running = 0;
this.queue = [];
}
request(config) {
return new Promise((resolve, reject) => {
// 将请求加入队列
this.queue.push({
config,
resolve,
reject
});
this.processQueue();
});
}
processQueue() {
// 如果当前运行数小于最大并发数,且队列不为空
while (this.running < this.maxConcurrent && this.queue.length > 0) {
const task = this.queue.shift();
this.running++;
// 执行请求
fetch(task.config.url, task.config.options)
.then(response => response.json())
.then(data => {
task.resolve(data);
})
.catch(error => {
task.reject(error);
})
.finally(() => {
this.running--;
this.processQueue(); // 处理下一个
});
}
}
}
// 使用示例
const controller = new ConcurrentAjax(5);
const urls = [
'/api/user',
'/api/orders',
'/api/products',
'/api/reviews',
'/api/shipping',
'/api/promotions'
];
const promises = urls.map(url =>
controller.request({
url,
options: { method: 'GET' }
})
);
Promise.all(promises)
.then(results => console.log('全部完成', results))
.catch(err => console.error('出错', err));
这个手写的例子虽然简单,但把核心逻辑讲得很清楚:队列 + 计数器。每次请求完成,计数器减一,然后从队列里取下一个请求,直到所有请求都完成。
回调冲突问题:如何保证“谁的钱进谁的口袋”
并发控制解决了“同时发几个”的问题,但还有一个坑:回调冲突。
想象一下,你发了 6 个请求,它们完成的顺序是不确定的。请求 A 可能最后完成,请求 B 可能最先完成。如果你在回调里直接操作 DOM 或者全局变量,很容易出现“张冠李戴”的情况。
比如:
- 请求 user 的数据,回调里把它显示在用户名位置
- 请求 orders 的数据,回调里把它显示在订单列表位置
如果 orders 先完成,但你在 user 的回调里操作了订单 DOM,那就乱套了。
解决方案一:Promise + 明确的返回值
使用 Promise 的话,每个请求的结果都会绑定到对应的 Promise 上,不会出现混淆:
const userPromise = fetch('/api/user').then(res => res.json());
const ordersPromise = fetch('/api/orders').then(res => res.json());
const productsPromise = fetch('/api/products').then(res => res.json());
Promise.all([userPromise, ordersPromise, productsPromise])
.then(([user, orders, products]) => {
// 这里 user、orders、products 都是对应请求的结果,不会搞混
renderUser(user);
renderOrders(orders);
renderProducts(products);
});
解决方案二:请求携带唯一 ID
如果你的场景比较复杂,比如请求是在循环中动态生成的,可以给每个请求带一个 ID,回调时通过 ID 匹配:
const pendingRequests = new Map();
function fetchWithId(id, url) {
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
fetch(url)
.then(res => res.json())
.then(data => {
const request = pendingRequests.get(id);
if (request) {
request.resolve(data);
pendingRequests.delete(id);
}
})
.catch(err => {
const request = pendingRequests.get(id);
if (request) {
request.reject(err);
pendingRequests.delete(id);
}
});
});
}
// 使用
fetchWithId('user-001', '/api/user')
.then(data => console.log('用户数据', data));
fetchWithId('order-002', '/api/orders')
.then(data => console.log('订单数据', data));
解决方案三:使用 AbortController 取消超时请求
超时问题也是并发请求中常见的坑。有的请求特别慢,拖慢整个页面。这时候需要能取消请求。
现代浏览器提供了 AbortController:
const controller = new AbortController();
const signal = controller.signal;
// 设置超时
const timeoutId = setTimeout(() => {
controller.abort(); // 取消请求
}, 5000); // 5秒超时
fetch('/api/slow-endpoint', { signal })
.then(res => res.json())
.then(data => {
clearTimeout(timeoutId);
console.log('数据', data);
})
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求已取消或超时');
} else {
console.error('其他错误', err);
}
});
在并发控制器里,你也可以结合超时机制:
class ConcurrentAjax {
constructor(maxConcurrent = 6, timeout = 5000) {
this.maxConcurrent = maxConcurrent;
this.timeout = timeout;
this.running = 0;
this.queue = [];
}
request(config) {
return new Promise((resolve, reject) => {
this.queue.push({
config: {
...config,
signal: new AbortController().signal
},
resolve,
reject,
timeoutId: null
});
this.processQueue();
});
}
processQueue() {
while (this.running < this.maxConcurrent && this.queue.length > 0) {
const task = this.queue.shift();
this.running++;
// 设置超时
task.timeoutId = setTimeout(() => {
// 超时取消请求
const controller = new AbortController();
// 注意:这里需要重新创建 controller 来 abort,因为原始 signal 已经传入了 fetch
// 实际使用中建议把 controller 存起来
task.config.signal = controller.signal;
controller.abort();
task.reject(new Error('请求超时'));
this.running--;
this.processQueue();
}, this.timeout);
fetch(task.config.url, {
...task.config.options,
signal: task.config.signal
})
.then(response => {
clearTimeout(task.timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then(data => task.resolve(data))
.catch(error => {
clearTimeout(task.timeoutId);
task.reject(error);
})
.finally(() => {
this.running--;
this.processQueue();
});
}
}
}
实战案例:商品详情页数据加载
让我给你讲一个真实的场景。我之前做电商项目,商品详情页需要加载:
- 商品基本信息(名称、价格、图片)
- 商品详情描述(富文本)
- 用户评价(分页,取前10条)
- 推荐商品(4个)
- 商家信息
- 库存状态
- 优惠活动
总共 7 个接口。如果无脑并发,在 Chrome 里前 6 个会立即发出,第 7 个排队。但问题是,有些接口响应很快(比如库存状态 200ms),有些很慢(比如评价接口可能要 2s)。
优化方案
async function loadProductData(productId) {
// 定义所有请求,带优先级
const requests = [
{ url: `/api/products/${productId}`, priority: 1, label: '基本信息' },
{ url: `/api/products/${productId}/details`, priority: 2, label: '详情描述' },
{ url: `/api/products/${productId}/reviews?limit=10`, priority: 3, label: '用户评价' },
{ url: `/api/products/${productId}/recommendations?limit=4`, priority: 4, label: '推荐商品' },
{ url: `/api/shops/${productId}`, priority: 5, label: '商家信息' },
{ url: `/api/stock/${productId}`, priority: 1, label: '库存状态' },
{ url: `/api/promotions/${productId}`, priority: 2, label: '优惠活动' }
];
const controller = new ConcurrentAjax(5, 8000); // 最多5个并发,8秒超时
const results = {};
// 按优先级排序,高优先级先入队
requests.sort((a, b) => a.priority - b.priority);
const promises = requests.map(req =>
controller.request({
url: req.url,
options: { method: 'GET' }
}).then(data => {
results[req.label] = data;
console.log(`${req.label} 加载完成`);
}).catch(err => {
console.warn(`${req.label} 加载失败`, err);
results[req.label] = null;
})
);
// 等待所有请求完成
await Promise.all(promises);
// 渲染页面
renderProductPage(results);
}
这个方案的好处:
- 并发可控:最多 5 个请求同时发送,避免浏览器限制
- 超时保护:每个请求 8 秒超时,不会无限等待
- 失败隔离:单个请求失败不影响其他请求
- 优先级:关键数据(基本信息、库存)优先加载
一些实战中的小技巧
1. 请求去重
有时候用户手抖点了两次“加载”,或者页面复用导致同一个请求发了两次。可以加个简单的缓存:
const requestCache = new Map();
function cachedFetch(url, options = {}) {
if (requestCache.has(url)) {
return requestCache.get(url);
}
const promise = fetch(url, options)
.then(res => res.json())
.catch(err => {
requestCache.delete(url); // 失败时清除缓存,允许重试
throw err;
});
requestCache.set(url, promise);
return promise;
}
2. 指数退避重试
网络不稳定时,直接失败体验不好。可以加重试:
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (err) {
if (i === maxRetries - 1) throw err;
// 指数退避:1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
}
}
}
3. 合并小请求
如果有很多小请求,比如加载 10 个用户的头像,可以合并成一个请求:
// 不要这样
users.forEach(user => fetch(`/api/avatar/${user.id}`));
// 应该这样
const userIds = users.map(u => u.id).join(',');
fetch(`/api/avatars?ids=${userIds}`)
.then(res => res.json())
.then(avatars => { /* 处理结果 */ });
总结
并发请求控制这事儿,说难不难,说简单也不简单。核心就三点:
- 控制并发数:用队列 + 计数器的模式,别让浏览器压力太大
- 避免回调冲突:用 Promise 或者带 ID 的请求,确保数据不张冠李戴
- 处理超时和失败:用 AbortController 取消超时请求,用重试机制提高成功率
我之前踩过的那些坑,现在回想起来,都是因为没有做好并发控制。希望这篇文章能帮你避开这些坑,写出更健壮的前端代码。
如果你在实际项目中遇到什么具体问题,欢迎留言讨论,咱们一起解决。毕竟,写代码这事儿,独行快,众行远嘛!
