你是不是也有过这种经历:页面上一个按钮,你手抖多点了两下,或者网速稍微卡一下,整个页面就“僵”住了,转圈圈转得你怀疑人生。更糟糕的是,后台可能瞬间涌进来几十上百个请求,浏览器CPU飙红,风扇狂转,最后要么崩溃,要么直接卡死。
别急,这事儿真不是玄学。今天咱们不聊那些枯燥的理论,就聊聊怎么让浏览器“呼吸”顺畅,怎么用对异步处理,怎么管理好那些乱窜的请求。
一、先搞懂:为什么浏览器会卡?
很多人以为浏览器是“单线程”的,所以请求多了就卡。这话对,也不全对。
浏览器的JavaScript 执行引擎确实是单线程的(主线程),但网络请求、定时器、DOM 渲染等是由不同的线程处理的。问题出在:当太多异步请求同时触发,回调堆积、内存占用飙升、主线程被大量任务阻塞,浏览器就“喘不过气”了。
举个生活中的例子:你让一个服务员(主线程)同时点100道菜,每道菜都要他亲自去后厨催进度(回调),那他能不累死吗?最后他坐那儿发呆,你就等着饿肚子吧。
二、AJAX 异步处理的几个“坑”
1. 盲目发起请求,没有控制并发数
// 错误示范:直接循环发起100个请求
for (let i = 0; i < 100; i++) {
fetch(`/api/data/${i}`)
.then(res => res.json())
.then(data => console.log(data));
}
这100个请求几乎同时发出,浏览器瞬间压力山大。而且,如果接口响应速度不一致,回调的先后顺序也无法预测,状态管理会乱成一团。
2. 没有取消机制,请求“僵尸”化
用户点击了“加载更多”,发起了10个请求。结果用户又快速滚动了5次,前面9个请求其实已经没用了,但它们还在后台飘着,等响应回来还要处理数据、更新UI。这些“僵尸请求”白白占用资源。
3. 回调地狱,代码难维护
fetch('/api/step1')
.then(res => res.json())
.then(data1 => {
fetch('/api/step2', { body: JSON.stringify(data1) })
.then(res => res.json())
.then(data2 => {
fetch('/api/step3', { body: JSON.stringify(data2) })
.then(res => res.json())
.then(data3 => console.log(data3));
});
});
这种写法不仅难看,而且错误处理麻烦,异步逻辑耦合严重。
三、实战技巧:如何优雅地处理并发请求?
技巧1:使用 Promise.all 或 Promise.allSettled 批量管理
当你确实需要同时发起多个独立请求,并且希望它们全部完成后再处理结果时,Promise.all 是个好帮手。但如果某个请求失败会导致整个链断裂,那就用 Promise.allSettled,它不会因为某个请求失败而整体 rejection。
const ids = [1, 2, 3, 4, 5];
// 使用 Promise.allSettled 确保所有请求都能得到处理
Promise.allSettled(ids.map(id => fetch(`/api/item/${id}`).then(res => res.json())))
.then(results => {
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`Item ${ids[index]}:`, result.value);
} else {
console.error(`Failed to fetch item ${ids[index]}:`, result.reason);
}
});
});
这样,即使某个请求失败,也不会影响其他请求的结果收集。
技巧2:并发控制 —— 限制同时发出的请求数量
这是解决“卡顿”最关键的一步。我们不想让100个请求同时飞出去,而是希望同时只有 N 个在飞,其他的排队等。
下面是一个通用的并发控制器:
/**
* 并发请求控制器
* @param {Array} items - 需要处理的项
* @param {Function} fetchFn - 处理每一项的异步函数
* @param {Number} concurrency - 最大并发数
* @returns {Promise<Array>} - 所有结果
*/
async function concurrentPromise(items, fetchFn, concurrency) {
const results = [];
const executing = new Set();
for (const item of items) {
// 当正在执行的请求达到上限时,等待其中一个完成
if (executing.size >= concurrency) {
const [done] = await Promise.race([...executing]);
executing.delete(done);
}
// 创建新的 Promise,加入执行集合
const promise = fetchFn(item)
.then(result => results.push(result))
.finally(() => executing.delete(promise));
executing.add(promise);
}
await Promise.all([...executing]);
return results;
}
// 使用示例:限制同时只发3个请求
const ids = Array.from({ length: 100 }, (_, i) => i + 1);
concurrentPromise(ids, id => fetch(`/api/data/${id}`).then(res => res.json()), 3)
.then(results => {
console.log('All fetched:', results.length);
});
这个思路就像图书馆借书:一次只允许3个人去借(并发数3),其他人排队。借完一个回来,下一个人才能去。这样浏览器压力小,也不会乱。
技巧3:请求取消 —— 用 AbortController 告别僵尸请求
现代浏览器支持 AbortController,可以在请求还没响应时主动取消它。
function fetchDataWithAbort(id, signal) {
return fetch(`/api/data/${id}`, { signal })
.then(res => res.json());
}
const controller = new AbortController();
const signal = controller.signal;
// 发起10个请求
const promises = Array.from({ length: 10 }, (_, i) =>
fetchDataWithAbort(i, signal)
);
Promise.all(promises)
.then(results => console.log('Success:', results))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Requests aborted intentionally');
} else {
console.error('Fetch failed:', err);
}
});
// 500ms 后,觉得不需要这些结果了,主动取消
setTimeout(() => controller.abort(), 500);
这在“无限滚动”、“搜索建议”等场景中特别有用。用户还在打字,前面的请求就可以直接砍掉,只保留最新那个。
四、线程管理:别把主线程累垮
浏览器主线程除了跑 JS,还要处理 UI 渲染、用户交互。如果异步回调里做了大量计算,主线程照样卡。
1. 用 setTimeout 或 requestIdleCallback 拆分重型任务
function processLargeData(data) {
const chunkSize = 1000;
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, data.length);
// 模拟处理
for (let i = index; i < end; i++) {
// 数据处理...
}
index = end;
if (index < data.length) {
// 交给浏览器空闲时间继续处理
requestIdleCallback(processChunk);
} else {
console.log('All done');
}
}
requestIdleCallback(processChunk);
}
这样,数据处理不会一次性阻塞主线程,浏览器还能保持响应。
2. Web Workers:真正“另开线程”
如果任务真的特别重(比如大数组排序、图像处理、复杂计算),那就别在主线程上折腾了,直接上 Web Worker。
// worker.js
self.onmessage = function(e) {
const data = e.data;
// 重型计算
const result = data.reduce((a, b) => a + b, 0);
self.postMessage(result);
};
// main.js
const worker = new Worker('worker.js');
worker.postMessage([1, 2, 3, 4, 5]);
worker.onmessage = function(e) {
console.log('Result:', e.data);
};
Worker 在后台线程运行,完全不占主线程资源。缺点是通信只能通过 postMessage,不能直接操作 DOM。
五、一个完整的实战案例:搜索建议框
我们来做一个真实的场景:输入框实时搜索,但用户打字很快,前面的请求必须取消,且并发不能超过 2 个。
class SearchBox {
constructor(inputEl, containerEl) {
this.input = inputEl;
this.container = containerEl;
this.abortController = null;
this.concurrentLimit = 2;
this.pendingRequests = [];
this.input.addEventListener('input', this.handleInput.bind(this));
}
async handleInput(e) {
const query = e.target.value.trim();
// 取消上一个未完成的请求
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
// 清空结果
this.container.innerHTML = '';
if (!query) return;
// 并发控制:同时最多发 2 个请求
const fetchWithConcurrency = (q, signal) => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timeout')), 3000);
fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal })
.then(res => res.json())
.then(data => {
clearTimeout(timeout);
resolve(data);
})
.catch(err => {
clearTimeout(timeout);
if (err.name !== 'AbortError') reject(err);
});
});
};
// 模拟分页或分词,发起多个子请求
const queries = [query, query.slice(0, -1)]; // 比如尝试去掉最后一个字
const results = await this.concurrentFetch(queries, fetchWithConcurrency, signal);
this.renderResults(results.flat());
}
async concurrentFetch(items, fetchFn, signal) {
const results = [];
const executing = new Set();
for (const item of items) {
if (executing.size >= this.concurrentLimit) {
const [done] = await Promise.race([...executing]);
executing.delete(done);
}
const promise = fetchFn(item, signal)
.then(r => results.push(r))
.finally(() => executing.delete(promise));
executing.add(promise);
}
await Promise.all([...executing]);
return results;
}
renderResults(data) {
this.container.innerHTML = data.map(item =>
`<div class="suggestion">${item.title}</div>`
).join('');
}
}
// 使用
const searchBox = new SearchBox(
document.getElementById('search-input'),
document.getElementById('search-results')
);
这个例子融合了请求取消、并发控制、超时处理,是一个比较健壮的搜索建议实现。
六、总结:几条实用原则
- 别同时发太多请求:用并发控制器限制同时发送的数量。
- 及时取消无用请求:用
AbortController砍掉“僵尸请求”。 - 重型计算交给 Worker:别占主线程,让用户感觉流畅。
- 拆分大任务:用
requestIdleCallback或setTimeout避免一次性阻塞。 - 错误处理要彻底:
Promise.allSettled比Promise.all更适合批量请求。
浏览器卡顿不是魔法,是资源管理没做好。把这些技巧用熟,你的页面会像德芙巧克力一样丝滑。
下次再遇到“卡死”,别急着刷新页面,先想想:是不是请求太多?是不是没取消?是不是计算太狠?对症下药,问题就解决了。
