前端人保命指南:搞定异步回调地狱,7天从Promises到async-await丝滑进阶
说实话,我干了这么多年前端,最崩溃的时刻往往不是CSS布局崩了,也不是浏览器兼容性问题——是明明代码逻辑看着都对,控制台却疯狂输出undefined,或者接口数据死活拿不到手。那种无力感,就像你明明点了外卖,骑手显示"已送达",但门口啥也没有。你盯着代码,代码也盯着你,双方在沉默中互相怀疑人生。还记得jQuery时代吗?那时候我们写异步大概是这样的:看到没?这就是传说中的**“回调地狱”(Call
前端人保命指南:搞定异步回调地狱,7天从Promises到async-await丝滑进阶
前端人保命指南:搞定异步回调地狱,7天从Promises到async-await丝滑进阶
别整那些虚的,先聊聊咱们为啥总被异步搞崩心态
说实话,我干了这么多年前端,最崩溃的时刻往往不是CSS布局崩了,也不是浏览器兼容性问题——是明明代码逻辑看着都对,控制台却疯狂输出undefined,或者接口数据死活拿不到手。
那种无力感,就像你明明点了外卖,骑手显示"已送达",但门口啥也没有。你盯着代码,代码也盯着你,双方在沉默中互相怀疑人生。
那些年我们写过的"回调地狱",代码缩进比楼梯还长
还记得jQuery时代吗?那时候我们写异步大概是这样的:
// 经典回调地狱,缩进地狱级难度
$.ajax({
url: '/api/getUser',
success: function(user) {
$.ajax({
url: '/api/getOrders',
data: { userId: user.id },
success: function(orders) {
$.ajax({
url: '/api/getOrderDetails',
data: { orderId: orders[0].id },
success: function(details) {
$.ajax({
url: '/api/getPaymentInfo',
data: { detailId: details.id },
success: function(payment) {
// 终于拿到了!但是代码已经在屏幕外面了...
console.log('支付信息:', payment);
},
error: function(err) {
console.error('获取支付信息失败:', err);
}
});
},
error: function(err) {
console.error('获取订单详情失败:', err);
}
});
},
error: function(err) {
console.error('获取订单列表失败:', err);
}
});
},
error: function(err) {
console.error('获取用户信息失败:', err);
}
});
看到没?这就是传说中的**“回调地狱”(Callback Hell)**。代码往右缩进得越来越深,最后你得横向滚动才能看到完整的代码。更恶心的是,错误处理得在每个层级都写一遍,漏一个就等着线上报错吧。
我当时写这种代码的时候,心里就一个念头:这玩意儿要是能像写同步代码那样从上到下写该多好。可惜那时候年少无知,不知道后面会有Promise来救我狗命。
为什么明明逻辑对了,接口数据就是拿不到,控制台全是undefined
这个问题坑过多少前端新人?我举个最经典的例子:
// 错误的示范:以为这样写能拿到数据
function getUserData() {
let result;
fetch('/api/user')
.then(response => response.json())
.then(data => {
result = data; // 数据确实赋值了
console.log('内部:', result); // 这里能打印出来
});
return result; // 但是这里返回的是undefined!
}
const user = getUserData();
console.log('外部:', user); // undefined,心态崩了
问题出在哪? fetch是异步的,当你return result的时候,网络请求可能还没完成,result还是初始的undefined。这就好比你让朋友去楼下买奶茶,他刚出门你就问他"奶茶呢",他肯定一脸懵逼。
正确的思维方式应该是:异步操作的结果,只能在异步的上下文里处理。你不能用同步的思维去"等待"一个异步结果,除非你用await。
同步思维撞上异步世界的墙,脑子容易短路的那些瞬间
咱们写代码的时候,大脑默认是同步模式。比如:
// 同步思维:先A后B再C,天经地义
const a = doA();
const b = doB(a);
const c = doC(b);
console.log(c);
但异步世界不讲这个规矩。当你改成:
// 异步现实:谁先完事说不准
const a = await doA(); // 可能等2秒
const b = await doB(a); // 又等2秒
const c = await doC(b); // 再等2秒
// 总共等了6秒,用户骂娘了
这时候你就得琢磨:这三个操作有没有依赖关系?能不能并行? 同步思维会让你习惯性地一个接一个写,但异步世界里,并行往往才是正解。
用户界面卡死、白屏、转圈圈,背锅侠永远是前端
最惨的是什么?是后端接口200ms就返回了,但你的页面白屏3秒。产品经理跑过来:"怎么加载这么慢?"你一查,发现是串行请求太多,或者某个异步操作阻塞了主线程。
还有那种"转圈圈"转个没完的,用户以为页面死了,疯狂点击刷新。实际上可能是某个Promise一直没resolve,或者catch没写导致错误被吞了。
前端作为用户直接接触的那一层,所有性能问题最后都会变成前端的问题。接口慢是后端的事,但页面卡死就是你没处理好异步加载策略。所以搞懂异步,真的是保命技能。
扒一扒异步这玩意儿到底是个啥妖魔鬼怪
要搞定异步,得先明白JavaScript是单线程的。它一次只能干一件事,但又要处理网络请求、定时器、用户点击这些"耗时操作",怎么办?**事件循环(Event Loop)**就是它的解决方案。
事件循环(Event Loop)其实就是个无情的排队机器
想象一下,JavaScript引擎是个勤劳的打工人,手里有两个清单:
- 执行栈(Call Stack):现在正在干的活
- 任务队列(Task Queue):等着干的活
代码从上到下执行,遇到同步代码直接干,遇到异步代码(比如setTimeout、fetch)就扔给浏览器/Node.js的Web API去处理,然后继续往下走。等异步操作完了,回调函数会被塞进任务队列,等着事件循环来取。
console.log('1'); // 同步,直接执行
setTimeout(() => {
console.log('2'); // 异步,扔进宏任务队列
}, 0);
Promise.resolve().then(() => {
console.log('3'); // 异步,扔进微任务队列
});
console.log('4'); // 同步,直接执行
// 输出顺序:1 -> 4 -> 3 -> 2
为啥是1、4、3、2? 因为同步代码先执行完(1、4),然后事件循环检查微任务队列,发现有个Promise的then,执行(3),最后检查宏任务队列,执行setTimeout(2)。
这个顺序死记硬背没用,得理解微任务优先级高于宏任务这个规则。
宏任务和微任务那点爱恨情仇,谁插队谁先跑
**宏任务(Macrotask)**包括:
setTimeout、setIntervalsetImmediate(Node.js)I/O操作UI rendering
**微任务(Microtask)**包括:
Promise.then/catch/finallyMutationObserverqueueMicrotask
关键规则:每次事件循环,先清空所有微任务,再执行一个宏任务。也就是说,微任务能插队,宏任务得老老实实等着。
// 极端案例,看看你能不能猜对输出
setTimeout(() => console.log('timeout 1'), 0);
setTimeout(() => console.log('timeout 2'), 0);
Promise.resolve().then(() => {
console.log('promise 1');
Promise.resolve().then(() => console.log('promise 2'));
});
console.log('sync');
// 输出:sync -> promise 1 -> promise 2 -> timeout 1 -> timeout 2
看到没?两个setTimeout都是宏任务,但promise链是微任务,微任务里还能继续塞微任务,所以promise 2比timeout 1还早执行。这个机制要是搞不清,写复杂异步逻辑的时候时序能把你绕死。
Promise状态机:从Pending到Fulfilled或Rejected的内心戏
Promise其实是个状态机,三种状态:
- Pending:进行中,还没结果
- Fulfilled:成功,有了值(value)
- Rejected:失败,有了原因(reason)
状态一旦改变就不可逆。Pending可以变成Fulfilled或Rejected,但Fulfilled和Rejected之间不能互相转换。
const promise = new Promise((resolve, reject) => {
console.log('Promise executor 立即执行'); // 这是同步代码!
setTimeout(() => {
if (Math.random() > 0.5) {
resolve('成功了!');
} else {
reject('失败了!');
}
}, 1000);
});
console.log('这行会在 Promise executor 之后打印');
// Promise的executor函数是同步执行的!
// 但then/catch里的回调是异步的
很多人误以为Promise全是异步的,其实executor是同步执行的。只有then/catch/finally里的回调才是异步的,被扔进微任务队列。
async/await披着同步外衣的异步狼,别被它骗了
async/await是ES2017引入的语法糖,本质上还是Promise。它只是让异步代码看起来像同步代码,但执行逻辑依然是异步的。
async function foo() {
console.log('A');
await Promise.resolve(); // 这里会暂停,但把后面的代码扔进微任务
console.log('B'); // 这行变成异步了!
}
foo();
console.log('C');
// 输出:A -> C -> B
关键点:await关键字会"暂停"async函数的执行,但它后面的代码实际上被转换成了Promise的then回调,所以变成了异步。这就是为什么上面输出是A、C、B而不是A、B、C。
这个特性有时候会导致意外的时序问题,特别是当你以为await能阻塞整个程序的时候——它只阻塞async函数内部,不阻塞外部代码。
Promise这波操作到底香在哪,又坑在哪
Promise的出现确实拯救了回调地狱,但也不是银弹,它有自己的甜蜜点和陷阱。
链式调用让代码终于能读得像人话了
Promise最大的贡献就是链式调用(Chaining),让异步流程能从上到下读:
// 用Promise重构之前的回调地狱
fetch('/api/user')
.then(response => {
if (!response.ok) throw new Error('网络错误');
return response.json();
})
.then(user => {
console.log('拿到用户:', user);
return fetch(`/api/orders?userId=${user.id}`);
})
.then(response => response.json())
.then(orders => {
console.log('拿到订单:', orders);
return fetch(`/api/order-details?orderId=${orders[0].id}`);
})
.then(response => response.json())
.then(details => {
console.log('拿到详情:', details);
// 继续处理...
})
.catch(error => {
// 一个catch捕获链上所有错误!
console.error('某个环节出错了:', error);
});
看着舒服多了对吧?错误处理集中在一个catch里,不用每层都写。而且每个then返回新的Promise,可以一直链下去。
但链式调用也有坑:忘记return。
// 错误示范:then里没return,链断了
fetch('/api/user')
.then(response => {
response.json(); // 没return!
})
.then(data => {
// 这里的data是undefined,因为上一个then没返回Promise
console.log(data);
});
// 正确写法
fetch('/api/user')
.then(response => {
return response.json(); // 必须return
})
.then(data => {
console.log(data); // 正常拿到数据
});
箭头函数隐式返回可以省点代码:
fetch('/api/user')
.then(response => response.json()) // 隐式return
.then(data => console.log(data));
.then().catch() 处理错误的优雅姿势 vs 漏写catch的火葬场
Promise的错误处理看起来优雅,但漏写catch就是灾难:
// 火葬场现场:没catch,错误被吞了,程序静默失败
fetch('/api/data')
.then(response => response.json())
.then(data => processData(data));
// 如果processData抛错,或者fetch失败,这里没catch
// 你会在控制台看到"UnhandledPromiseRejection",然后程序继续跑
// 但你的数据没了,页面可能白屏,你还不知道为啥
// 优雅姿势:每个链都要有个catch
fetch('/api/data')
.then(response => response.json())
.then(data => processData(data))
.catch(error => {
console.error('处理失败:', error);
showErrorUI(error); // 给用户看错误提示
});
更隐蔽的坑:catch本身也会返回Promise,如果你catch后还想继续链式调用,得注意:
fetch('/api/user')
.then(response => response.json())
.catch(error => {
console.error('获取用户失败:', error);
return { name: '匿名用户' }; // catch后返回默认值,链继续
})
.then(user => {
// 即使前面出错了,这里也能拿到匿名用户对象
console.log('当前用户:', user);
});
并发处理神器 Promise.all 和 Promise.race,谁先完事谁赢
有时候你需要同时发起多个请求,等所有结果回来再处理,这时候Promise.all是神器:
// 并行获取用户、订单、配置,比串行快3倍
async function loadDashboardData() {
try {
const [user, orders, config] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/orders').then(r => r.json()),
fetch('/api/config').then(r => r.json())
]);
console.log('全部加载完成:', { user, orders, config });
renderDashboard(user, orders, config);
} catch (error) {
// 任何一个失败都会进这里
console.error('至少一个接口挂了:', error);
}
}
Promise.all的坑:一个失败,全部失败。如果某个接口挂了,整个Promise.all直接reject,你拿不到其他成功的结果。
如果你需要不管成功失败,都要等所有请求完成,用Promise.allSettled(ES2020):
const results = await Promise.allSettled([
fetch('/api/user'),
fetch('/api/orders'), // 假设这个会失败
fetch('/api/config')
]);
// results是数组,每个元素有status: 'fulfilled' | 'rejected'
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`请求${index}成功:`, result.value);
} else {
console.error(`请求${index}失败:`, result.reason);
}
});
Promise.race则是另一个极端:谁先完成(无论成功失败)就返回谁:
// 超时控制:5秒内没返回就报错
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('请求超时')), 5000);
});
try {
const data = await Promise.race([
fetch('/api/slow-endpoint'),
timeoutPromise
]);
console.log('在5秒内拿到了数据:', data);
} catch (error) {
console.error('要么接口挂了,要么超时了:', error);
}
内存泄漏警告:忘了回收的Promise就像没关的水龙头
Promise虽然好用,但创建了就无法取消(除非用AbortController,后面讲)。如果你在组件里创建了大量Promise,但组件卸载了,Promise还在跑,就可能内存泄漏或状态更新到已卸载组件。
// React组件里的经典内存泄漏
useEffect(() => {
fetch('/api/data')
.then(response => response.json())
.then(data => {
// 如果组件已经卸载,这里setState会报错
setData(data);
});
}, []);
// 正确做法:用AbortController取消请求
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(response => response.json())
.then(data => {
setData(data);
})
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求被取消,正常');
} else {
console.error('其他错误:', err);
}
});
// 清理函数:组件卸载时取消请求
return () => {
controller.abort();
};
}, []);
AbortController是现代浏览器提供的取消异步操作的标准方式,axios也支持。记住:任何可能在组件卸载后完成的异步操作,都要考虑取消机制。
async/await:让异步代码看起来像同步的魔法
如果说Promise是回调地狱的救生艇,那async/await就是豪华游轮。它让异步代码的可读性提升了一个维度。
别再.then()到底了,try/catch 才是成年人的错误处理方式
async/await最大的好处是可以用try/catch处理异步错误,就像写同步代码一样自然:
// Promise风格的错误处理
fetchUser()
.then(user => fetchOrders(user.id))
.then(orders => processOrders(orders))
.catch(error => handleError(error));
// async/await风格,是不是顺眼多了?
async function loadUserData() {
try {
const user = await fetchUser();
const orders = await fetchOrders(user.id);
const processed = processOrders(orders);
return processed;
} catch (error) {
handleError(error);
// 可以选择重新抛出,或者返回默认值
throw error;
}
}
try/catch能捕获await的reject,也能捕获同步抛错,一网打尽。而且你可以用多个catch块处理不同类型的错误:
async function robustFetch() {
try {
const data = await fetchData();
return data;
} catch (error) {
if (error.name === 'NetworkError') {
// 网络问题,重试或提示用户检查网络
return await retryFetch();
} else if (error.status === 401) {
// 未授权,跳转登录
redirectToLogin();
return null;
} else {
// 其他错误,上报日志
reportError(error);
throw error;
}
}
}
await 后面必须跟Promise?不然就是脱裤子放屁
很多人以为await后面只能跟Promise,其实await后面可以跟任何值:
// await 普通值,相当于Promise.resolve(值)
async function test() {
const a = await 42; // 等同于 await Promise.resolve(42)
console.log(a); // 42
const b = await 'hello'; // 也能await字符串
console.log(b); // 'hello'
const c = await null; // 甚至null
console.log(c); // null
}
// 那await有啥用?主要是等Promise,但也能等thenable对象
const thenable = {
then(resolve, reject) {
setTimeout(() => resolve('我是thenable'), 100);
}
};
async function testThenable() {
const result = await thenable; // 也能await!
console.log(result); // '我是thenable'
}
实际意义:有时候你不知道一个值是不是Promise,直接await它,是Promise就等,不是Promise就当同步值用,代码更健壮。
串行执行还是并行执行?别把性能跑丢了还不知道
这是async/await最常见的性能陷阱:习惯性await,导致串行执行:
// 性能杀手:串行执行,总耗时 = time1 + time2 + time3
async function slowWay() {
const user = await fetchUser(); // 假设1秒
const orders = await fetchOrders(); // 假设1秒
const config = await fetchConfig(); // 假设1秒
// 总共3秒!用户等哭了
return { user, orders, config };
}
// 正确姿势:并行执行,总耗时 = max(time1, time2, time3)
async function fastWay() {
// 先同时发起所有请求
const userPromise = fetchUser();
const ordersPromise = fetchOrders();
const configPromise = fetchConfig();
// 再一起await
const user = await userPromise;
const orders = await ordersPromise;
const config = await configPromise;
// 总共1秒左右!
return { user, orders, config };
}
// 更简洁的写法:Promise.all
async function fastWay2() {
const [user, orders, config] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchConfig()
]);
return { user, orders, config };
}
原则:如果多个异步操作没有依赖关系,一定要并行!别一个一个await。
但如果有依赖关系,串行是必须的:
// 必须先拿用户ID,才能查订单,必须串行
async function dependentWay() {
const user = await fetchUser(); // 必须先完成
const orders = await fetchOrders(user.id); // 依赖user.id
return orders;
}
top-level await 在模块里的新玩法,直接爽翻天
以前await只能在async函数里用,ES2022引入了top-level await,模块顶层也能直接await了:
// config.js 模块
// 以前得这么写,很丑
let config;
async function init() {
config = await fetch('/api/config').then(r => r.json());
}
init();
export { config }; // 导出的时候可能还没初始化完!
// 现在可以直接在模块顶层await
const response = await fetch('/api/config');
export const config = await response.json();
// 其他模块导入时,会自动等待config加载完成
// import { config } from './config.js'; // 保证config已就绪
应用场景:
- 模块初始化需要异步数据(配置、用户权限等)
- 动态导入模块(
await import('./heavy-module.js')) - 条件导出(根据环境导出不同实现)
// 根据环境变量选择不同实现
const implementation = process.env.NODE_ENV === 'development'
? await import('./mock-api.js')
: await import('./real-api.js');
export const { fetchData } = implementation;
注意:top-level await会阻塞模块加载,用多了可能影响启动性能,别滥用。
实际干活时这些场景你肯定躲不掉
理论知识再多,不如看看真实业务里怎么玩。这几个场景,你100%会遇到。
封装axios请求拦截器,统一处理Token过期和全局报错
实际项目里不会裸用fetch,都会封装axios。拦截器是处理异步流程的绝佳案例:
// utils/request.js
import axios from 'axios';
import { message } from 'antd'; // 假设用antd
import { useUserStore } from '@/stores/user'; // 假设用pinia/zustand
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
// 请求拦截器:加token、加loading等
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 可以在这里加全局loading
if (config.showLoading !== false) {
window.showGlobalLoading?.();
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器:统一错误处理、token过期等
request.interceptors.response.use(
(response) => {
// 关闭loading
if (response.config.showLoading !== false) {
window.hideGlobalLoading?.();
}
// 业务逻辑错误(后端返回200但data里code不为0)
const { code, data, msg } = response.data;
if (code !== 0) {
message.error(msg || '操作失败');
return Promise.reject(new Error(msg));
}
return data; // 直接返回业务数据,省得每次.data
},
async (error) => {
// 关闭loading
window.hideGlobalLoading?.();
const { response, config } = error;
// 401未授权,token过期
if (response?.status === 401) {
// 清除旧token
localStorage.removeItem('token');
// 如果配置了自动刷新token,这里可以处理
if (config.retryCount && config.retryCount > 0) {
try {
const newToken = await refreshToken(); // 调用刷新接口
localStorage.setItem('token', newToken);
config.headers.Authorization = `Bearer ${newToken}`;
config.retryCount--;
return request(config); // 重试原请求
} catch (refreshError) {
// 刷新也失败了,跳转登录
redirectToLogin();
return Promise.reject(refreshError);
}
}
redirectToLogin();
return Promise.reject(new Error('登录已过期,请重新登录'));
}
// 其他HTTP错误
const errorMsg = response?.data?.msg ||
response?.statusText ||
'网络错误,请稍后重试';
message.error(errorMsg);
return Promise.reject(error);
}
);
// 辅助函数:刷新token
async function refreshToken() {
const refreshToken = localStorage.getItem('refreshToken');
const { data } = await axios.post('/api/refresh-token', { refreshToken });
return data.token;
}
function redirectToLogin() {
window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname);
}
export default request;
使用的时候就很爽了:
import request from '@/utils/request';
// 自动带token、自动处理错误、自动显示loading
async function getUserList() {
try {
const users = await request.get('/api/users', {
params: { page: 1, size: 10 },
showLoading: true // 控制是否显示全局loading
});
return users;
} catch (error) {
// 错误已经被拦截器处理过了(提示了用户)
// 这里可以选择性处理,或者干脆不catch
console.log('获取失败,但用户已经看到提示了');
return [];
}
}
多个接口依赖,先拿用户ID再查详情,怎么串联最丝滑
业务里经常有这种瀑布流依赖:A接口返回id,B接口用id查详情,C接口用详情里的某个字段再查其他数据。
// 场景:查看订单详情,需要串联多个接口
async function loadOrderDetail(orderId) {
try {
// 1. 先拿订单基础信息
const order = await request.get(`/api/orders/${orderId}`);
// 2. 用订单里的userId查用户信息
const [user, products, logistics] = await Promise.all([
request.get(`/api/users/${order.userId}`),
// 3. 用订单里的productIds批量查商品(可能多个)
request.post('/api/products/batch', {
ids: order.productIds
}),
// 4. 如果有物流单号,查物流
order.trackingNumber
? request.get(`/api/logistics/${order.trackingNumber}`)
: Promise.resolve(null) // 没有物流单号就返回null
]);
// 5. 组装完整数据
return {
...order,
userName: user.name,
userAvatar: user.avatar,
products: products.map(p => ({
name: p.name,
price: p.price,
image: p.mainImage
})),
logistics: logistics?.routes || []
};
} catch (error) {
console.error('加载订单详情失败:', error);
throw error;
}
}
技巧:
- 没有依赖关系的并行(Promise.all)
- 有依赖关系的串行(await)
- 条件请求用三元运算符 + Promise.resolve(null)占位
上传大文件分片并发控制,别让浏览器直接卡死
上传大文件要分片,但并发数不能太高,不然浏览器卡死。需要实现并发池:
class FileUploader {
constructor(file, options = {}) {
this.file = file;
this.chunkSize = options.chunkSize || 1024 * 1024; // 默认1MB一片
this.concurrency = options.concurrency || 3; // 同时上传3片
this.chunks = this.createChunks();
this.uploadedChunks = new Set(); // 记录已上传的分片
}
// 将文件切成多块
createChunks() {
const chunks = [];
let start = 0;
while (start < this.file.size) {
const end = Math.min(start + this.chunkSize, this.file.size);
chunks.push(this.file.slice(start, end));
start = end;
}
return chunks;
}
// 上传单个分片
async uploadChunk(chunk, index, retryCount = 3) {
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', index);
formData.append('total', this.chunks.length);
formData.append('filename', this.file.name);
for (let i = 0; i < retryCount; i++) {
try {
const result = await request.post('/api/upload/chunk', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
this.uploadedChunks.add(index);
return result;
} catch (error) {
if (i === retryCount - 1) throw error;
console.warn(`分片${index}第${i+1}次重试...`);
await this.delay(1000 * (i + 1)); // 指数退避
}
}
}
// 并发控制核心:用Promise.race实现池化
async upload() {
const pool = new Set(); // 正在执行的上传任务
const results = [];
for (let i = 0; i < this.chunks.length; i++) {
// 如果该分片已上传(断点续传),跳过
if (this.uploadedChunks.has(i)) {
console.log(`分片${i}已存在,跳过`);
continue;
}
const promise = this.uploadChunk(this.chunks[i], i).then(result => {
pool.delete(promise); // 完成后从池子移除
return result;
});
pool.add(promise);
results.push(promise);
// 池子满了就等一个完成再继续
if (pool.size >= this.concurrency) {
await Promise.race(pool);
}
}
// 等待所有剩余任务完成
await Promise.all(results);
// 通知后端合并分片
return this.mergeChunks();
}
async mergeChunks() {
return request.post('/api/upload/merge', {
filename: this.file.name,
total: this.chunks.length
});
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// 使用
const uploader = new FileUploader(largeFile, { concurrency: 3 });
try {
const result = await uploader.upload();
console.log('上传成功:', result);
} catch (error) {
console.error('上传失败:', error);
// 可以保存进度,下次断点续传
}
关键点:
Promise.race(pool)实现并发控制:池子满了就等最快的完成- 每个分片独立重试,失败不影响其他分片
- 支持断点续传:刷新页面后跳过已上传分片
防抖节流配合异步请求,搜索框别再每敲一个字都调接口
搜索框输入时防抖是必须的,但异步请求的时序问题容易被忽略:
// 错误的防抖:只防抖了函数调用,没处理响应时序
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 问题:用户输入"abc",停300ms触发请求
// 但请求A(搜"abc")耗时2秒,请求B(搜"abcd")耗时200ms
// 结果B先回来,A后回来,页面显示"abc"的结果,用户看到的是"abcd"的搜索词
const search = debounce(async (keyword) => {
const results = await fetchSearchResults(keyword);
renderResults(results); // 可能渲染错的结果!
}, 300);
// 正确的防抖:加上请求取消机制
function createAbortableDebounce(fn, delay) {
let timer;
let currentController = null;
return async function(...args) {
clearTimeout(timer);
// 取消之前的请求
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
const signal = currentController.signal;
timer = setTimeout(async () => {
try {
await fn.apply(this, [...args, signal]);
} catch (error) {
if (error.name !== 'AbortError') {
throw error;
}
}
}, delay);
};
}
// 使用
const search = createAbortableDebounce(async (keyword, signal) => {
try {
const response = await fetch(`/api/search?q=${keyword}`, { signal });
const results = await response.json();
// 只有没被取消的请求才渲染
if (!signal.aborted) {
renderResults(results);
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求被取消,正常');
} else {
showError(error);
}
}
}, 300);
// React Hook版本
function useDebouncedSearch() {
const [results, setResults] = useState([]);
const abortControllerRef = useRef(null);
const debouncedSearch = useMemo(
() => debounce(async (keyword) => {
// 取消上次请求
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
try {
const response = await fetch(
`/api/search?q=${keyword}`,
{ signal: abortControllerRef.current.signal }
);
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error('搜索失败:', error);
}
}
}, 300),
[]
);
// 组件卸载时取消请求
useEffect(() => {
return () => abortControllerRef.current?.abort();
}, []);
return { results, search: debouncedSearch };
}
核心逻辑:每次新输入都取消之前的请求,保证只有最后一次输入的结果被渲染。这比单纯防抖更安全,防止竞态条件(Race Condition)。
代码跑飞了?别慌,按这个路子排查能救半条命
异步代码出问题往往静默失败,或者时序错乱,排查起来比同步代码麻烦得多。这几个调试技巧,关键时刻能救命。
控制台爆红 UnhandledPromiseRejection,赶紧去找漏网的catch
Chrome控制台看到这种红字,说明有Promise rejected了但没被catch:
Uncaught (in promise) Error: 请求失败
排查步骤:
- 点击错误堆栈,找到源头
- 检查链式调用是否每个分支都有catch
- 特别注意async函数里没await的Promise
// 容易被忽略的坑:async函数里创建了Promise但没await
async function dangerous() {
// 这个Promise如果reject了,外部try/catch捕获不到!
somePromise().then(data => {
// 处理数据
});
// 因为没有await,这里直接返回了,Promise在后台跑
}
// 正确做法:要么await,要么加catch
async function safe() {
await somePromise().then(data => {
// 处理
});
// 或者如果不关心结果
somePromise().catch(err => console.error(err));
}
全局兜底(不推荐作为常规手段,但开发时可用):
window.addEventListener('unhandledrejection', event => {
console.error('未处理的Promise拒绝:', event.reason);
// 可以上报监控
reportError(event.reason);
event.preventDefault(); // 防止控制台报错
});
时序错乱导致数据覆盖,console.log都救不了你的时候咋办
最隐蔽的bug:先发的请求后回来,覆盖了后发请求的结果。比如搜索"react"然后快速改成"vue",结果页面显示"react"的数据。
排查神器:给每个请求加ID:
let requestId = 0;
async function fetchData(keyword) {
const currentId = ++requestId;
console.log(`发起请求 #${currentId}: ${keyword}`);
const response = await fetch(`/api/search?q=${keyword}`);
const data = await response.json();
// 检查这个响应是否还对应当前最新的请求
if (currentId !== requestId) {
console.warn(`请求 #${currentId} 已过期,丢弃结果`);
return null; // 丢弃过期结果
}
return data;
}
或者用前面提到的AbortController直接取消旧请求,更干净。
用 Chrome DevTools 的 Async 堆栈追踪,揪出那个隐藏的异步调用
普通断点调试异步代码很痛苦,因为调用栈断了——异步回调执行时,已经看不到是谁发起的。
解决方案:
- 打开DevTools,Sources面板
- 右侧勾选Async选项(在Call Stack旁边)
- 打断点,现在能看到完整的异步调用链了!
或者代码里手动加debugger:
async function complexFlow() {
const step1 = await fetchStep1();
debugger; // 在这里暂停,Async栈能看到完整流程
const step2 = await fetchStep2(step1);
return step2;
}
Performance面板也能看事件循环和异步任务耗时,优化性能时有用。
模拟弱网环境,看看你的 Loading 状态是不是真的生效了
很多bug只在慢网络下出现,本地开发很难复现。
Chrome DevTools Network面板:
- 下拉选择Slow 3G或Fast 3G
- 或者自定义:Add… -> 设置下载速度500kb/s,延迟300ms
测试重点:
- Loading状态是否及时显示
- 快速切换页面是否会取消请求
- 超时处理是否生效
- 重试机制是否正常工作
// 可以在代码里手动延迟,模拟慢接口
const mockSlowApi = (data, delay = 2000) => {
return new Promise(resolve => {
setTimeout(() => resolve(data), delay);
});
};
// 使用
const user = await mockSlowApi({ name: '张三' }, 3000); // 强制等3秒
老鸟私藏的几个骚操作,让代码逼格瞬间拉满
掌握了基础,来看看一些进阶玩法,面试或者重构老代码时能派上用场。
手写一个简易版 Promise,搞懂原理比背API强一百倍
理解Promise最好的方式就是手写一个,虽然生产环境不会用,但面试常考:
class MyPromise {
constructor(executor) {
this.state = 'pending'; // pending, fulfilled, rejected
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach(fn => fn());
}
};
const reject = (reason) => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn());
}
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
// 处理默认值,实现值传递
onFulfilled = typeof onFulfilled === 'function'
? onFulfilled
: value => value;
onRejected = typeof onRejected === 'function'
? onRejected
: reason => { throw reason };
const promise2 = new MyPromise((resolve, reject) => {
if (this.state === 'fulfilled') {
setTimeout(() => {
try {
const x = onFulfilled(this.value);
this.resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error);
}
}, 0);
} else if (this.state === 'rejected') {
setTimeout(() => {
try {
const x = onRejected(this.reason);
this.resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error);
}
}, 0);
} else {
// pending状态,先存起来
this.onFulfilledCallbacks.push(() => {
setTimeout(() => {
try {
const x = onFulfilled(this.value);
this.resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error);
}
}, 0);
});
this.onRejectedCallbacks.push(() => {
setTimeout(() => {
try {
const x = onRejected(this.reason);
this.resolvePromise(promise2, x, resolve, reject);
} catch (error) {
reject(error);
}
}, 0);
});
}
});
return promise2;
}
// 处理then返回值的复杂逻辑(Promise A+规范核心)
resolvePromise(promise2, x, resolve, reject) {
if (promise2 === x) {
reject(new TypeError('Chaining cycle detected'));
return;
}
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
let called = false;
try {
const then = x.then;
if (typeof then === 'function') {
// x是thenable,递归解析
then.call(x,
y => {
if (called) return;
called = true;
this.resolvePromise(promise2, y, resolve, reject);
},
r => {
if (called) return;
called = true;
reject(r);
}
);
} else {
resolve(x);
}
} catch (error) {
if (called) return;
reject(error);
}
} else {
resolve(x);
}
}
catch(onRejected) {
return this.then(null, onRejected);
}
static resolve(value) {
return new MyPromise(resolve => resolve(value));
}
static reject(reason) {
return new MyPromise((_, reject) => reject(reason));
}
static all(promises) {
return new MyPromise((resolve, reject) => {
if (!Array.isArray(promises)) {
reject(new TypeError('Argument must be an array'));
return;
}
const results = new Array(promises.length);
let completedCount = 0;
if (promises.length === 0) {
resolve(results);
return;
}
promises.forEach((promise, index) => {
MyPromise.resolve(promise).then(
value => {
results[index] = value;
completedCount++;
if (completedCount === promises.length) {
resolve(results);
}
},
reason => reject(reason)
);
});
});
}
}
// 测试一下
const p = new MyPromise((resolve, reject) => {
setTimeout(() => resolve('Hello'), 1000);
});
p.then(msg => {
console.log(msg); // Hello
return msg + ' World';
}).then(msg => {
console.log(msg); // Hello World
});
关键理解:
- then必须返回新Promise,实现链式调用
- 状态改变后不可变
- 异步执行回调(用setTimeout模拟微任务)
用 async/await 重构老旧的回调代码,同事看了都得喊666
维护老项目时,经常遇到回调地狱。用promisify技巧可以快速改造:
const fs = require('fs');
const util = require('util');
// Node.js内置的promisify,把回调风格转为Promise
const readFile = util.promisify(fs.readFile);
// 现在可以用async/await了
async function processFile() {
try {
const data = await readFile('./file.txt', 'utf8');
console.log(data);
} catch (error) {
console.error('读取失败:', error);
}
}
// 如果没有util.promisify,自己手写一个
function promisify(fn) {
return function(...args) {
return new Promise((resolve, reject) => {
fn.call(this, ...args, (error, result) => {
if (error) reject(error);
else resolve(result);
});
});
};
}
// 改造jQuery的$.ajax(假设还在用)
const requestAsync = promisify($.ajax);
async function modernFetch() {
const data = await requestAsync({
url: '/api/data',
method: 'GET'
});
return data;
}
渐进式重构:不用一次性改完,可以边改边用,回调和Promise混用完全OK。
结合生成器函数(Generator)玩点更高级的流程控制
async/await本质上就是Generator + Promise的语法糖。理解Generator能让你实现更灵活的异步流程控制:
// 用Generator实现可中断的异步流程
function* fetchGenerator() {
console.log('开始获取用户...');
const user = yield fetchUser(); // yield暂停,等待异步结果
console.log('获取到用户:', user);
console.log('开始获取订单...');
const orders = yield fetchOrders(user.id);
console.log('获取到订单:', orders);
return { user, orders };
}
// 自动执行Generator的runner函数
function runGenerator(generatorFn) {
const iterator = generatorFn();
function handle(result) {
if (result.done) return Promise.resolve(result.value);
return Promise.resolve(result.value)
.then(data => handle(iterator.next(data)))
.catch(error => handle(iterator.throw(error)));
}
return handle(iterator.next());
}
// 使用
runGenerator(fetchGenerator)
.then(result => console.log('完成:', result))
.catch(error => console.error('出错:', error));
// 更高级的:可取消的异步任务
function createCancellableTask(generatorFn) {
let cancelled = false;
let currentPromise = null;
const iterator = generatorFn();
const cancel = () => {
cancelled = true;
// 可以在这里abort fetch等
};
function run() {
function step(prevResult) {
if (cancelled) {
return Promise.resolve({ cancelled: true });
}
const { value, done } = iterator.next(prevResult);
if (done) return Promise.resolve(value);
currentPromise = Promise.resolve(value);
return currentPromise.then(
result => step(result),
error => {
if (!cancelled) throw error;
}
);
}
return step();
}
return { run, cancel };
}
// 使用:可以中途取消的长任务
const task = createCancellableTask(function* () {
const step1 = yield delay(1000);
console.log('步骤1完成');
const step2 = yield delay(1000);
console.log('步骤2完成');
return '全部完成';
});
const promise = task.run();
setTimeout(() => task.cancel(), 1500); // 1.5秒后取消,步骤2不会执行
应用场景:
- 可取消的异步序列(比如路由切换时取消未完成的加载)
- 复杂的异步状态机
- 需要"步进"调试的异步流程
封装通用的异步重试机制,网络抖动也不怕请求挂掉
生产环境网络不稳定,自动重试是必备功能:
/**
* 带重试和退避策略的请求封装
* @param {Function} fn - 要执行的异步函数
* @param {Object} options - 配置项
*/
async function withRetry(fn, options = {}) {
const {
maxRetries = 3, // 最大重试次数
retryDelay = 1000, // 基础延迟(毫秒)
backoffMultiplier = 2, // 退避倍数(指数退避)
retryCondition = (error) => true, // 判断是否应该重试
onRetry = (error, attempt) => {} // 每次重试的回调
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn(); // 尝试执行
} catch (error) {
lastError = error;
// 最后一次尝试也失败了,或者不符合重试条件
if (attempt === maxRetries || !retryCondition(error)) {
throw error;
}
// 计算退避时间:基础延迟 * (倍数 ^ 尝试次数)
// 加上随机抖动,防止雪崩
const delay = retryDelay * Math.pow(backoffMultiplier, attempt)
+ Math.random() * 1000;
onRetry(error, attempt + 1);
console.warn(`第${attempt + 1}次尝试失败,${delay}ms后重试...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError; // 理论上不会到这里,为了保险
}
// 使用示例
async function fetchWithRetry() {
return withRetry(
() => fetch('/api/unstable-endpoint').then(r => r.json()),
{
maxRetries: 3,
retryDelay: 1000,
retryCondition: (error) => {
// 只有网络错误和5xx错误才重试,4xx不重试(客户端错误)
return !error.status || error.status >= 500;
},
onRetry: (error, attempt) => {
showToast(`请求失败,正在进行第${attempt}次重试...`);
}
}
);
}
// 更高级:带断路器模式的封装(防止雪崩)
class CircuitBreaker {
constructor(fn, options = {}) {
this.fn = fn;
this.failureThreshold = options.failureThreshold || 5;
this.resetTimeout = options.resetTimeout || 60000;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.failureCount = 0;
this.lastFailureTime = null;
}
async execute(...args) {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.resetTimeout) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit breaker is OPEN');
}
}
try {
const result = await this.fn(...args);
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = 'OPEN';
console.error('Circuit breaker opened!');
}
}
}
// 使用断路器保护关键接口
const protectedFetch = new CircuitBreaker(
() => fetch('/api/critical-endpoint'),
{ failureThreshold: 3, resetTimeout: 30000 }
);
// 连续失败3次后,直接抛错,不再请求,保护后端服务
断路器模式(Circuit Breaker)在微服务架构里很常见,前端也可以用,防止失败请求雪崩。
最后唠两句,别把异步当洪水猛兽
写到这儿,该讲的都讲了。但还有几句话想唠叨唠叨。
哪怕是大佬也会写出竞态条件,心态要稳
我见过工作十年的架构师,照样在快速输入的场景下栽跟头,写出竞态条件的bug。异步编程的坑,跟资历没关系,跟细心程度有关系。
所以当你又遇到"为什么数据不对"的问题时,别慌,先检查时序。打印一下请求发起时间、响应时间、渲染时间,往往一目了然。
多写多错多调试,哪有什么天生就会的异步大师
我刚开始学Promise的时候,then里忘记return,catch没写全,async/await串行执行慢成狗,这些坑一个没落全都踩过。
唯一的捷径就是多写。写错了就调试,看事件循环,看调用栈,看网络时序。踩的坑多了,自然就长记性了。
下次再遇到回调地狱,直接甩出这篇大纲里的招数怼回去
现在你有武器库了:
- 回调地狱?上Promise链式调用
- 错误处理乱?try/catch安排上
- 性能慢?检查串行/并行
- 时序错乱?AbortController取消旧请求
- 网络不稳定?重试机制+断路器
别再让异步搞崩心态了。它不是妖魔鬼怪,只是个需要排队办事的打工人,理解它的规则,就能使唤得动它。
好了,去写代码吧,记得多写注释,多写catch,多检查并行,保你异步不翻车。

更多推荐


所有评论(0)