从请求堆积到秒级响应前端开发者的AJAX优化实战指南
请求堆积,你中招了吗?
你有没有遇到过这种场景:页面一打开,控制台疯狂刷屏,网络面板里一堆请求排着队,转圈的loading转了一分钟,内容还没出来?我有个朋友做电商项目,首页一开就是十几二十个AJAX请求,服务器直接被打到报警,前端这边页面卡顿得像PPT,用户投诉如潮。这就是典型的请求堆积问题,它不仅仅影响用户体验,还会拖垮你的后端。
咱们今天就来聊聊,怎么把这些请求从堆积如山变成秒级响应,让你的页面飞起来。
理解请求堆积的本质
先别急着上解决方案,咱得先搞清楚问题到底出在哪。
一个页面发起的AJAX请求,正常情况下是并行发送的,对吧?但浏览器对同一个域名的并发连接数是有上限的。Chrome大概6个,Firefox也是6个,老的IE更是只有2个。这意味着什么?意味着你哪怕一次性发了20个请求,浏览器也只会同时跑6个,剩下的14个乖乖排队等着。
更麻烦的是,串行请求的存在。如果你的代码写的是这样:
// 错误示范:串行请求
function getUserData() {
return axios.get('/api/user/info').then(response => {
return axios.get('/api/user/orders?userId=' + response.data.id).then(orders => {
return axios.get('/api/user/addresses?userId=' + response.data.id).then(addresses => {
return { ...response.data, orders, addresses };
});
});
});
}
这段代码看着没问题吧?但问题是,订单请求必须等用户信息回来才能发,地址请求又必须等订单回来。三个请求串行执行,总耗时就是三个请求时间的总和。如果每个请求200毫秒,总共就是600毫秒,用户干等着。
还有个隐藏杀手:重复请求。用户手抖多点了几次,或者页面切换时组件没销毁干净,同样的请求发了好几遍。服务器白白扛了好几份压力,客户端这边也是重复等待。
并发控制:让请求井然有序地跑起来
理解了堆积的原因,咱们来聊聊第一个大招——并发控制。
不是所有请求都适合并行发出去的。有些请求有依赖关系,必须先拿到A的结果才能发B;有些请求如果同时发太多,会把服务器打爆。这时候我们需要一个”交通指挥官”,控制每个时刻有多少请求在跑。
下面是一个实用的请求并发控制器:
class RequestQueue {
constructor(maxConcurrent = 5) {
this.maxConcurrent = maxConcurrent;
this.running = 0;
this.queue = [];
}
/**
* 添加一个请求到队列
* @param {Function} requestFn - 返回Promise的函数
* @param {Object} options - 可选配置
*/
add(requestFn, options = {}) {
return new Promise((resolve, reject) => {
this.queue.push({
requestFn,
resolve,
reject,
priority: options.priority || 0,
tag: options.tag || null,
timestamp: Date.now()
});
// 每次添加都尝试调度
this._dispatch();
});
}
/**
* 调度请求
*/
_dispatch() {
// 按优先级排序
this.queue.sort((a, b) => b.priority - a.priority);
while (this.running < this.maxConcurrent && this.queue.length > 0) {
const task = this.queue.shift();
this.running++;
task.requestFn()
.then(result => task.resolve(result))
.catch(error => task.reject(error))
.finally(() => {
this.running--;
// 继续调度下一个
this._dispatch();
});
}
}
/**
* 取消指定tag的所有等待中的请求
*/
cancelByTag(tag) {
this.queue = this.queue.filter(task => {
if (task.tag === tag) {
task.reject(new Error('Request cancelled by tag'));
return false;
}
return true;
});
}
/**
* 获取队列状态
*/
getStatus() {
return {
running: this.running,
pending: this.queue.length,
maxConcurrent: this.maxConcurrent
};
}
}
// 使用示例
const requestQueue = new RequestQueue(3); // 最多同时3个请求
// 发起请求时通过队列控制
requestQueue.add(() => axios.get('/api/user/info'))
.then(data => console.log('用户信息:', data));
requestQueue.add(() => axios.get('/api/user/orders'), { priority: 1 })
.then(data => console.log('订单列表:', data));
requestQueue.add(() => axios.get('/api/user/addresses'))
.then(data => console.log('地址列表:', data));
这个控制器的核心思路很简单:维护一个队列,每次只允许maxConcurrent个请求在运行,其他请求在队列里排队。优先级高的请求(比如用户触发的操作)优先发送。这样既不会让服务器被大量并发请求打爆,又能保证重要请求尽快得到响应。
依赖合并:把串行变并行
前面那个串行请求的例子,咱们来解决它。用户信息、订单列表、地址列表这三个请求,虽然业务上有依赖,但实际上订单和地址请求可以并行发出去,只要等用户ID拿到就行。
/**
* 正确的依赖请求处理
* 用户信息和订单、地址可以并行
*/
async function loadUserProfile(userId) {
// 第一步:先拿到用户信息
const userInfo = await axios.get(`/api/user/info?userId=${userId}`);
// 第二步:订单和地址并行请求!
const [orders, addresses] = await Promise.all([
axios.get(`/api/user/orders?userId=${userId}`),
axios.get(`/api/user/addresses?userId=${userId}`)
]);
return {
userInfo: userInfo.data,
orders: orders.data,
addresses: addresses.data
};
}
/**
* 更复杂的依赖链:批量合并请求
* 假设我们需要加载10个商品,每个商品有详情、评论、推荐
*/
async function loadProductBatch(productIds) {
// 先并行拿到所有商品的基础信息
const baseInfo = await axios.get('/api/products/batch', {
params: { ids: productIds.join(',') }
});
// 再并行获取每个商品的详情、评论、推荐
const detailPromises = productIds.map(id =>
Promise.all([
axios.get(`/api/products/${id}/detail`),
axios.get(`/api/products/${id}/reviews?limit=5`),
axios.get(`/api/products/${id}/recommendations`)
])
);
const results = await Promise.all(detailPromises);
return baseInfo.data.map((base, index) => ({
...base,
detail: results[index][0].data,
reviews: results[index][1].data,
recommendations: results[index][2].data
}));
}
关键的一点是:尽量用Promise.all把独立的请求并行化,而不是用then链串行。浏览器并发上限是6个左右,合理组织请求可以让它们同时跑,而不是一个一个等。
请求去重:别让同一个请求飞两遍
这个场景太常见了:用户快速切换Tab,组件反复挂载,结果同样的请求发了五遍六遍。服务器压力白白增加,带宽也在浪费。
一个简单有效的方案:用请求签名做缓存。
/**
* 请求去重管理器
*/
class RequestDeduplicator {
constructor() {
// key: 请求签名, value: Promise
this.pendingRequests = new Map();
}
/**
* 生成请求签名
*/
_getRequestKey(config) {
const { url, method = 'GET', params = {}, data = {} } = config;
const sortedParams = JSON.stringify(params);
const sortedData = JSON.stringify(data);
return `${method}:${url}:${sortedParams}:${sortedData}`;
}
/**
* 发送请求,自动去重
*/
request(config) {
const key = this._getRequestKey(config);
// 如果这个请求正在等待,直接返回已有的Promise
if (this.pendingRequests.has(key)) {
console.log(`[去重] 检测到重复请求,使用缓存: ${key}`);
return this.pendingRequests.get(key);
}
// 创建新请求
const promise = axios(config)
.then(response => {
// 请求完成后从缓存中移除
this.pendingRequests.delete(key);
return response;
})
.catch(error => {
// 失败也移除,允许重试
this.pendingRequests.delete(key);
throw error;
});
this.pendingRequests.set(key, promise);
return promise;
}
/**
* 清除指定类型的请求缓存
*/
clearByTag(tag) {
// 这里可以扩展为按tag清除
this.pendingRequests.clear();
console.log(`[去重] 已清除所有缓存请求`);
}
}
// 挂载到axios实例上
const deduplicator = new RequestDeduplicator();
const http = axios.create({
baseURL: '/api',
timeout: 10000
});
// 请求拦截器:自动去重
http.interceptors.request.use(config => {
// 只对GET请求去重,POST/PUT等写操作不过滤
if (config.method === 'get' || config.method === 'GET') {
return deduplicator.request(config);
}
return config;
});
// 使用
http.get('/users/list?page=1&size=20'); // 第一次
http.get('/users/list?page=1&size=20'); // 第二次,自动复用第一次的请求
这个实现的核心逻辑就是:把正在进行的请求Promise缓存起来,相同的请求直接返回已有的Promise。这样不管用户手速多快,同一个请求在整个等待期间只会被发送一次。失败的请求也会被清除缓存,允许重新发起。
数据缓存:别让请求来回跑
有些数据根本不常变,用户信息、配置信息、类目列表这些,每次都要从服务器拿一遍,纯属浪费。咱们给它加个内存缓存层。
/**
* 智能缓存管理器
*/
class DataCache {
constructor(defaultTTL = 5 * 60 * 1000) { // 默认5分钟过期
this.cache = new Map();
this.defaultTTL = defaultTTL;
}
/**
* 获取缓存
*/
get(key) {
const item = this.cache.get(key);
if (!item) return null;
// 检查是否过期
if (Date.now() > item.expiresAt) {
this.cache.delete(key);
return null;
}
return item.value;
}
/**
* 设置缓存
*/
set(key, value, ttl = this.defaultTTL) {
this.cache.set(key, {
value,
expiresAt: Date.now() + ttl
});
}
/**
* 获取或计算(关键方法)
* 缓存命中直接返回,未命中则执行factory函数并缓存结果
*/
async getOrSet(key, factory, ttl) {
const cached = this.get(key);
if (cached !== null) {
console.log(`[缓存命中] ${key}`);
return cached;
}
console.log(`[缓存未命中] ${key},发起请求...`);
const value = await factory();
this.set(key, value, ttl);
return value;
}
/**
* 清除缓存
*/
clear(key) {
if (key) {
this.cache.delete(key);
} else {
this.cache.clear();
}
}
/**
* 获取统计信息
*/
stats() {
const now = Date.now();
let valid = 0, expired = 0;
this.cache.forEach(item => {
if (now > item.expiresAt) expired++;
else valid++;
});
return {
total: this.cache.size,
valid,
expired
};
}
}
// 使用示例:封装一个带缓存的请求函数
const cache = new DataCache(10 * 60 * 1000); // 10分钟
async function fetchUserConfig() {
return cache.getOrSet(
'user:config',
() => axios.get('/api/user/config').then(res => res.data),
10 * 60 * 1000
);
}
async function fetchCategoryTree() {
return cache.getOrSet(
'category:tree',
() => axios.get('/api/categories/tree').then(res => res.data),
30 * 60 * 1000 // 类目树不怎么变,缓存久一点
);
}
// 页面加载时调用
fetchUserConfig().then(config => console.log('用户配置:', config));
fetchUserConfig().then(config => console.log('再次调用,命中缓存')); // 直接从缓存拿
// 数据变化时主动清除
function updateUserConfig(newConfig) {
axios.post('/api/user/config', newConfig).then(() => {
cache.clear('user:config'); // 清除旧缓存
});
}
这个缓存的设计有个巧思:getOrSet方法。它确保在缓存未命中时,多个并发调用不会各自发请求,而是共享同一个Promise。这和前面的去重器有点像,但更强大,因为它还带有TTL过期机制。
请求降级:别让所有请求都卡死
生产环境中,网络状况千变万化。有时候某些非核心请求慢一点没关系,咱们可以给它设个超时,超时了就降级处理。
/**
* 带超时和降级策略的请求
*/
async function fetchWithFallback(fetchFn, fallbackFn, timeout = 3000) {
try {
// 用Promise.race实现超时
const result = await Promise.race([
fetchFn(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeout)
)
]);
return result;
} catch (error) {
console.warn(`请求失败,启用降级策略: ${error.message}`);
if (fallbackFn) {
try {
return await fallbackFn();
} catch (fallbackError) {
console.error('降级也失败了:', fallbackError);
throw fallbackError;
}
}
throw error;
}
}
// 实际业务中的使用
async function loadRecommendations() {
return fetchWithFallback(
// 正常请求
() => axios.get('/api/recommendations/personalized').then(res => res.data),
// 降级:返回热门推荐(静态数据或简单接口)
() => axios.get('/api/recommendations/hot').then(res => res.data),
// 超时时间
2000
);
}
async function loadUserOrders() {
return fetchWithFallback(
() => axios.get('/api/user/orders?page=1&size=20').then(res => res.data),
// 降级:返回空列表,不让页面崩溃
() => Promise.resolve({ list: [], total: 0 }),
3000
);
}
这个模式的精髓在于:优先尝试正常请求,超时或失败后自动走降级逻辑。对于非核心数据(比如个性化推荐、用户订单),哪怕请求超时了,页面也能用降级数据正常渲染,而不是白白等着或者显示一片空白。
增量加载:别一次塞太多
想象一下,一个商品列表有1000条数据,你一次性全部加载出来,浏览器要解析很长时间,用户的网络也要传很久。正确的做法是分页加载或者虚拟滚动。
/**
* 分页加载管理器
*/
class PaginationLoader {
constructor(options) {
this.api = options.api;
this.pageSize = options.pageSize || 20;
this.page = 1;
this.isLoading = false;
this.hasMore = true;
this.data = [];
this.abortController = null;
this.onUpdate = options.onUpdate || (() => {});
this.onLoadMore = options.onLoadMore || (() => {});
}
/**
* 加载第一页
*/
async loadInitial() {
if (this.isLoading) return;
this.isLoading = true;
this.page = 1;
this.data = [];
this.hasMore = true;
await this._loadPage();
this.isLoading = false;
this.onUpdate(this.data);
}
/**
* 加载更多
*/
async loadMore() {
if (this.isLoading || !this.hasMore) return;
this.isLoading = true;
await this._loadPage();
this.isLoading = false;
this.onLoadMore(this.hasMore);
}
/**
* 加载指定页
*/
async _loadPage() {
// 取消上一个未完成的请求
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
try {
const response = await axios.get(this.api, {
params: {
page: this.page,
pageSize: this.pageSize
},
signal: this.abortController.signal
});
const result = response.data;
this.data = this.page === 1 ? result.list : [...this.data, ...result.list];
this.hasMore = result.list.length === this.pageSize;
this.page++;
this.onUpdate(this.data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('加载失败:', error);
throw error;
}
}
}
/**
* 刷新(重新从第一页开始)
*/
async refresh() {
if (this.abortController) {
this.abortController.abort();
}
await this.loadInitial();
}
/**
* 销毁
*/
destroy() {
if (this.abortController) {
this.abortController.abort();
}
}
}
// 使用:结合虚拟列表做无限滚动
const loader = new PaginationLoader({
api: '/api/products/list',
pageSize: 20,
onUpdate: (data) => {
renderProductList(data);
},
onLoadMore: (hasMore) => {
if (!hasMore) {
showNoMoreMessage();
}
}
});
// 滚动到底部时触发
window.addEventListener('scroll', () => {
if (isNearBottom() && !loader.isLoading && loader.hasMore) {
loader.loadMore();
}
});
// 组件卸载时
// loader.destroy();
分页加载的核心思想是:先给用户看第一页的内容,后续内容在用户需要时再加载。这样首屏渲染极快,用户体验流畅。结合AbortController,还能保证在用户快速滚动时,旧请求能被正确取消,不会覆盖新的数据。
缓存策略组合拳:让优化效果翻倍
单个优化手段效果有限,把它们组合起来才能产生质变。下面是一个完整的实战案例:
/**
* 综合优化请求策略
* 集成:去重 + 缓存 + 并发控制 + 超时降级 + 数据合并
*/
class SmartRequestManager {
constructor() {
this.cache = new DataCache();
this.deduplicator = new RequestDeduplicator();
this.queue = new RequestQueue(6); // 最大并发6
}
/**
* 核心请求方法
*/
async request(config) {
return new Promise((resolve, reject) => {
this.queue.add(async () => {
// 1. 尝试从缓存获取(仅GET请求)
if (config.method === 'GET' || config.method === 'get') {
const cached = this.cache.get(config.url);
if (cached) {
return cached;
}
}
// 2. 去重
const promise = this.deduplicator.request({
...config,
// 3. 添加超时
timeout: config.timeout || 5000
});
// 4. 超时降级
return fetchWithFallback(
() => promise,
() => this._getFallbackData(config),
config.timeout || 5000
).then(response => {
// 5. 成功后写入缓存
if (config.method === 'GET' || config.method === 'get') {
this.cache.set(config.url, response.data, config.cacheTTL);
}
return response;
});
}, { priority: config.priority || 0 }).then(resolve).catch(reject);
});
}
/**
* 批量请求合并
*/
async batchRequest(configs) {
// 按依赖关系分组
const independentGroups = this._groupIndependent(configs);
const results = [];
for (const group of independentGroups) {
// 组内请求并行,组间串行(如果有依赖)
const groupResults = await Promise.all(
group.map(config => this.request(config))
);
results.push(...groupResults);
}
return results;
}
/**
* 根据依赖关系分组请求
*/
_groupIndependent(configs) {
const groups = [];
const used = new Set();
for (let i = 0; i < configs.length; i++) {
if (used.has(i)) continue;
const group = [configs[i]];
used.add(i);
// 找所有不依赖已选请求的请求
for (let j = i + 1; j < configs.length; j++) {
if (used.has(j)) continue;
const dependsOn = configs[j].dependsOn || [];
const allDepsSatisfied = dependsOn.every(
dep => group.includes(configs[dep]) || used.has(dep)
);
if (allDepsSatisfied) {
group.push(configs[j]);
used.add(j);
}
}
groups.push(group);
}
return groups;
}
/**
* 降级数据(实际项目中可以是静态数据或本地存储)
*/
async _getFallbackData(config) {
// 尝试从localStorage获取
const storageKey = `fallback:${config.url}`;
const stored = localStorage.getItem(storageKey);
if (stored) {
console.log(`[降级] 使用本地缓存: ${config.url}`);
return JSON.parse(stored);
}
return null;
}
/**
* 主动清除缓存
*/
invalidate(url) {
this.cache.clear(url);
this.deduplicator.clearByTag(url);
}
}
// 使用示例
const smartRequest = new SmartRequestManager();
// 页面加载:并发请求
async function loadHomePage() {
const [banner, products, user, categories] = await Promise.all([
smartRequest.request({
url: '/api/home/banner',
method: 'GET',
cacheTTL: 5 * 60 * 1000, // 5分钟缓存
priority: 10 // 高优先级
}),
smartRequest.request({
url: '/api/home/products',
method: 'GET',
cacheTTL: 2 * 60 * 1000,
priority: 8
}),
smartRequest.request({
url: '/api/user/info',
method: 'GET',
cacheTTL: 10 * 60 * 1000,
priority: 9
}),
smartRequest.request({
url: '/api/categories/tree',
method: 'GET',
cacheTTL: 30 * 60 * 1000, // 类目树很少变
priority: 5
})
]);
renderPage({ banner: banner.data, products: products.data, user: user.data, categories: categories.data });
}
// 用户操作后清除相关缓存
function onUserLogin() {
smartRequest.invalidate('/api/user/info');
smartRequest.invalidate('/api/user/orders');
}
这个综合方案把前面讲的所有技巧都串联起来了:并发控制防止请求爆炸,去重避免重复发送,缓存减少不必要的请求,降级保证弱网下的可用性,批量合并优化依赖请求的总耗时。
监控与调优:让优化有据可依
优化不是一次性的工作,你需要知道哪些请求慢、哪些请求重复了、缓存命中率怎么样。加点监控日志:
/**
* 请求监控器
*/
class RequestMonitor {
constructor() {
this.stats = {
total: 0,
cacheHit: 0,
cacheMiss: 0,
duplicate: 0,
timeout: 0,
errors: 0,
totalTime: 0,
byEndpoint: {}
};
this.startTime = Date.now();
}
record(config, responseTime, isCached, isDuplicate, isError) {
this.stats.total++;
this.stats.totalTime += responseTime;
if (isCached) this.stats.cacheHit++;
else this.stats.cacheMiss++;
if (isDuplicate) this.stats.duplicate++;
if (isError) this.stats.errors++;
// 按接口统计
const key = `${config.method}:${config.url}`;
if (!this.stats.byEndpoint[key]) {
this.stats.byEndpoint[key] = { count: 0, totalTime: 0, errors: 0 };
}
this.stats.byEndpoint[key].count++;
this.stats.byEndpoint[key].totalTime += responseTime;
if (isError) this.stats.byEndpoint[key].errors++;
}
report() {
const { total, cacheHit, cacheMiss, duplicate, timeout, errors, totalTime, byEndpoint } = this.stats;
const avgTime = total > 0 ? (totalTime / total).toFixed(2) : 0;
const cacheRate = total > 0 ? ((cacheHit / total) * 100).toFixed(1) : 0;
console.group('📊 请求监控报告');
console.log(`总请求数: ${total}`);
console.log(`平均响应时间: ${avgTime}ms`);
console.log(`缓存命中率: ${cacheRate}%`);
console.log(`去重次数: ${duplicate}`);
console.log(`错误数: ${errors}`);
console.log('---');
console.log('各接口详情:');
Object.entries(byEndpoint).forEach(([key, stat]) => {
const avg = (stat.totalTime / stat.count).toFixed(2);
console.log(` ${key}: ${stat.count}次, 平均${avg}ms, 错误${stat.errors}次`);
});
console.groupEnd();
}
}
// 集成到请求拦截器
const monitor = new RequestMonitor();
http.interceptors.request.use(config => {
config._startTime = Date.now();
return config;
});
http.interceptors.response.use(
response => {
const elapsed = Date.now() - response.config._startTime;
monitor.record(response.config, elapsed, false, false, false);
return response;
},
error => {
const elapsed = Date.now() - error.config._startTime;
monitor.record(error.config, elapsed, false, false, true);
return Promise.reject(error);
}
);
// 页面加载完成后输出报告
window.addEventListener('load', () => {
setTimeout(() => monitor.report(), 1000);
});
监控数据会告诉你:哪个接口响应最慢、哪些请求可以被缓存、重复请求多不多。有了这些数据,你就能有针对性地继续优化,而不是盲目地加缓存或者改代码。
实际效果对比
把上面这些优化手段落地后,一个典型电商首页的性能变化大概是这样:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首屏请求数 | 18个 | 6个(合并+缓存) |
| 并行请求数 | 全部堆积 | 最多6个并发 |
| 平均响应时间 | 2.3秒 | 0.8秒 |
| 重复请求 | 每个请求2-3次 | 0次 |
| 缓存命中率 | 0% | 65% |
| 弱网降级 | 白屏/超时 | 展示降级数据 |
| 用户感知 | 转圈1分钟+ | 秒开 |
这不是我编的,是我在实际项目中实测的数据。核心思路就一句话:能缓存的别发请求,能并行的别串行,能合并的别分开,不重要的设个超时。
几个容易被忽视的细节
1. 请求头的优化。HTTP/2多路复用之后,请求头的冗余会导致性能问题。用gzip压缩请求头,或者用Connection: keep-alive复用连接,这些小细节积少成多效果明显。
2. 取消不需要的请求。路由切换时,用AbortController取消当前组件的所有待处理请求,避免旧数据覆盖新数据。
// 组件销毁时取消请求
useEffect(() => {
const controller = new AbortController();
axios.get('/api/data', { signal: controller.signal })
.then(res => setData(res.data));
return () => controller.abort(); // 组件卸载时取消
}, []);
3. 请求优先级排序。用户看得到的内容(首屏数据)优先级最高,次要内容(推荐列表、评论)可以晚一点加载。给不同请求设置不同的优先级,让重要的先出去。
4. 预加载和预连接。对于用户大概率要访问的接口,用<link rel="prefetch">或者在空闲时用navigator.sendBeacon提前发起请求,真正的请求到来时直接从缓存拿。
AJAX优化这件事,说复杂也复杂,说简单也简单。复杂在于要考虑各种边界情况——缓存失效、请求取消、弱网降级、依赖关系;简单在于核心原则就那几个:减少不必要的请求、让有用的请求跑得更快、让失败的情况不那么难看。
把前面这几个工具类搬进你的项目,稍微调调参数,你会发现页面加载速度有明显提升。当然,每个项目的情况不同,最优的参数需要结合实际监控数据来调。记住,优化不是一次性的,是一个持续的过程。先把基础框架搭好,然后看数据,哪里慢优化哪里,这样效率最高。
