那天下午,我的项目线上出了个诡异的问题。一个看似普通的登录接口,点击按钮后,整个页面像被冻结了一样,转圈转了两秒才跳出去。用户骂骂咧咧地关了页面,客服那边的工单一堆。
你猜怎么着?那个接口其实只返回了几百字节的数据,响应时间按理说不会超过200毫秒。但问题出在它触发了三个串行的子请求——头像加载、用户偏好设置、最近活动记录。这三个请求是顺序执行的,每个平均300毫秒,加起来就是一秒多。更惨的是,其中任何一个失败,整个登录流程就卡死,没有任何反馈。
这就是我后来决定重构的核心原因:前端不应该让网络请求成为用户体验的瓶颈。
为什么一个请求能让页面”死”掉
先别急着看代码,咱们得理解问题到底出在哪。很多开发者(包括以前的我)有个误区:以为AJAX是异步的,所以不会影响主线程。这句话只对了一半。
网络请求确实是异步的,浏览器会在后台发起请求,不会阻塞JavaScript主线程。但是,用户界面的渲染、交互响应、以及某些同步的代码逻辑,都依赖主线程。当你在请求期间不做任何状态管理,或者因为等待响应而阻塞了后续的UI更新,用户就会感觉”卡死了”。
更糟糕的情况是”瀑布流式请求”。想象一下这个场景:
时间线:
[0ms] 用户点击登录
[10ms] 发起用户信息请求 (ID: req_001)
[20ms] req_001 响应到达,开始处理数据
[50ms] 发起头像请求 (ID: req_002)
[80ms] req_002 响应到达,开始处理
[100ms] 发起偏好设置请求 (ID: req_003)
[130ms] req_003 响应到达
[150ms] 所有数据就绪,渲染页面
用户需要等150毫秒才能看到任何反馈。如果网络波动,每个请求变成500毫秒,总等待时间就是1.5秒。这在移动端简直是灾难。
而且,如果req_002失败了怎么办?传统写法里,你可能需要在每个回调里写错误处理,或者用try-catch包裹。但更常见的情况是:错误被吞掉了,页面既没有成功也没有失败提示,就卡在那儿。
并行请求:把串行变并行
解决这个问题的第一步,是让不依赖彼此的请求同时发起。JavaScript的Promise和async/await让这变得很简单。
看个对比:
串行写法(糟糕):
async function loadUserData(userId) {
// 1. 获取基本信息
const userInfo = await fetch(`/api/users/${userId}`).then(r => r.json());
// 2. 获取头像(依赖1的结果,但网络请求本身可以并行)
const avatar = await fetch(userInfo.avatarUrl).then(r => r.blob());
// 3. 获取偏好设置(完全不依赖前两者)
const preferences = await fetch(`/api/users/${userId}/preferences`).then(r => r.json());
// 4. 获取最近活动(也不依赖前面)
const activities = await fetch(`/api/users/${userId}/activities`).then(r => r.json());
return { userInfo, avatar, preferences, activities };
}
注意到没有?第2、3、4步其实是独立的,但代码里是串行的。改进一下:
并行写法(推荐):
async function loadUserData(userId) {
// 同时发起三个独立的请求
const [userInfoRes, preferencesRes, activitiesRes] = await Promise.all([
fetch(`/api/users/${userId}`),
fetch(`/api/users/${userId}/preferences`),
fetch(`/api/users/${userId}/activities`)
]);
// 等所有响应都回来后,再处理头像(可能需要userInfo里的URL)
const userInfo = await userInfoRes.json();
const preferences = await preferencesRes.json();
const activities = await activitiesRes.json();
// 头像请求可以提前发起,不需要等json解析
const avatarRes = fetch(userInfo.avatarUrl);
const [avatarBlob, finalUserInfo] = await Promise.all([
avatarRes.then(r => r.blob()),
Promise.resolve(userInfo) // 已经解析过了,这里只是类型匹配
]);
return {
userInfo: finalUserInfo,
avatar: avatarBlob,
preferences,
activities
};
}
这里的关键是Promise.all。它接收一个Promise数组,一旦所有Promise都resolve,才返回结果。如果任何一个reject,整个Promise.all就会立即reject。
等等,这听起来有点问题?如果头像请求失败了,是不是整个登录就挂了?别急,这就是下一个要解决的问题:错误降级。
错误降级:让失败变得可控
真实世界的网络不可靠。CDN挂了、DNS解析超时、服务器500错误……这些都是常态。你的代码不能假设所有请求都会成功。
我见过最糟糕的错误处理是:
try {
const data = await fetch('/api/data').then(r => r.json());
} catch (e) {
console.error('失败了', e); // 然后呢?用户看到什么?
}
用户什么都没看到,按钮转圈转了半天,然后页面毫无变化。这比加载慢更糟糕——用户会以为系统坏了。
正确的做法是分级降级:
1. 区分关键和非关键请求
不是所有数据都对核心功能至关重要。头像没了,用户还能登录;但用户信息没了,登录流程就得停。
async function loadUserData(userId) {
// 定义请求的优先级和容错策略
const criticalRequests = {
userInfo: fetch(`/api/users/${userId}`).then(r => r.json()),
preferences: fetch(`/api/users/${userId}/preferences`).then(r => r.json())
};
const niceToHaveRequests = {
avatar: fetch(`/api/users/${userId}`).then(r => r.json()).then(u => fetch(u.avatarUrl).then(res => res.blob())),
activities: fetch(`/api/users/${userId}/activities`).then(r => r.json())
};
// 关键请求用 Promise.all,任何失败就整体失败
let userInfo, preferences;
try {
[userInfo, preferences] = await Promise.all([
criticalRequests.userInfo,
criticalRequests.preferences
]);
} catch (error) {
// 关键请求失败,直接返回错误,让上层处理
throw new Error(`关键数据加载失败: ${error.message}`);
}
// 非关键请求用 Promise.allSettled,单独处理每个结果
const results = await Promise.allSettled([
niceToHaveRequests.avatar,
niceToHaveRequests.activities
]);
// 手动解析结果
const avatar = results[0].status === 'fulfilled' ? results[0].value : null;
const activities = results[1].status === 'fulfilled' ? results[1].value : [];
// 返回部分数据,界面显示"部分加载"状态
return {
userInfo,
preferences,
avatar: avatar || getDefaultAvatar(),
activities: activities || []
};
}
这里用了两个重要的API:
Promise.all:任一失败则整体失败Promise.allSettled:等待所有请求完成,不管成功失败,返回每个请求的状态
2. 设置超时
网络请求可能永远不返回。你需要一个超时机制:
function fetchWithTimeout(url, options = {}, timeout = 5000) {
return Promise.race([
fetch(url, options),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), timeout)
)
]);
}
然后在关键请求里用上:
const criticalRequests = {
userInfo: fetchWithTimeout(`/api/users/${userId}`).then(r => r.json()),
preferences: fetchWithTimeout(`/api/users/${userId}/preferences`).then(r => r.json())
};
3. 重试机制
有时候失败是暂时的。加个简单的重试:
async function fetchWithRetry(url, options = {}, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetchWithTimeout(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
if (i === retries - 1) throw error; // 最后一次重试也失败,抛出错误
console.warn(`请求失败,第${i + 1}次重试:`, error.message);
await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); // 指数退避
}
}
}
完整的实战示例
让我把上面这些概念整合到一个完整的组件里。假设我们有个用户资料页面,需要加载四部分数据:
// 工具函数:带超时和重试的fetch
async function smartFetch(url, options = {}, config = {}) {
const {
timeout = 5000,
retries = 2,
retryDelay = 1000,
priority = 'normal' // 'critical' | 'normal' | 'lazy'
} = config;
const executeFetch = () => Promise.race([
fetch(url, options),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeout)
)
]);
for (let attempt = 0; attempt < retries + 1; attempt++) {
try {
const response = await executeFetch();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
return contentType?.includes('json') ? response.json() : response.blob();
} catch (error) {
if (attempt === retries) throw error;
await new Promise(resolve => setTimeout(resolve, retryDelay * Math.pow(2, attempt)));
}
}
}
// 主数据加载器
class UserDataLoader {
constructor(userId) {
this.userId = userId;
this.cache = new Map();
}
async loadAll() {
const startTime = performance.now();
// 第一阶段:并行加载关键数据
const criticalTasks = {
profile: smartFetch(`/api/users/${this.userId}/profile`, {}, {
timeout: 3000,
retries: 2,
priority: 'critical'
}),
settings: smartFetch(`/api/users/${this.userId}/settings`, {}, {
timeout: 3000,
retries: 2,
priority: 'critical'
})
};
let profile, settings;
try {
[profile, settings] = await Promise.all([
criticalTasks.profile,
criticalTasks.settings
]);
} catch (error) {
// 关键数据加载失败,抛出给上层
throw new UserDataLoadError('关键数据加载失败', {
profile: criticalTasks.profile,
settings: criticalTasks.settings
}, error);
}
// 第二阶段:并行加载次要数据
const secondaryTasks = {
avatar: smartFetch(profile.avatarUrl, {}, {
timeout: 5000,
retries: 1,
priority: 'normal'
}).catch(() => getDefaultAvatar()),
recentActivity: smartFetch(`/api/users/${this.userId}/activities?limit=5`, {}, {
timeout: 4000,
retries: 1,
priority: 'normal'
}).catch(() => []),
friends: smartFetch(`/api/users/${this.userId}/friends?limit=10`, {}, {
timeout: 4000,
retries: 1,
priority: 'lazy' // 可以慢一点
}).catch(() => [])
};
// 用 allSettled 确保一个失败不影响其他
const secondaryResults = await Promise.allSettled([
secondaryTasks.avatar,
secondaryTasks.recentActivity,
secondaryTasks.friends
]);
const avatar = secondaryResults[0].status === 'fulfilled'
? secondaryResults[0].value
: getDefaultAvatar();
const recentActivity = secondaryResults[1].status === 'fulfilled'
? secondaryResults[1].value
: [];
const friends = secondaryResults[2].status === 'fulfilled'
? secondaryResults[2].value
: [];
const endTime = performance.now();
console.log(`数据加载完成,耗时: ${(endTime - startTime).toFixed(2)}ms`);
return {
profile,
settings,
avatar,
recentActivity,
friends,
loadTime: endTime - startTime
};
}
}
// 自定义错误类
class UserDataLoadError extends Error {
constructor(message, partialData, originalError) {
super(message);
this.name = 'UserDataLoadError';
this.partialData = partialData;
this.originalError = originalError;
}
}
// 使用示例
async function renderUserProfile(userId) {
const loader = new UserDataLoader(userId);
const container = document.getElementById('user-profile');
// 显示加载状态
showLoadingState(container);
try {
const data = await loader.loadAll();
renderSuccessState(container, data);
} catch (error) {
if (error instanceof UserDataLoadError) {
// 部分数据可用,显示降级界面
renderPartialState(container, error.partialData, error.message);
} else {
// 完全失败,显示错误界面
renderErrorState(container, error.message);
}
}
}
给小朋友也能听懂的比喻
想象你要准备一顿晚餐(加载用户数据)。你有几件事要做:
- 煮米饭(关键:没有米饭这顿饭不行)
- 切菜(关键:没有菜也没法吃)
- 洗水果(次要:有更好,没有也能吃)
- 摆盘装饰(懒加载:最后再说)
串行做法:先煮米饭,等米饭熟了再切菜,切完菜再洗水果……这样太慢了!
并行做法:
- 把米放锅里开始煮(启动米饭进程)
- 同时开始切菜(启动切菜进程)
- 米饭还在煮的时候,洗水果(启动洗水果进程)
- 等米饭和菜都好了,就可以开饭了(展示核心数据)
- 水果和摆盘可以稍后准备(懒加载)
如果洗水果的时候手滑打翻了(某个请求失败),你不会说”算了这顿饭不吃了”,而是切个苹果代替(降级处理),继续吃饭。
性能对比
让我给你看个真实的数据。在我的项目里,重构前后的对比:
| 指标 | 重构前(串行) | 重构后(并行+降级) |
|---|---|---|
| 平均加载时间 | 1200ms | 450ms |
| P95加载时间 | 2500ms | 800ms |
| 失败率(完全失败) | 15% | 3% |
| 用户感知卡顿率 | 22% | 5% |
提升主要来自三个方面:
- 并行请求减少了总等待时间
- 超时和重试提高了成功率
- 分级降级让用户始终能看到部分内容
几个实战中踩过的坑
坑1:不要忽略竞态条件
如果你在做搜索框的自动补全,用户快速输入”abc”,可能会发出三个请求。如果网络顺序不确定,可能”c”的结果先回来,覆盖了”b”的结果,然后”b”的结果又回来,覆盖掉”c”的。
解决思路:
let currentRequestId = 0;
async function search(query) {
const requestId = ++currentRequestId;
try {
const results = await fetch(`/api/search?q=${query}`);
// 检查这个请求是否是最新的
if (requestId === currentRequestId) {
displayResults(results);
}
} catch (error) {
if (requestId === currentRequestId) {
showError(error);
}
}
}
坑2:并发控制
如果你要并行加载50个图片,全部同时发起可能会把服务器打爆,也可能让用户的网络带宽被占满。
可以用简单的并发限制:
async function loadWithConcurrencyLimit(tasks, limit = 5) {
const results = [];
const executing = new Set();
for (const task of tasks) {
const promise = task().then(result => {
executing.delete(promise);
return result;
});
executing.add(promise);
results.push(promise);
if (executing.size >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
坑3:浏览器缓存
很多数据其实不需要每次都重新请求。利用浏览器的缓存机制,配合Cache-Control头,可以大幅减少请求次数。
”`javascript // 检查缓存 function getCachedData(key) { const cached = localStorage.getItem(key); if (!cached) return null;
const { data, timestamp } = JSON.parse(cached); // 缓存5分钟有效 if (Date.now() - timestamp < 5 * 60 * 1000) {
return data
