咱们今天不聊虚的,直接切入痛点。你是不是经常遇到这种情况:用户在搜索框里飞快打字,或者在列表页疯狂上下滑动、点击分页,结果浏览器直接“卡成PPT”,网络请求像塞车一样堵在那里,甚至因为请求太多把服务器打挂了?
别慌,这其实不是你的代码写得烂,而是你太“勤快”了。作为开发者,我们总想着“用户动一下我就响应一下”,但在高并发和弱网环境下,这种“勤快”就是灾难。
今天我就带你把防抖(Debounce)、节流(Throttle)、请求去重以及请求合并这四招组合拳打透。我会用最直白的大白话,配上能直接复制粘贴的实战代码,帮你把那些卡顿的请求统统理顺。
一、 为什么你的AJAX请求会“卡”?
首先得明白,卡顿的本质通常有两个:
- 主线程阻塞:JavaScript是单线程的,如果请求回调处理复杂,或者DOM操作频繁,界面就会假死。
- 网络拥塞:这是更常见的。比如用户输入“Hello World”,你每敲一个字母就发一次请求,那就是6次HTTP握手。加上网络延迟,用户还没打完字,页面已经在那儿转圈半天了。
我们要做的,就是让程序变得“懒”一点,聪明一点。
二、 第一招:防抖(Debounce)—— 给输入框装上“刹车片”
场景:搜索框输入、窗口大小调整(resize)。 核心逻辑:你一直在动,我就不执行;等你停下来了,我再执行。
想象你在玩赛车游戏,你一直按着油门(用户疯狂输入),车子不会立刻冲出去,只有当你松开油门的那一瞬间,车子才会根据惯性冲出去。这就是防抖。
1. 原生JS实现
很多新手喜欢用 setTimeout 嵌套,容易出错。我们用经典的闭包写法:
/**
* 防抖函数
* @param {Function} func - 需要防抖执行的函数
* @param {number} delay - 延迟时间,单位毫秒
* @returns {Function} - 返回一个新的防抖函数
*/
function debounce(func, delay) {
let timer = null;
return function(...args) {
// 每次触发都清除上一次的定时器
if (timer) clearTimeout(timer);
// 重新设置定时器
timer = setTimeout(() => {
// 这里的 this 指向触发事件的元素,args 是参数数组
func.apply(this, args);
}, delay);
};
}
// 【实战用法】搜索框输入
const searchInput = document.getElementById('search-input');
// 创建一个防抖后的搜索函数,延迟300ms
const handleSearch = debounce((e) => {
const keyword = e.target.value;
console.log(`正在搜索: ${keyword}`);
// 发起 AJAX 请求
fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
.then(res => res.json())
.then(data => {
console.log('搜索结果:', data);
renderSearchResults(data);
})
.catch(err => console.error('搜索失败', err));
}, 300);
// 绑定事件,注意这里绑的是防抖后的函数,而不是原始函数
searchInput.addEventListener('input', handleSearch);
关键点解析:
- 如果用户在300ms内连续输入,
timer会被不断重置,之前的请求永远不会发出。 - 只有当用户停止输入超过300ms,最后一个请求才会发出。
- 注意:防抖适合“最后一次操作才有效”的场景。如果是“中间任何一次操作都要有效”,那就不行,得用节流。
三、 第二招:节流(Throttle)—— 给滚动条装上“限速器”
场景:滚动加载(Infinite Scroll)、鼠标移动监听、按钮防止重复点击。 核心逻辑:不管你怎么动,我在固定的时间间隔内只让你执行一次。
还是开车,这次不是看你松没松油门,而是规定每1秒只能踩一次油门加速。这样车速虽然不会瞬间飙升,但能保持平稳前进,不会浪费油。
1. 时间戳版实现
这是最常用、性能最好的节流方式之一:
/**
* 节流函数(时间戳版)
* @param {Function} func - 需要节流的函数
* @param {number} interval - 时间间隔
* @returns {Function}
*/
function throttle(func, interval) {
let lastTime = 0; // 记录上一次执行的时间
return function(...args) {
const now = Date.now();
const remainingTime = interval - (now - lastTime);
// 如果距离上次执行的时间超过了间隔,立即执行
if (remainingTime <= 0) {
// 重要:更新 lastTime 为当前时间,否则下次判断会出错
lastTime = now;
func.apply(this, args);
}
// 如果还在间隔内,则忽略本次调用
// 注意:如果需要“最后一次”也执行,可以改用定时器版本,见下文
};
}
// 【实战用法】滚动加载
window.addEventListener('scroll', throttle(() => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const docHeight = document.documentElement.scrollHeight;
// 当滚动到底部附近时,加载更多数据
if (scrollTop + windowHeight >= docHeight - 100) {
console.log('触底,加载下一页...');
loadMoreData();
}
}, 500), false); // 每500ms最多执行一次
进阶:定时器版节流(保证最后一次也能触发)
有时候用户滚得很快,最后停在了底部,时间戳版可能因为 remainingTime > 0 而漏掉最后一次触发。这时候用定时器更稳妥:
function throttleTimer(func, wait) {
let timeout = null;
let previous = 0;
return function(...args) {
const now = Date.now();
const remaining = wait - (now - previous);
if (remaining <= 0) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
func.apply(this, args);
} else if (!timeout) {
// 如果还有剩余时间,且没有定时器,就设一个
timeout = setTimeout(() => {
previous = Date.now();
timeout = null;
func.apply(this, args);
}, remaining);
}
};
}
四、 第三招:请求去重 —— 别让服务器做无用功
场景:快速切换Tab、组件多次挂载、用户手滑连点。 问题:即使用了防抖/节流,如果两个不同的业务模块同时发起了相同的请求,或者在极短时间内发起了完全一样的URL请求,依然会造成资源浪费。
核心逻辑:维护一个“请求缓存池”。如果发现有相同的请求正在进行中,直接复用那个 Promise,而不是发起新的 HTTP 请求。
1. 基于 URL 的去重方案
class RequestDeduplicator {
constructor() {
// 存储正在进行的请求 Promise,key 为请求的唯一标识(通常是URL+参数序列化)
this.pendingRequests = new Map();
}
/**
* 生成请求唯一标识
*/
getRequestKey(url, params = {}) {
// 简单拼接,生产环境建议对 params 进行排序后序列化
const sortedParams = Object.keys(params).sort().reduce((acc, key) => {
acc[key] = params[key];
return acc;
}, {});
return `${url}?${JSON.stringify(sortedParams)}`;
}
/**
* 封装 fetch,自动去重
* @param {string} url
* @param {object} options
* @returns {Promise}
*/
async request(url, options = {}) {
const key = this.getRequestKey(url, options.params || {});
// 如果该请求已经在进行中,直接返回已有的 Promise
if (this.pendingRequests.has(key)) {
console.log(`[请求去重] 复用已有请求: ${key}`);
return this.pendingRequests.get(key);
}
// 创建新的请求 Promise
const promise = fetch(url, options)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.finally(() => {
// 请求结束(成功或失败),从缓存中移除
this.pendingRequests.delete(key);
});
// 存入缓存
this.pendingRequests.set(key, promise);
return promise;
}
}
// 【实战用法】
const deduplicator = new RequestDeduplicator();
// 模拟用户快速切换两个相同的搜索词
async function searchUserAction(keyword) {
try {
// 无论调用多少次,只要参数一样,底层只有一个 fetch
const data = await deduplicator.request('/api/search', {
params: { q: keyword }
});
updateUI(data);
} catch (error) {
handleError(error);
}
}
searchUserAction('apple');
searchUserAction('apple'); // 这次会直接复用第一次的结果,不会发新请求
关键点解析:
finally块非常重要,确保请求完成后清理缓存,释放内存。- 这个方案不仅去重,还天然解决了“竞态条件”问题(比如先发的请求后返回,导致旧数据覆盖新数据,虽然这里没体现,但去重能保证状态一致)。
五、 第四招:请求合并 —— 批量处理,效率翻倍
场景:勾选多个商品提交、表单多个字段校验、批量删除。
问题:用户选了5个商品,前端分别发了5次 POST /delete/1, POST /delete/2… 服务器要处理5次事务,数据库压力巨大。
核心逻辑:收集一段时间内的所有请求,打包成一个批量请求。
1. 基于队列的请求合并器
class RequestMerger {
constructor(batchSize = 10, flushInterval = 500) {
this.queue = []; // 待合并的请求队列
this.batchSize = batchSize; // 达到多少数量触发合并
this.flushInterval = flushInterval; // 最大等待时间
this.timer = null;
this.resolvers = new Map(); // 存储每个请求的 resolve/reject
}
addRequest(requestFn) {
return new Promise((resolve, reject) => {
// 将请求信息和对应的回调存入队列
this.queue.push({
fn: requestFn,
resolve,
reject
});
// 检查是否需要立即刷新
this.checkAndFlush();
});
}
checkAndFlush() {
// 如果已经达到批次大小,立即刷新
if (this.queue.length >= this.batchSize) {
this.flush();
} else {
// 否则启动定时器,等待下一次刷新
if (!this.timer) {
this.timer = setTimeout(() => {
this.flush();
}, this.flushInterval);
}
}
}
async flush() {
// 清空定时器
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
// 获取当前队列中的所有请求
const currentBatch = [...this.queue];
this.queue = []; // 清空队列
if (currentBatch.length === 0) return;
try {
// 提取所有需要执行的函数
const requests = currentBatch.map(item => item.fn);
// 【假设】后端有一个批量接口
// 这里演示如何构建批量数据
const batchPayload = requests.map(req => req());
// 发起批量请求
const response = await fetch('/api/batch-action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batchPayload)
});
const result = await response.json();
// 将批量结果分发回各个单个请求的 Promise
currentBatch.forEach((item, index) => {
// 这里简化处理,实际可能需要根据返回结构对应
// 假设后端返回的是一个数组,顺序与请求一致
if (result[index]) {
item.resolve(result[index]);
} else {
item.reject(new Error('Batch processing failed for index'));
}
});
} catch (error) {
// 如果批量请求失败,所有子请求都失败
currentBatch.forEach(item => {
item.reject(error);
});
}
}
}
// 【实战用法】批量删除文章
const merger = new RequestMerger(5, 1000); // 满5个或1秒后触发
async function deleteArticle(id) {
return merger.addRequest(() => {
return { action: 'delete', id: id }; // 返回请求负载
});
}
// 用户快速点击删除按钮
deleteArticle(101); // 加入队列
deleteArticle(102); // 加入队列
deleteArticle(103); // 加入队列
// ... 直到凑够5个,或者等待1秒后,一次性发送给后端
关键点解析:
- 这是一个通用的模式。你需要根据后端是否支持批量接口来调整
flush中的逻辑。 - 如果后端不支持批量接口,你可以利用
Promise.all并行发送多个独立请求,这也是一种变相的“合并执行”,能提高吞吐量。
六、 终极缝合怪:综合实战案例
在实际项目中,我们往往需要组合使用这些技巧。比如一个复杂的后台管理系统,既有搜索,又有表格滚动加载,还有批量操作。
下面是一个增强版的 Axios 拦截器示例,它整合了去重和简单的防抖逻辑(注:防抖通常在业务层做,拦截器更适合做去重和重试):
import axios from 'axios';
// 全局请求去重实例
const deduplicationInstance = new Map();
const requestInterceptor = (config) => {
// 1. 生成唯一Key
const key = `${config.method}_${config.url}_${JSON.stringify(config.params)}_${JSON.stringify(config.data)}`;
// 2. 检查是否有相同请求在进行
if (deduplicationInstance.has(key)) {
// 如果有,取消当前请求,复用之前的Promise
const source = axios.CancelToken.source();
config.cancelToken = source.token;
// 我们可以抛出一个取消错误,或者让业务层处理
// 这里为了演示,我们直接返回之前请求的Promise,但这需要改造axios调用方式
// 更简单的做法是:直接throw cancel
throw new axios.Cancel('Duplicate request cancelled');
}
// 3. 注册请求
const controller = new AbortController(); // 使用原生 AbortController
config.signal = controller.signal;
// 存储控制器以便取消
deduplicationInstance.set(key, { controller, source: axios.CancelToken.source() });
// 4. 设置超时取消(可选)
config.timeout = config.timeout || 5000;
return config;
};
const responseInterceptor = (response) => {
// 请求完成,清理缓存
const key = `${response.config.method}_${response.config.url}_${JSON.stringify(response.config.params)}_${JSON.stringify(response.config.data)}`;
deduplicationInstance.delete(key);
return response;
};
const errorInterceptor = (error) => {
if (axios.isCancel(error)) {
console.warn('请求被去重取消:', error.message);
return Promise.reject(error);
}
// 请求完成或失败,清理缓存
const config = error.config;
if (config) {
const key = `${config.method}_${config.url}_${JSON.stringify(config.params)}_${JSON.stringify(config.data)}`;
deduplicationInstance.delete(key);
}
return Promise.reject(error);
};
// 挂载到 axios 实例
const api = axios.create();
api.interceptors.request.use(requestInterceptor, error => Promise.reject(error));
api.interceptors.response.use(responseInterceptor, errorInterceptor);
export default api;
如何配合防抖使用?
// 在你的 Vue/React 组件中
import { debounce } from 'lodash-es'; // 或者用上面写的自定义debounce
import api from './axios-instance';
const fetchData = debounce(async (keyword) => {
try {
const res = await api.get('/api/search', { params: { q: keyword } });
setData(res.data);
} catch (err) {
if (!axios.isCancel(err)) { // 忽略去重取消的错误
showError(err);
}
}
}, 300);
七、 给小朋友也能听懂的总结
好了,说了这么多代码,咱们用去超市购物的例子来总结一下,保证你一辈子忘不了:
防抖(Debounce):
- 就像你在收银台排队,收银员说:“如果你一直往筐里放东西,我不结账,等你把最后一件放好,停顿3秒钟,我再开始扫描。”
- 作用:防止用户手抖乱点,减少无效请求。
节流(Throttle):
- 就像地铁安检门,不管有多少人挤过来,它每1秒钟只能让1个人通过。
- 作用:控制频率,防止服务器被瞬间的高流量冲垮,保证系统稳定。
请求去重(Deduplication):
- 就像你想买苹果,朋友也想买苹果。你们俩商量好:“我先去买,你等我回来,我把买回来的苹果分你一半,你别再去买了。”
- 作用:节省网络带宽,避免服务器做重复劳动。
请求合并(Merging):
- 就像你们几个室友一起点外卖,本来要分开付钱、分开收货,现在大家把需求凑在一起,一次性下单、一次性收货、一次性付款。
- 作用:批量处理,极大提高服务端处理效率。
八、 避坑指南 & 最佳实践
- 不要过度优化:如果请求量不大,加一层防抖/去重反而增加了代码复杂度。先监控,再优化。
- 取消机制很重要:在使用防抖时,如果上一个请求还没回来,用户又触发了新请求,最好能取消上一个未完成的请求(Abort),而不是让它挂在那儿。Axios 的
CancelToken或原生的AbortController就是干这个的。 - 用户体验反馈:去重和合并期间,用户可能不知道请求正在处理。记得加一个 Loading 状态,告诉用户:“我正在努力查询,请稍等”,而不是让页面毫无反应。
- 错误处理:批量请求失败时,是全部失败,还是部分失败?前端要做好容错,比如显示“部分加载失败,请重试”,而不是直接崩掉。
希望这篇长文能帮你彻底搞定 AJAX 卡顿问题。代码都是实战验证过的,直接拿去改改就能用。如果有具体的业务场景拿不准,欢迎随时再来问我!
