你有没有遇到过这样的场景:用户点击了一个按钮,页面转圈圈转了足足三秒,或者更糟糕的是,用户因为着急,在短时间内连续点击了五次,后台服务器瞬间收到五个一模一样的请求,最后数据返回时,界面还出现了奇怪的重影或者数据覆盖问题?
作为前端开发者,我们常常听到一句话:“用户的时间比黄金贵”。在网络环境参差不齐的今天,哪怕只是减少几十毫秒的延迟,或者避免一次无谓的网络请求,都能显著提升用户体验。而AJAX(异步JavaScript和XML)请求,作为前端与后端通信的核心手段,往往是性能优化的“重灾区”。
今天,我们就来深入拆解AJAX请求优化的实战技巧,特别是如何科学地减少重复请求,让用户不再等待,不再卡顿。
一、 为什么要警惕“重复请求”?
在深入代码之前,我们先搞清楚一个问题:为什么重复请求这么讨厌?
想象一下,你正在写代码,去茶水间接水。第一次你去接,等了5分钟。回来刚坐下,同事问你:“水开了吗?”你刚要回答,他又问:“水开没?”你再回答,他又问:“真的开没?”
这时候你会怎么做?你可能会想:“这人有病吧,我刚才不是说开了吗?”然后你可能干脆不理他,或者心里默默吐槽。
在浏览器里,你的“接水”过程就是发起网络请求,而“同事”就是用户的点击行为。重复请求带来的危害主要有三点:
- 浪费服务器资源:后端数据库可能被重复查询,CPU和内存白白消耗。
- 浪费客户端资源:浏览器的网络栈、内存、电量都在无意义地工作。
- 用户体验糟糕:如果请求成功但有延迟,用户可能会因为之前的点击还没响应而再次点击,导致数据错乱(比如列表渲染了多次,或者最后返回的数据覆盖了之前的状态)。
所以,优化AJAX请求,核心目标就是:让每个请求只发一次,并且让浏览器记住结果。
二、 基础大招:防抖与节流
这是前端优化中最经典、最基础,也最有效的两个技巧。很多人只听说过名字,但不知道具体怎么用,或者用错了地方。我们先从这两个概念说起。
2.1 防抖(Debounce):让“手抖”的人冷静一下
场景:用户在搜索框里输入关键词,每敲一个字母,都触发一次搜索请求。比如用户输入“JavaScript”,会触发8次请求(J, Ja, Java, JavaS, JavaSc, JavaScr, JavaScri, JavaScript)。
问题:这8次请求中,只有最后一次是用户真正想要的。前7次都是无效的“噪音”。更糟糕的是,如果网络慢,第8个请求(用户真正想搜的)可能比第1个请求还晚返回,导致界面先显示“J”的结果,再显示“Ja”的结果…最后才显示真正的“JavaScript”结果,用户看得一脸懵。
解决方案:防抖。
防抖的核心思想是:设定一个时间阈值,如果在阈值内事件再次触发,则重新计时。 只有当事件停止触发一段时间后,才真正执行操作。
/**
* 防抖函数
* @param {Function} func - 需要防抖的函数
* @param {number} delay - 延迟时间(毫秒)
*/
function debounce(func, delay) {
let timer = null; // 核心:用一个变量记住定时器ID
return function(...args) {
// 每次触发,都清除上一个定时器
// 这样,只有当用户在delay时间内不再触发时,timer才会真正执行
if (timer) {
clearTimeout(timer);
}
// 重新设定一个新的定时器
timer = setTimeout(() {
func.apply(this, args);
}, delay);
};
}
// 使用示例:搜索框输入
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
// 假设这是真正的搜索请求函数
function performSearch(keyword) {
console.log(`发起搜索请求: ${keyword}`);
fetch(`/api/search?q=${encodeURIComponent(keyword)}`)
.then(res => res.json())
.then(data => {
searchResults.innerHTML = data.map(item => `<li>${item.name}</li>`).join('');
});
}
// 给输入框绑定事件,使用防抖,延迟300ms
searchInput.addEventListener('input', debounce(function(e) {
const keyword = e.target.value.trim();
if (keyword) {
performSearch(keyword);
}
}, 300));
解释给小朋友听: 想象你在玩“木头人”游戏。老师说“开始”,你一动就不能动。如果你一直动,老师就要重新计时。只有当你安静地站了3秒钟,老师才会说“可以动了”。防抖就是那个“3秒钟”的规则,确保用户真的停止输入了,我们才去搜索,而不是每动一下就搜索一次。
2.2 节流(Throttle):让“手速”快的人慢下来
场景:用户疯狂点击“加载更多”按钮,或者窗口滚动事件触发频繁。
问题:虽然防抖可以解决输入问题,但对于持续触发的事件(如滚动),防抖可能会导致长时间不触发,或者触发后一次性执行太多任务。我们需要的是:在一段时间内,无论触发多少次,只执行一次。
解决方案:节流。
节流的核心思想是:设定一个时间阈值,每隔一段时间执行一次,即使事件一直触发,也只会按固定频率执行。
/**
* 节流函数
* @param {Function} func - 需要节流的函数
* @param {number} interval - 执行间隔(毫秒)
*/
function throttle(func, interval) {
let lastTime = 0; // 核心:记住上次执行的时间
return function(...args) {
const now = Date.now();
// 如果距离上次执行已经超过interval,才执行
if (now - lastTime >= interval) {
lastTime = now; // 更新上次执行时间
func.apply(this, args);
}
};
}
// 使用示例:滚动加载
window.addEventListener('scroll', throttle(function() {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = document.documentElement.clientHeight;
const docHeight = document.documentElement.scrollHeight;
// 当滚动到接近底部时,加载更多内容
if (scrollTop + windowHeight >= docHeight - 100) {
console.log('加载更多内容...');
loadMoreData();
}
}, 500)); // 每500ms最多执行一次
解释给小朋友听: 想象你是在坐过山车,每隔10秒钟喷一次水。不管你在过山车上怎么尖叫、怎么乱动,过山车只会按时喷出水。这就是节流,确保动作以固定的节奏发生,不会太频繁,也不会太稀疏。
三、 进阶技巧:请求缓存
防抖和节流解决了“触发频率”的问题,但还有一个问题:用户可能点了A,又点了B,结果B的请求先返回,覆盖了A的结果。 或者,同一个数据,用户在不同页面都看到了,但每次都要重新请求。
这时候,我们需要请求缓存。
3.1 简单缓存:用Map对象记住请求结果
对于相同参数的请求,如果结果没变,我们就不需要再发请求,直接从缓存里取。
// 一个简单的请求缓存库
const requestCache = new Map();
/**
* 带缓存的fetch请求
* @param {string} url - 请求地址
* @param {object} params - 请求参数(用于生成缓存key)
* @param {number} cacheTime - 缓存时间(毫秒),默认5分钟
*/
function cachedFetch(url, params = {}, cacheTime = 5 * 60 * 1000) {
// 生成缓存key,比如 '/api/user?name=alice&age=18'
const key = `${url}?${JSON.stringify(params)}`;
// 检查缓存是否有效
if (requestCache.has(key)) {
const { data, timestamp } = requestCache.get(key);
if (Date.now() - timestamp < cacheTime) {
console.log('命中缓存:', key);
return Promise.resolve(data);
} else {
// 缓存过期,删除旧缓存
requestCache.delete(key);
}
}
// 发起真实请求
return fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
params // 注意:fetch原生不支持params,这里仅作示意,实际需拼接URL
})
.then(response => response.json())
.then(data => {
// 存入缓存
requestCache.set(key, {
data,
timestamp: Date.now()
});
return data;
})
.catch(error => {
// 请求失败,也存入缓存?通常失败不缓存,或者缓存更短时间
console.error('请求失败', error);
throw error;
});
}
// 使用示例
cachedFetch('/api/user', { id: 1 })
.then(user => console.log('用户数据:', user));
// 第二次请求相同参数,直接从缓存取,不发起网络请求
cachedFetch('/api/user', { id: 1 });
注意:上面的代码中,fetch不支持params参数,实际项目中需要手动拼接URL。你可以使用qs或url-search-params等库来处理。
3.2 缓存策略:何时缓存,何时不缓存
不是所有请求都适合缓存。我们需要区分:
- GET请求:通常是幂等的,适合缓存。
- POST/PUT/DELETE请求:可能修改数据,通常不缓存,或者缓存时间极短。
- 实时数据:如股票价格、聊天消息,不适合缓存,或者缓存时间非常短(比如1秒)。
- 不常变化的数据:如用户基本信息、配置信息,可以缓存较长时间。
一个实用的缓存策略:
const cacheStrategies = {
// 用户信息:缓存10分钟
'user': { time: 10 * 60 * 1000, method: 'GET' },
// 文章列表:缓存5分钟
'articles': { time: 5 * 60 * 1000, method: 'GET' },
// 搜索关键词:缓存1分钟(搜索结果变化较快)
'search': { time: 1 * 60 * 1000, method: 'GET' },
// 提交表单:不缓存
'submit': { time: 0, method: 'POST' }
};
function getRequestStrategy(apiName) {
return cacheStrategies[apiName] || { time: 0, method: 'GET' };
}
四、 高级技巧:请求去重
即使有了缓存,还有一个问题:在缓存生效前,如果有多个相同的请求同时发出怎么办?
比如,用户快速点击了两个按钮,都触发了同一个API请求。第一个请求还没返回,第二个请求就已经发出了。这样还是会有两个并行的请求,浪费资源。
解决方案:请求去重。
请求去重的核心思想是:在请求发出前,检查是否已经有相同的请求在pending(进行中)状态。如果有,就返回同一个Promise,而不是再次发起请求。
// 全局请求去重对象
const pendingRequests = new Map();
/**
* 带去重的fetch请求
* @param {string} url - 请求地址
* @param {object} params - 请求参数
*/
function deduplicatedFetch(url, params = {}) {
const key = `${url}?${JSON.stringify(params)}`;
// 如果已经有相同的请求在pending,返回同一个Promise
if (pendingRequests.has(key)) {
console.log('请求去重,复用现有Promise:', key);
return pendingRequests.get(key);
}
// 创建一个新的Promise
const promise = new Promise((resolve, reject) => {
fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
params
})
.then(response => response.json())
.then(data => {
resolve(data);
})
.catch(error => {
reject(error);
})
.finally(() => {
// 请求完成(无论成功还是失败),从pendingRequests中移除
pendingRequests.delete(key);
});
});
// 将Promise存入pendingRequests
pendingRequests.set(key, promise);
return promise;
}
// 使用示例
deduplicatedFetch('/api/user', { id: 1 })
.then(user => console.log('用户1:', user));
// 在第一个请求还没返回时,再次调用
deduplicatedFetch('/api/user', { id: 1 })
.then(user => console.log('用户1(复用):', user));
// 输出:
// 请求去重,复用现有Promise: /api/user?{"id":1}
// 用户1: {...}
// 用户1(复用): {...}
解释给小朋友听: 想象你在图书馆借书。如果同时有两个人来借同一本书,第一个人已经去借了,第二个人就会等第一个人借完,然后一起看(或者看同一本)。这样图书馆就不用跑去借两次书,节省了时间。请求去重就是让浏览器“等一等”,复用已有的请求,而不是每次都重新去“借书”。
五、 结合实战:一个完整的优化方案
现在,我们把防抖、节流、缓存、去重结合起来,做一个更完善的请求优化方案。
”`javascript /**
高级请求优化库 */ class RequestOptimizer { constructor() {
this.cache = new Map(); // 缓存 this.pending = new Map(); // 进行中请求 this.debounceTimers = new Map(); // 防抖定时器 this.throttleLastTime = new Map(); // 节流最后执行时间}
/**
防抖请求
@param {string} key - 请求的唯一标识
@param {Function} requestFn - 实际发起请求的函数
@param {number} delay - 延迟时间 */ debounceRequest(key, requestFn, delay = 300) { return () => {
// 清除之前的定时器 if (this.debounceTimers.has(key)) { clearTimeout(this.debounceTimers.get(key)); } // 设置新的定时器 const timer = setTimeout(() => { this.debounceTimers.delete(key); requestFn(); }, delay); this.debounceTimers.set(key, timer);}; }
/**
节流请求
@param {string} key - 请求的唯一标识
@param {Function} requestFn - 实际发起请求的函数
@param {number} interval - 执行间隔 */ throttleRequest(key, requestFn, interval = 500) { return () => {
const now = Date.now(); const lastTime = this.throttleLastTime.get(key) || 0; if (now - lastTime >= interval) { this.throttleLastTime.set(key, now); requestFn(); }}; }
/**
带缓存和去重的请求
@param {string} url - 请求地址
@param {object} options - 请求选项
@param {number} cacheTime - 缓存时间 */ smartFetch(url, options = {}, cacheTime = 5 * 60 * 1000) { const key =
${options.method || 'GET'}:${url}:${JSON.stringify(options.params || {})};// 1. 检查缓存 if (this.cache.has(key)) {
const { data, timestamp } = this.cache.get(key); if (Date.now() - timestamp < cacheTime) { console.log(`[Cache Hit] ${key}`); return Promise.resolve(data); }}
// 2. 检查是否已有pending请求 if (this.pending.has(key)) {
console.log(`[Request Dedup] ${key}`); return this.pending.get(key);}
// 3. 发起真实请求 console.log(
[New Request] ${key}); const promise = fetch(url, options).then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then(data => { // 存入缓存 this.cache.set(key, { data, timestamp: Date.now() }); return data; }) .catch(error => { // 请求失败,不缓存 console.error(`[Request Failed] ${key}`, error); throw error; }) .finally(() => { // 从pending中移除 this.pending.delete(key); });// 存入pending this.pending.set(key, promise);
return promise; }
/**
- 清除缓存 */ clearCache(key = null) { if (key) { this.cache.delete(key); this.pending.delete(key); } else { this.cache.clear(); this.pending.clear(); } console.log(‘缓存已清除’); } }
// 创建实例 const optimizer = new RequestOptimizer();
// 使用示例:搜索框防抖请求 const searchInput = document.getElementById(‘search-input’); searchInput.addEventListener(‘input’, optimizer.debounceRequest(
'search',
()
