网站加载慢请求频繁导致性能问题开发者必看AJAX优化实战技巧从请求合并缓存策略防抖节流优先级处理到错误重试完整解决方案
先说一个最近让我印象特别深的场景。上个月帮一个做电商后台的朋友排查问题,后台页面打开一次要8秒多,F12打开一看,网络面板密密麻麻全是请求,单页面同时发了47个AJAX请求,加载时间长就不说了,用户点两下就触发一堆重复请求,服务器差点被打崩。这个问题我们折腾了一周,用了各种手段优化,最后把首屏请求压到8个以内,加载时间从8秒降到1.2秒。我把这个过程和方法整理出来,希望能帮到正在被类似事情折磨的你。
请求合并:别再发那么多重复的请求了
我们先从最直观的问题开始。很多开发者写前端代码的时候,习惯每个数据请求都单独发一个AJAX,结果一个页面里有几十个接口同时飞出去。我见过最离谱的一个监控大屏,数据刷新频率是每秒一次,结果每秒产生60多个请求,这是要逼死后端吗?
请求合并的核心思路是:把同一时间段内需要获取的数据,打包成一个请求发出去。
最常见的场景是多列表单筛选条件变化,每次条件改动都触发多个接口。比如一个后台管理系统,左边是分类树,右边是商品列表,每次点击分类,同时触发「分类详情」「商品列表」「统计数据」三个请求。如果用户快速切换5次分类,就是15个请求同时打向服务器。
// 原始写法 - 每次变化都发请求
categorySelect.addEventListener('change', (e) => {
fetchCategoryDetail(e.target.value); // 请求1
fetchProductList(e.target.value); // 请求2
fetchStatistics(e.target.value); // 请求3
});
// 优化后 - 请求合并
function createRequestMerge() {
let pendingRequests = new Map();
let flushTimer = null;
function flush() {
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(async () => {
const requests = Array.from(pendingRequests.values());
pendingRequests.clear();
// 用一个 Promise.all 并发执行合并后的请求
await Promise.all(requests.map(req => req()));
}, 100); // 100ms 内累积的请求会被合并
}
return {
add(key, requestFn) {
if (pendingRequests.has(key)) {
pendingRequests.delete(key); // 新的请求覆盖旧的
}
pendingRequests.set(key, requestFn);
flush();
}
};
}
// 使用
const merge = createRequestMerge();
categorySelect.addEventListener('change', (e) => {
const categoryId = e.target.value;
merge.add('detail', () => fetchCategoryDetail(categoryId));
merge.add('list', () => fetchProductList(categoryId));
merge.add('stats', () => fetchStatistics(categoryId));
});
上面这个方案的核心是一个时间窗口。100ms内收集到的请求会被打包,然后用 Promise.all 并发执行。关键是 pendingRequests.delete(key) 这一行——如果同一个key在窗口期内又被添加了,旧的请求直接丢弃,用新的替代,避免浪费。
还有一个更激进的方案是请求折叠。比如搜索框,用户输入”苹果”,中间经过了”苹”→”苹果”两个状态,”苹”对应的请求根本不需要发,直接等用户打完再发最后一次。这就是下面要讲的防抖,但它和请求合并配合使用效果更好。
缓存策略:能不发就不发
缓存是AJAX优化里性价比最高的一块,没有之一。一个好的缓存策略,能让你50%以上的请求直接不走网络。
这里我要分几个层面来讲,因为不同的场景对应不同的缓存方案。
内存缓存:第一道防线
很多数据请求一次就够了,比如系统配置、字典数据、权限信息。这些数据不会频繁变化,完全没有必要每次都去请求。
class RequestCache {
constructor(ttl = 5 * 60 * 1000) {
this.cache = new Map();
this.ttl = ttl;
}
get(key) {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > this.ttl) {
this.cache.delete(key);
return null;
}
return entry.data;
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
});
}
has(key) {
return this.get(key) !== null;
}
invalidate(key) {
this.cache.delete(key);
}
invalidateByPrefix(prefix) {
for (const key of this.cache.keys()) {
if (key.startsWith(prefix)) {
this.cache.delete(key);
}
}
}
}
// 封装带缓存的fetch
const cache = new RequestCache(10 * 60 * 1000); // 10分钟过期
async function cachedFetch(url, options = {}) {
const cacheKey = `${options.method || 'GET'}:${url}`;
// 优先读缓存
const cached = cache.get(cacheKey);
if (cached && !options.forceRefresh) {
return cached;
}
// 缓存没有,发请求
const response = await fetch(url, options);
const data = await response.json();
cache.set(cacheKey, data);
return data;
}
// 使用
const userInfo = await cachedFetch('/api/user/info');
const config = await cachedFetch('/api/config/system');
这个内存缓存虽然简单,但能解决80%的重复请求问题。关键是设置合理的过期时间。系统配置可以缓存30分钟甚至更久,用户信息建议5分钟,商品详情这种变化频繁的数据可以缩短到1分钟或者不做缓存直接走网络。
HTTP缓存:让浏览器帮你缓存
除了应用层缓存,HTTP层面的缓存也很关键。合理配置 Cache-Control 和 ETag,可以让浏览器自动复用缓存,不发任何网络请求。
// 响应头示例
Cache-Control: public, max-age=600 // 公共缓存,10分钟内直接使用缓存
ETag: "abc123-xyz" // 实体标签,缓存过期后用条件请求校验
在前端代码里,对于支持条件请求的接口,可以这样做:
async function smartFetch(url, options = {}) {
const cacheKey = `req:${url}`;
let etag = sessionStorage.getItem(cacheKey);
const fetchOptions = { ...options };
// 如果有ETag,带上 If-None-Match 头
if (etag) {
fetchOptions.headers = {
...fetchOptions.headers,
'If-None-Match': etag
};
}
const response = await fetch(url, fetchOptions);
// 304 Not Modified - 使用缓存
if (response.status === 304) {
const cached = JSON.parse(sessionStorage.getItem(`${cacheKey}:data`));
return cached;
}
// 200 OK - 更新缓存
const data = await response.json();
const newEtag = response.headers.get('ETag');
if (newEtag) {
sessionStorage.setItem(cacheKey, newEtag);
sessionStorage.setItem(`${cacheKey}:data`, JSON.stringify(data));
}
return data;
}
这里用 sessionStorage 而不是 localStorage 的原因是,sessionStorage 在当前标签页会话内有效,关闭标签页就清空,非常适合单次会话内的数据缓存,不会跟服务端缓存策略冲突。
虚拟缓存:缓存整个请求链
复杂页面往往有多个关联请求,比如先获取用户信息,再根据用户信息获取权限列表,再根据权限渲染菜单。这种场景下,可以缓存整个请求链的结果。
class ChainCache {
constructor() {
this.cache = new Map();
}
// 注册一个请求链
register(chainId, chainFn) {
this.cache.set(chainId, {
fn: chainFn,
data: null,
timestamp: 0
});
}
// 执行或返回缓存
async execute(chainId, force = false) {
const entry = this.cache.get(chainId);
if (!force && entry.data && Date.now() - entry.timestamp < 30000) {
return entry.data;
}
const data = await entry.fn();
entry.data = data;
entry.timestamp = Date.now();
return data;
}
// 失效指定链
invalidate(chainId) {
const entry = this.cache.get(chainId);
if (entry) {
entry.data = null;
entry.timestamp = 0;
}
}
}
// 使用示例
const chainCache = new ChainCache();
chainCache.register('user-chain', async () => {
const user = await fetch('/api/user/info');
const permissions = await fetch('/api/user/permissions', {
headers: { 'User-Id': user.id }
});
const menus = await fetch('/api/menu/list', {
headers: { 'User-Id': user.id }
});
return { user, permissions, menus };
});
这个方案把一组有依赖关系的请求打包成一个”链”,第一次执行后缓存结果,后续直接返回,直到主动失效或过期。对于复杂后台系统来说,这种缓存方式特别实用。
防抖和节流:控制请求的”流量”
这是前端性能优化的经典话题,但在AJAX场景下有它独特的用法。很多人知道防抖节流,但不知道在什么场景用哪个,用多少毫秒合适。
防抖:适合搜索框、筛选条件
防抖的核心是最后一次操作之后等一段时间再触发。搜索框是最典型的场景——用户输入”react”的过程中,”r”、”re”、”rea”、”react”这些中间状态都不需要发请求,只要等用户停下来,发最后一次就够了。
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 搜索框优化
const searchInput = document.getElementById('search');
let debounceTimer = null;
searchInput.addEventListener('input', debounce((e) => {
const keyword = e.target.value.trim();
if (keyword.length < 2) return; // 字符太少不发请求
// 这里用防抖包裹的函数
fetchResults(keyword);
}, 400));
// 更完整的防抖搜索组件
class DebouncedSearch {
constructor(input, fetchFn, options = {}) {
this.input = input;
this.fetchFn = fetchFn;
this.delay = options.delay || 400;
this.minLength = options.minLength || 2;
this.loading = false;
this.currentRequest = null;
this.input.addEventListener('input', this.handleInput.bind(this));
}
handleInput() {
const value = this.input.value.trim();
if (value.length < this.minLength) {
this.renderEmpty();
return;
}
// 取消上一次未发送的请求
if (this.currentRequest) {
this.currentRequest.abort();
}
// 防抖延迟
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(async () => {
this.loading = true;
this.showLoading();
this.currentRequest = new AbortController();
try {
const results = await this.fetchFn(value, this.currentRequest.signal);
if (!this.currentRequest.signal.aborted) {
this.renderResults(results);
}
} catch (err) {
if (err.name !== 'AbortError') {
this.renderError(err);
}
} finally {
this.loading = false;
this.hideLoading();
this.currentRequest = null;
}
}, this.delay);
}
renderEmpty() { /* ... */ }
renderResults(results) { /* ... */ }
renderError(err) { /* ... */ }
showLoading() { /* ... */ }
hideLoading() { /* ... */ }
}
这个组件有几个细节值得注意:
AbortController—— 取消未完成的请求。防抖延迟期间如果用户又输入了,之前的请求直接取消,避免旧的响应覆盖新的。- 最小字符数限制 —— “a”、”ab”这种太短的关键字,发请求没意义,还浪费资源。
- 状态管理 ——
loading状态防止重复触发。
对于筛选条件,防抖的延迟可以设长一点,600-800ms 比较合适。因为筛选操作比搜索停顿时间更长,用户一般要思考一下再选。
节流:适合滚动加载、实时数据
节流的核心是每隔一段时间执行一次。典型的场景是滚动加载更多数据,或者实时监控系统状态。
function throttle(fn, interval = 200) {
let lastTime = 0;
let timer = null;
return function (...args) {
const now = Date.now();
const remainTime = interval - (now - lastTime);
if (remainTime <= 0) {
// 上次执行时间超过间隔,立即执行
if (timer) {
clearTimeout(timer);
timer = null;
}
lastTime = now;
fn.apply(this, args);
return;
}
// 还没到间隔,用定时器延迟执行
if (!timer) {
timer = setTimeout(() => {
lastTime = Date.now();
timer = null;
fn.apply(this, args);
}, remainTime);
}
};
}
// 滚动加载更多
const scrollContainer = document.getElementById('list-container');
let isLoading = false;
const loadMore = throttle(async () => {
if (isLoading) return;
isLoading = true;
try {
const nextPage = currentPage + 1;
const data = await fetch(`/api/items?page=${nextPage}&size=20`);
if (data.items.length === 0) {
// 没有更多数据
scrollContainer.removeEventListener('scroll', handleScroll);
return;
}
renderItems(data.items);
currentPage = nextPage;
} finally {
isLoading = false;
}
}, 500); // 500ms内只执行一次
scrollContainer.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
// 距离底部100px时触发加载
if (scrollHeight - scrollTop - clientHeight < 100) {
loadMore();
}
});
这里节流间隔设了500ms,对于滚动加载来说比较合适。间隔太短(比如100ms)会让请求还是太频繁,间隔太长(比如1000ms)会让用户体验有延迟感。
还有一个容易忽略的点:节流的第一次触发时机。上面的实现是在间隔到了之后才执行,也就是说第一次滚动到触发位置要等500ms。如果希望立即响应,可以加一个 leading 选项:
function throttle(fn, interval = 200, options = { leading: true }) {
let lastTime = 0;
let timer = null;
return function (...args) {
const now = Date.now();
const remainTime = interval - (now - lastTime);
if (options.leading && remainTime <= 0) {
// 立即执行
if (timer) {
clearTimeout(timer);
timer = null;
}
lastTime = now;
fn.apply(this, args);
return;
}
if (!timer) {
timer = setTimeout(() => {
lastTime = Date.now();
timer = null;
fn.apply(this, args);
}, remainTime);
}
};
}
防抖和节流的场景选择
这个问题很多人搞混,我给你一个简单好记的判断标准:
- 关心”最后一次结果” → 用防抖。搜索、筛选、窗口resize,用户操作完之后你只关心最终状态。
- 关心”均匀执行频率” → 用节流。滚动加载、实时数据、按钮防重复点击,你需要按固定节奏处理。
请求优先级:别让低优先级的请求拖慢页面
这个点很多人会忽略,但它在实际项目中特别重要。想象一下这个场景:
用户打开一个后台页面,同时需要加载:顶部导航的用户信息、左侧菜单的权限列表、中间表格的数据、右边图表的数据、底部通知的数量……如果所有请求同时发出,带宽被平摊,每个请求都变慢。但实际上,用户首先关心的是表格数据,其次才是图表,通知数量可以最后加载。
给请求分级,按优先级调度,是个很有效的手段。
class PriorityQueue {
constructor() {
this.queues = {
high: [], // 高优先级:用户操作直接触发的
normal: [], // 正常优先级:页面加载需要的
low: [] // 低优先级:非必要的数据
};
this.maxConcurrent = 3; // 每个优先级最多同时请求数
this.running = 0;
this.processing = false;
}
// 添加请求到队列
add(requestFn, priority = 'normal') {
this.queues[priority].push({
fn: requestFn,
priority,
timestamp: Date.now()
});
this.processNext();
}
// 取消请求
cancel(predicate) {
for (const key of Object.keys(this.queues)) {
this.queues[key] = this.queues[key].filter(
req => !predicate(req)
);
}
}
// 处理队列
async processNext() {
if (this.processing) return;
this.processing = true;
while (this.hasPending()) {
// 找最高优先级的请求
const request = this.findNext();
if (!request) break;
// 检查并发限制
if (this.running >= this.maxConcurrent) {
await new Promise(resolve => setTimeout(resolve, 50));
continue;
}
this.running++;
// 并发执行,但优先处理高优先级
Promise.resolve(request.fn())
.catch(err => console.error('Request failed:', err))
.finally(() => {
this.running--;
// 从队列中移除
this.removeRequest(request);
});
// 短暂暂停,让其他微任务有机会执行
await new Promise(resolve => setTimeout(resolve, 10));
}
this.processing = false;
}
findNext() {
// 优先取高优先级
if (this.queues.high.length > 0) return this.queues.high.shift();
if (this.queues.normal.length > 0) return this.queues.normal.shift();
if (this.queues.low.length > 0) return this.queues.low.shift();
return null;
}
hasPending() {
return this.queues.high.length > 0 ||
this.queues.normal.length > 0 ||
this.queues.low.length > 0;
}
removeRequest(request) {
for (const key of Object.keys(this.queues)) {
const index = this.queues[key].indexOf(request);
if (index !== -1) {
this.queues[key].splice(index, 1);
}
}
}
}
// 使用示例
const requestQueue = new PriorityQueue();
// 高优先级:用户直接操作触发的
document.getElementById('search-btn').addEventListener('click', () => {
requestQueue.add(() => fetchSearchResults(), 'high');
});
// 正常优先级:页面初始化需要
requestQueue.add(() => fetchTableData(), 'normal');
requestQueue.add(() => fetchUserMenu(), 'normal');
// 低优先级:非关键数据,最后加载
requestQueue.add(() => fetchChartsData(), 'low');
requestQueue.add(() => fetchNotificationCount(), 'low');
这个优先级队列有几个设计细节:
- 并发限制——不是优先级高就无限制并发,
maxConcurrent控制总体并发数,避免把服务器打爆。 - 先进先出——同一优先级内,先入队的先执行,避免饿死。
- 可取消——用户改变操作意图时,可以取消队列中未执行的请求。
实际项目中,我一般会这样划分优先级:
| 优先级 | 场景 | 示例 |
|---|---|---|
| High | 用户主动操作触发 | 搜索、提交表单、翻页 |
| Normal | 页面核心数据 | 列表数据、用户信息、权限菜单 |
| Low | 辅助数据 | 图表、统计数字、通知数 |
| Idle | 空闲时加载 | 预加载、历史数据 |
Idle级别的请求可以用 requestIdleCallback 来做:
function scheduleIdleRequest(requestFn) {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
requestFn();
});
} else {
// 降级处理
setTimeout(requestFn, 100);
}
}
// 页面加载完后,空闲时预加载下页数据
scheduleIdleRequest(() => {
preloadNextPageData();
});
错误重试:网络不稳定时的优雅处理
做了那么多优化,请求还是可能失败。网络波动、服务器超时、接口暂时不可用……这些情况怎么处理?直接失败丢弃?还是给用户展示一个错误页面?都不是,带智能的重试机制才是正确答案。
基础重试:指数退避
最经典的重试策略是指数退避(Exponential Backoff)——第一次等1秒重试,失败等2秒,再失败等4秒……这样可以避免在服务器刚恢复时再次压垮它。
async function fetchWithRetry(url, options = {}, retryConfig = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
maxDelay = 10000,
retryableStatuses = [408, 413, 429, 500, 502, 503, 504],
onRetry = null
} = retryConfig;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// 对于特定状态码也视为需要重试
if (retryableStatuses.includes(response.status)) {
throw new Error(`HTTP ${response.status}`);
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
lastError = error;
if (attempt >= maxRetries) break;
// 指数退避:1s, 2s, 4s...
const delay = Math.min(
baseDelay * Math.pow(2, attempt),
maxDelay
);
// 加上一点随机抖动,避免大量请求同时重试
const jitter = Math.random() * delay * 0.5;
console.log(`Attempt ${attempt + 1} failed: ${error.message}. Retrying in ${(delay + jitter) / 1000}s...`);
if (typeof onRetry === 'function') {
onRetry(attempt, error, delay + jitter);
}
await new Promise(resolve => setTimeout(resolve, delay + jitter));
}
}
throw lastError;
}
这个实现有几个关键点:
- 随机抖动(Jitter)——如果多个客户端同时失败,全部在同一时刻重试,会形成”重试风暴”把刚恢复的服务器再次打垮。加随机因子可以让重试时间分散开。
- 可配置的状态码——429(Too Many Requests)和 5xx 系列是适合重试的,但 400、401、403 这种客户端错误重试也没用,不需要重试。
- 重试回调——让调用方可以记录日志、展示进度等。
智能重试:区分错误类型
不是所有失败都值得重试。有些错误重试多少次都没用,反而浪费资源、拖慢用户体验。
async function smartFetch(url, options = {}) {
const {
maxRetries = 3,
timeout = 10000,
retryStrategies = {
// 网络错误:重试
network: { maxRetries: 3, backoff: 'exponential' },
// 超时:重试但次数少一点
timeout: { maxRetries: 2, backoff: 'linear' },
// 服务器错误:重试
server: { maxRetries: 3, backoff: 'exponential' },
// 客户端错误:不重试
client: { maxRetries: 0 },
// 认证失败:不重试,跳登录
auth: { maxRetries: 0, action: 'redirectToLogin' }
}
} = options;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
// 分析错误类型并决定策略
if (!response.ok) {
let strategy;
if (response.status >= 500) {
strategy = retryStrategies.server;
} else if (response.status === 401 || response.status === 403) {
strategy = retryStrategies.auth;
// 触发登录跳转
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.href);
throw new AuthError('认证失效');
} else if (response.status === 429) {
strategy = retryStrategies.server;
// 读取 Retry-After 头
const retryAfter = response.headers.get('Retry-After');
if (retryAfter) {
const waitTime = parseInt(retryAfter, 10) * 1000;
await sleep(waitTime);
return smartFetch(url, options);
}
} else {
strategy = retryStrategies.client;
}
// 需要重试
if (strategy.maxRetries > 0) {
const delay = calculateDelay(strategy.backoff, 0);
await sleep(delay);
return smartFetch(url, options);
}
throw new HttpError(response.status, response.statusText);
}
return await response.json();
} catch (error) {
// 统一错误处理
if (error.name === 'AbortError') {
throw new TimeoutError(`请求超时: ${url}`);
}
throw error;
}
}
function calculateDelay(backoff, attempt) {
switch (backoff) {
case 'exponential':
return Math.min(1000 * Math.pow(2, attempt), 30000);
case 'linear':
return 1000 * (attempt + 1);
default:
return 1000;
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
这个方案的精华在于根据错误类型走不同的重试策略。服务端5xx错误可以重试,因为可能是暂时性的;客户端4xx错误不应该重试,因为请求本身就有问题;401/403直接跳登录,重试没有意义;429(频率限制)读取 Retry-After 头,等指定时间后再试。
重试时的用户体验
光有重试逻辑还不够,用户得知道发生了什么。一个优雅的重试体验包括:
class RetryManager {
constructor() {
this.retryPromises = new Map();
this.retryCounters = new Map();
}
async fetchWithFeedback(url, options = {}, feedback = {}) {
const {
maxRetries = 3,
showToast = true,
showFallback = true
} = options;
const requestKey = url;
let attempt = 0;
let lastError = null;
// 显示加载中
if (feedback.onStart) feedback.onStart();
while (attempt <= maxRetries) {
try {
const result = await smartFetch(url, options);
// 成功,清除重试状态
this.retryCounters.delete(requestKey);
if (feedback.onSuccess) feedback.onSuccess(result);
return result;
} catch (error) {
lastError = error;
attempt++;
// 记录重试次数
this.retryCounters.set(requestKey, attempt);
if (attempt > maxRetries) break;
// 显示重试提示
if (showToast && feedback.onRetry) {
feedback.onRetry({
attempt,
maxRetries,
error,
retry: () => this.retryRequest(requestKey, url, options, feedback)
});
}
// 指数退避重试
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
await sleep(delay);
}
}
// 所有重试失败
if (showFallback && feedback.onFailed) {
feedback.onFailed(lastError);
} else {
throw lastError;
}
}
retryRequest(requestKey, url, options, feedback) {
this.fetchWithFeedback(url, options, feedback);
}
}
// 使用示例
const retryManager = new RetryManager();
retryManager.fetchWithFeedback('/api/realtime/data', {}, {
onStart: () => showLoadingIndicator(),
onSuccess: (data) => updateChart(data),
onRetry: ({ attempt, maxRetries, retry }) => {
// 显示重试提示,带手动重试按钮
showRetryToast({
message: `数据加载失败 (${attempt}/${maxRetries}),<button onclick="retry()">重试</button>`,
autoHide: false
});
},
onFailed: (error) => {
showFallbackContent('数据暂时不可用,请稍后再试');
}
});
这个方案把重试逻辑和UI反馈分离开,调用方只需要关心”成功时做什么”、”重试时展示什么”、”失败时展示什么”,不需要自己管理重试状态。
一个完整的实战案例
说了这么多理论,来一个真实场景的完整方案。这是一个后台管理系统的首页,需要加载:用户信息、菜单权限、待办通知、统计卡片、最近订单、系统公告。
// ========== 配置层 ==========
const REQUEST_CONFIG = {
cache: new RequestCache(5 * 60 * 1000),
queue: new PriorityQueue(),
retry: new RetryManager()
};
// ========== 请求定义层 ==========
const APIS = {
userInfo: { url: '/api/user/info', priority: 'high', cacheTTL: 10 * 60 * 1000 },
menu: { url: '/api/user/menu', priority: 'normal', cacheTTL: 30 * 60 * 1000 },
notification: { url: '/api/notification/unread', priority: 'low', cacheTTL: 2 * 60 * 1000 },
stats: { url: '/api/dashboard/stats', priority: 'normal', cacheTTL: 3 * 60 * 1000 },
recentOrders: { url: '/api/orders/recent', priority: 'normal', cacheTTL: 1 * 60 * 1000 },
announcement: { url: '/api/system/announcement', priority: 'low', cacheTTL: 10 * 60 * 1000 }
};
// ========== 业务逻辑层 ==========
class DashboardLoader {
constructor() {
this.loadingState = new Map();
this.cachedData = new Map();
}
async loadAll() {
// 并行加载所有请求,但按优先级调度
const loadTasks = Object.entries(APIS).map(([key, config]) =>
this.loadWithStrategy(key, config)
);
// 高优先级先加载,其他排队
await Promise.all(loadTasks);
}
async loadWithStrategy(key, config) {
// 检查缓存
const cached = REQUEST_CONFIG.cache.get(key);
if (cached) {
this.cachedData.set(key, cached);
this.updateUI(key, cached);
return;
}
// 标记加载中
this.loadingState.set(key, true);
this.showLoading(key);
// 带优先级和重试的请求
return new Promise((resolve) => {
REQUEST_CONFIG.queue.add(async () => {
try {
const data = await smartFetch(config.url, {
maxRetries: key === APIS.userInfo.url ? 1 : 3, // 用户信息只重试1次,快速失败
timeout: 8000
});
// 写入缓存
REQUEST_CONFIG.cache.set(key, data, config.cacheTTL);
this.cachedData.set(key, data);
// 更新UI
this.loadingState.set(key, false);
this.updateUI(key, data);
resolve(data);
} catch (error) {
this.loadingState.set(key, false);
this.handleError(key, error);
resolve(null);
}
}, config.priority);
});
}
updateUI(key, data) {
switch (key) {
case 'userInfo':
renderUserInfo(data);
break;
case 'menu':
renderMenu(data);
break;
case 'notification':
renderNotificationBadge(data.count);
break;
case 'stats':
renderStatsCards(data);
break;
case 'recentOrders':
renderRecentOrders(data);
break;
case 'announcement':
renderAnnouncement(data);
break;
}
}
showLoading(key) {
// 根据key显示对应的骨架屏
const skeletonMap = {
userInfo: 'user-skeleton',
menu: 'menu-skeleton',
notification: 'badge-skeleton',
stats: 'stats-skeleton',
recentOrders: 'orders-skeleton',
announcement: 'announcement-skeleton'
};
const el = document.getElementById(skeletonMap[key]);
if (el) el.classList.add('loading');
}
handleError(key, error) {
// 错误降级处理
const fallbackMap = {
userInfo: () => renderGuestInfo(),
stats: () => renderStatsSkeleton(),
recentOrders: () => renderOrdersEmpty(),
notification: () => renderNotificationHidden(),
menu: () => renderDefaultMenu(),
announcement: () => renderAnnouncementEmpty()
};
// 非关键数据失败静默处理,关键数据失败通知用户
const criticalKeys = ['userInfo', 'menu'];
const isCritical = criticalKeys.includes(key);
if (isCritical) {
console.error(`关键数据加载失败 [${key}]:`, error);
showToast('页面数据加载异常,请刷新重试', 'error');
} else {
// 非关键数据直接降级
if (fallbackMap[key]) fallbackMap[key]();
}
}
}
// ========== 初始化 ==========
const dashboard = new DashboardLoader();
// 页面加载完成后启动
document.addEventListener('DOMContentLoaded', () => {
dashboard.loadAll();
});
// 用户登录状态变化时,清除相关缓存
function onUserChanged() {
REQUEST_CONFIG.cache.invalidateByPrefix('userInfo');
REQUEST_CONFIG.cache.invalidateByPrefix('menu');
REQUEST_CONFIG.cache.invalidate('notification');
// 重新加载
dashboard.loadAll();
}
这个方案里有几个值得说的点:
- 缓存和优先级分离——缓存决定”要不要发请求”,优先级决定”什么时候发请求”。两者互不干扰。
- 关键数据和非关键数据区别对待——用户信息和菜单是关键的,失败要通知用户;统计和通知是非关键的,失败就降级展示。
- 骨架屏先行——在数据还没回来之前,先展示骨架屏,给用户”页面在加载”的反馈,比转圈好看多了。
- 登录状态变化时主动失效缓存——这是一个容易被忽略的场景,用户切换账号后,旧的缓存数据应该被清除。
几个容易被忽视的优化细节
最后分享几个在实际项目中踩过的坑和对应的解法。
请求去重
有时候同一个请求会被触发多次,比如组件重复渲染、事件绑定多次。加一个请求去重层很简单:
const pendingRequests = new Map();
function deduplicatedFetch(url, options = {}) {
// 相同url+options的请求只发一次
const key = `${options.method || 'GET'}:${url}`;
if (pendingRequests.has(key)) {
return pendingRequests.get(key);
}
const promise = smartFetch(url, options)
.finally(() => pendingRequests.delete(key));
pendingRequests.set(key, promise);
return promise;
}
取消未使用的请求
组件卸载时、路由切换时,之前发出的请求应该被取消,否则可能触发状态更新报错,或者用旧数据覆盖新数据。
// 每个请求带上AbortController
const controllers = new Map();
function cancelRequest(key) {
const controller = controllers.get(key);
if (controller) {
controller.abort();
controllers.delete(key);
}
}
function requestWithCleanup(key, url, options) {
const controller = new AbortController();
controllers.set(key, controller);
return smartFetch(url, {
...options,
signal: controller.signal
}).finally(() => {
controllers.delete(key);
});
}
// 组件卸载时清理
useEffect(() => {
return () => {
cancelRequest('userInfo');
cancelRequest('menu');
cancelRequest('stats');
};
}, []);
预加载和懒加载
页面里有些数据当前不需要,但可以提前加载。比如用户点击了某个Tab,数据已经好了,打开速度就快。
// 鼠标悬停时预加载
const tabEl = document.getElementById('stats-tab');
let preloadTimer = null;
tabEl.addEventListener('mouseenter', () => {
preloadTimer = setTimeout(() => {
// 提前加载统计数据
preloadData('/api/dashboard/stats').catch(() => {});
}, 200);
});
tabEl.addEventListener('mouseleave', () => {
if (preloadTimer) {
clearTimeout(preloadTimer);
preloadTimer = null;
}
});
// 点击时直接返回缓存
tabEl.addEventListener('click', async () => {
const data = await preloadData('/api/dashboard/stats');
renderStats(data);
});
反之,底部不重要的数据可以懒加载,等用户滚到附近再请求:
// IntersectionObserver 懒加载
const lazyElements = document.querySelectorAll('[data-lazy]');
const lazyObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const apiUrl = el.dataset.lazy;
el.classList.add('loading');
fetchWithRetry(apiUrl, { maxRetries: 2 })
.then(data => {
el.innerHTML = renderTemplate(data);
el.classList.remove('loading');
})
.catch(() => {
el.innerHTML = '<span>加载失败</span>';
el.classList.remove('loading');
});
lazyObserver.unobserve(el);
}
});
}, {
rootMargin: '200px' // 提前200px开始加载
});
lazyElements.forEach(el => lazyObserver.observe(el));
总结一下
AJAX性能优化不是单一技术手段能解决的,它需要一套组合拳:
- 请求合并减少请求数量,从根本上降低负载
- 缓存策略避免重复请求,让已知数据秒级返回
- 防抖节流控制触发频率,不被用户操作带跑偏
- 优先级调度保证关键数据先到达,提升感知速度
- 智能重试应对网络波动,不轻易放弃
最核心的一点是:不要等到出问题了才去优化。在项目初期就把这些机制搭好,后面加新功能的时候就顺理成章了。等页面已经卡成PPT了再回头救火,代价要大得多。
希望这些内容对你有用。如果有具体场景需要讨论,欢迎在评论区聊。
