说实话,这个问题我见过太多次了,很多开发者第一次遇到时都懵圈。前端页面突然不响应,接口报错,日志里还找不到明确原因,最后发现居然是因为请求太多被浏览器”劝退”了。今天咱们就聊聊这个坑,以及如何优雅地避开它。
浏览器并发限制的真相
首先得明白,浏览器对并发请求是有硬性限制的。不同浏览器、不同域名的限制也不一样:
| 浏览器 | 同域名最大并发 | 总并发限制 |
|---|---|---|
| Chrome | 6 | 约100-200 |
| Firefox | 6 | 约100-200 |
| Safari | 4-6 | 约100-200 |
| Edge | 6 | 约100-200 |
注意看,同域名限制通常是6个,这意味着如果你在一个页面上发起超过6个针对同一域名的请求,后面的请求会排队等待。这不是bug,这是浏览器的安全机制,防止页面占用过多资源。
常见问题表现
让我先说说典型的症状,你看看有没有遇到过:
现象一:请求莫名失败
// 你发起了10个请求,结果只有6个成功,剩下4个根本没发出去
fetch('/api/data1').then(...)
fetch('/api/data2').then(...)
// ... 到第10个
现象二:页面加载慢得离谱
// 你做了个数据看板,一次性请求了20个接口
// 结果用户等待时间从1秒变成10秒
// 因为后面的请求都在排队
现象三:偶尔成功,偶尔失败
// 同一个页面,有时候正常,有时候请求不全
// 这是因为浏览器会动态调整并发队列
排查步骤(实战角度)
遇到这个问题,别急着改代码,先诊断:
第一步:检查网络面板
打开Chrome DevTools -> Network,刷新页面,你会看到:
- 请求状态为”(pending)” - 这说明请求在排队,还没发出去
- 请求没有进入”in-flight”状态 - 并发限制生效了
- 请求数远超6个但只有6个同时进行时 - 确诊
第二步:分析请求来源
常见原因有这些:
// 1. 组件重复渲染导致重复请求
useEffect(() => {
fetchData() // 每次组件渲染都发请求,可能触发多次
}, [])
// 2. 多个组件独立请求同一接口
// 组件A请求 /api/user
// 组件B也请求 /api/user
// 组件C也请求 /api/user
// 3. 轮询/定时请求叠加
setInterval(() => {
fetch('/api/data') // 每个定时器都在并发队列里占位
}, 1000)
第三步:查看是否有CORS预检请求占用名额
// OPTIONS /api/data <-- 这个CORS预检请求也会占用并发名额!
// 如果你有很多跨域请求,预检请求会挤占正常的请求名额
解决方案(代码级)
方案一:请求队列管理(最推荐)
写一个带并发限制的请求队列:
class RequestQueue {
constructor(maxConcurrent = 6) {
this.maxConcurrent = maxConcurrent
this.running = 0
this.queue = []
}
async add(requestFn) {
return new Promise((resolve, reject) => {
this.queue.push({
requestFn,
resolve,
reject
})
this.process()
})
}
async process() {
if (this.running >= this.maxConcurrent || this.queue.length === 0) {
return
}
this.running++
const task = this.queue.shift()
try {
const result = await task.requestFn()
task.resolve(result)
} catch (error) {
task.reject(error)
} finally {
this.running--
this.process() // 处理下一个
}
}
}
// 使用示例
const queue = new RequestQueue(4) // 限制并发为4
// 所有请求都通过队列发送
const results = await Promise.all([
queue.add(() => fetch('/api/user1').then(r => r.json())),
queue.add(() => fetch('/api/user2').then(r => r.json())),
queue.add(() => fetch('/api/user3').then(r => r.json())),
queue.add(() => fetch('/api/user4').then(r => r.json())),
queue.add(() => fetch('/api/user5').then(r => r.json())),
queue.add(() => fetch('/api/user6').then(r => r.json())),
queue.add(() => fetch('/api/user7').then(r => r.json())), // 这个会排队
])
方案二:使用Promise.allSettled分批处理
async function fetchWithConcurrencyLimit(urls, limit = 6) {
const results = []
for (let i = 0; i < urls.length; i += limit) {
const batch = urls.slice(i, i + limit)
const batchResults = await Promise.allSettled(
batch.map(url =>
fetch(url).then(res => res.json())
)
)
results.push(...batchResults)
}
return results
}
// 使用
const urls = ['/api/data1', '/api/data2', ...] // 20个URL
const results = await fetchWithConcurrencyLimit(urls, 6)
方案三:React Query / SWR 缓存去重
如果你用React,强烈建议用这些库:
import { useQuery } from '@tanstack/react-query'
// 自动去重,相同请求只发一次
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/user/${userId}`).then(r => r.json())
})
// 并发限制也是内置的,不用自己管
方案四:优化请求设计
有时候问题出在架构上:
// 不好的做法:N个接口
for (let i = 0; i < 20; i++) {
fetch(`/api/items/${i}`)
}
// 好的做法:批量接口
fetch('/api/items?ids=1,2,3,4,5...')
// 或者:分页加载
const loadInBatches = async (ids, batchSize = 10) => {
const results = []
for (let i = 0; i < ids.length; i += batchSize) {
const batch = ids.slice(i, i + batchSize)
const data = await fetch(`/api/items?ids=${batch.join(',')}`).then(r => r.json())
results.push(...data)
}
return results
}
真实案例分析
去年我帮一个客户排查问题,他们的后台管理系统有个报表页面,一次性发起15个请求。用户反馈有时候能加载出来,有时候卡在”加载中”。
问题根源:
- 三个子组件各自useEffect独立发请求,没有去重
- 每个组件3-5个请求,总共15个
- 浏览器并发限制6个,剩下9个排队
- 排队请求在超时前没发出去,导致数据缺失
解决方案:
// 重构前:每个组件独立请求
function UserStats() {
const [stats, setStats] = useState(null)
useEffect(() => {
fetch('/api/stats/user').then(...)
}, [])
}
function OrderStats() {
// 也独立请求
}
// 重构后:统一请求管理
function useDashboardData() {
const data = useQuery({
queryKey: ['dashboard'],
queryFn: async () => {
const [userStats, orderStats, ...] = await Promise.all([
fetch('/api/stats/user').then(r => r.json()),
fetch('/api/stats/order').then(r => r.json()),
// ...
])
return { userStats, orderStats, ... }
}
})
return data
}
改完之后,请求从15个降到1个批量请求,性能提升了3倍。
给小朋友也能懂的解释
想象一下,浏览器就像一家餐厅,厨房同时只能做6道菜。如果你一次性点15道菜,厨师只能先做6道,剩下9道排队等。有的菜可能等太久,客人等不及走了(请求超时)。
解决方案就是:
- 分批点菜 - 一次点6道,做好再点6道
- 点套餐 - 把15道菜换成3个套餐,厨房更容易处理
- 只点重复的菜 - 如果3个人都要喝水,只叫服务员送1次,分给3个人
预防措施
最后给你几个防坑建议:
- 避免组件重复请求 - 用状态管理或React Query去重
- 接口尽量批量 - 一个接口返回多个数据,别分N个接口
- 设置超时和重试 - 别让请求卡死
- 监控网络面板 - 开发时多看看Network,养成习惯
- 考虑CDN和域名分片 - 不同域名可以突破并发限制(但要注意权衡)
// 完整的请求工具类示例
class SmartFetcher {
constructor(options = {}) {
this.maxConcurrent = options.maxConcurrent || 6
this.retryCount = options.retryCount || 3
this.timeout = options.timeout || 10000
this.queue = new RequestQueue(this.maxConcurrent)
this.cache = new Map()
}
async fetch(url, options = {}) {
// 缓存相同请求
const cacheKey = `${url}_${JSON.stringify(options)}`
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey)
}
const promise = this.queue.add(() =>
this._fetchWithRetry(url, options)
)
this.cache.set(cacheKey, promise)
return promise
}
async _fetchWithRetry(url, options, retries = this.retryCount) {
try {
const response = await Promise.race([
fetch(url, options),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), this.timeout)
)
])
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return await response.json()
} catch (error) {
if (retries > 0) {
return this._fetchWithRetry(url, options, retries - 1)
}
throw error
}
}
}
// 使用
const fetcher = new SmartFetcher({ maxConcurrent: 4, timeout: 5000 })
// 所有请求自动管理并发和重试
const [user, orders, products] = await Promise.all([
fetcher.fetch('/api/user'),
fetcher.fetch('/api/orders'),
fetcher.fetch('/api/products')
])
并发限制不是bug,是浏览器的保护机制。理解它、尊重它、利用它,你的应用会更稳定。记住,好的前端工程师不是让请求尽可能快,而是让请求尽可能聪明。
