JavaScript异步编程通俗指南:从混乱回调到优雅代码
一、先搞懂:为什么非要用异步?
JavaScript有个天生的特点:单线程。就像食堂只有一个打饭窗口,所有任务都得排队来,前一个做完才能轮到下一个。如果所有操作都“同步”执行(排队等),遇到网络请求、读取文件、大数据计算这种耗时操作时,程序就会“卡死”——页面动不了、按钮点不动,用户体验直接拉满。异步编程就是为了解决这个问题:让耗时操作“去后台排队”,不耽误主线程做其他事(比如渲染页面、响应用户点击),等耗时操作完成后,再回头处理结果。举个生活化例子:
- 同步:买奶茶必须等前一个人拿到手,你才能点单(全程阻塞);
- 异步:点单后拿个取号器,去旁边刷手机,等叫号再回来取(不阻塞,高效)。
二、异步编程的演进之路:从混乱到优雅
异步的核心需求一直没变,但写法在不断升级,每一代都在解决上一代的“痛点”。我们按时间顺序,从最简单的写法开始讲。
1. 回调函数:异步的“原始方式”
这是最基础、最原生的异步写法,核心逻辑是:把异步操作的处理逻辑,封装成一个函数(回调函数),等异步任务完成后,自动调用这个函数。
常见场景&例子
定时器、DOM点击事件、早期网络请求,都用这种方式:
// 1. 定时器回调(最常见)
setTimeout(() => {
console.log("1秒后执行(异步)");
}, 1000);
console.log("先执行我(主线程)"); // 输出顺序:先执行我 → 1秒后执行
// 2. Node.js里的文件读取(错误优先回调)
const fs = require('fs');
// 第一个参数固定是错误对象,成功则为null
fs.readFile('./test.txt', 'utf8', (err, data) => {
if (err) {
console.error('读文件失败:', err);
return;
}
console.log('文件内容:', data);
});最大痛点:回调地狱
如果需要多个异步操作“串行执行”(比如先请求用户信息,再用用户ID请求订单,再用订单ID请求商品),代码会嵌套成“金字塔”,越写越乱,这就是“回调地狱”。
// 回调地狱示例:三层嵌套,可读性极差
requestData('/api/user', (user) => {
console.log('拿到用户信息');
requestData(`/api/orders?userId=${user.id}`, (orders) => {
console.log('拿到订单信息');
requestData(`/api/goods?orderId=${orders[0].id}`, (goods) => {
console.log('拿到商品信息');
}, (err) => console.error('商品请求失败'));
}, (err) => console.error('订单请求失败'));
}, (err) => console.error('用户请求失败'));问题总结:嵌套深、错误处理分散、逻辑绕,后期根本没法维护。
2. 事件/发布-订阅:解耦的异步方式
为了解决回调函数“耦合紧”的问题,出现了“事件监听”模式。核心思路是:异步任务完成后,只“喊一声”(触发事件),谁关心这个结果,谁就提前“等着”(监听事件)。就像公众号推送:作者(发布者)只负责发文章,粉丝(订阅者)关注后就能收到,作者不用管谁在看。
常见场景&例子
DOM事件、组件通信(如Vue EventBus)都用这种模式:
// 1. 简单DOM事件(点击事件:点击后触发回调)
document.getElementById('btn').addEventListener('click', () => {
console.log('按钮被点击了(异步响应)');
});
// 2. 自定义发布-订阅(简化版Vue EventBus)
class EventBus {
constructor() {
this.listeners = {}; // 存事件和对应的回调
}
// 订阅事件(等着接收通知)
on(eventName, callback) {
if (!this.listeners[eventName]) {
this.listeners[eventName] = [];
}
this.listeners[eventName].push(callback);
}
// 发布事件(发送通知)
emit(eventName, data) {
this.listeners[eventName]?.forEach(callback => callback(data));
}
}
// 使用
const bus = new EventBus();
// 订阅“数据准备好”事件
bus.on('dataReady', (data) => console.log('收到数据:', data));
// 模拟异步任务,完成后发布事件
setTimeout(() => {
bus.emit('dataReady', { id: 1, content: '异步数据' });
}, 1000);优缺点
优点:解耦(发布者和订阅者互不依赖)、支持多个回调同时响应;缺点:事件触发顺序难追踪、没法直接获取异步结果,复杂场景容易乱。
3. 传统Ajax:异步网络请求的初代写法
早期前端请求后端数据,靠的是Ajax(核心是XMLHttpRequest对象),本质就是“回调函数+网络请求”,也是回调地狱的重灾区。
简单例子(原生Ajax)
// 原生Ajax请求函数
function requestData(url, successCb, errorCb) {
const xhr = new XMLHttpRequest(); // 创建请求对象
xhr.open('GET', url, true); // 配置请求(方法、地址、异步)
xhr.onload = function() {
// 请求完成且成功
if (xhr.status >= 200 && xhr.status < 300) {
successCb(JSON.parse(xhr.responseText));
} else {
errorCb(new Error(`请求失败:${xhr.status}`));
}
};
xhr.onerror = function() {
errorCb(new Error('网络出问题了'));
};
xhr.send(); // 发送请求
}
// 调用(还是依赖回调)
requestData(
'https://jsonplaceholder.typicode.com/todos/1',
(data) => console.log('成功:', data),
(err) => console.error('失败:', err)
);核心问题
和回调函数一样,串行请求时会陷入回调地狱,且每次请求都要写重复代码(创建对象、配置参数),复用性差。
4. Promise(ES6):告别回调地狱的第一步
ES6引入的Promise,是异步编程的“里程碑”。它把异步操作的“结果”和“处理逻辑”分开,用“链式调用”替代嵌套,彻底解决回调地狱。可以把Promise理解成“快递单”:下单后(创建Promise),快递有三种状态,你不用一直等,后续只需要关注“签收”(成功)或“丢件”(失败)的处理。
核心概念(超通俗版)
- pending(运输中):异步任务还在执行;
- fulfilled(已签收):异步任务成功,拿到结果;
- rejected(已丢件):异步任务失败,拿到错误原因;
- then():签收成功后的处理;
- catch():丢件后的统一处理;
- finally():不管成功失败,最后都要做的事(比如关闭加载动画)。
用Promise封装Ajax(摆脱回调)
// Promise版Ajax请求
function requestData(url) {
// 返回一个Promise对象(快递单)
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText)); // 成功:标记为“已签收”,传递结果
} else {
reject(new Error(`请求失败:${xhr.status}`)); // 失败:标记为“已丢件”,传递错误
}
};
xhr.onerror = function() {
reject(new Error('网络错误'));
};
xhr.send();
});
}链式调用:解决回调地狱
Promise的then()方法会返回新的Promise,支持链式写法,代码从“纵向嵌套”变成“横向平铺”:
// 串行请求:链式调用替代嵌套
requestData('https://jsonplaceholder.typicode.com/users/1')
.then((user) => {
console.log('拿到用户信息:', user);
// 继续返回新的Promise(下一张快递单)
return requestData(`/users/${user.id}/todos`);
})
.then((todos) => {
console.log('拿到待办列表:', todos);
return requestData(`/todos/${todos[0].id}`);
})
.then((detail) => console.log('拿到待办详情:', detail))
.catch((err) => console.error('任意一步出错:', err)) // 统一处理所有错误
.finally(() => console.log('请求结束(不管成功失败)'));批量处理异步任务
Promise提供了三个实用方法,应对多异步任务场景:
方法 | 作用 | 适用场景 |
|---|---|---|
Promise.all([p1,p2]) | 等所有任务都成功,才返回结果;一个失败则全失败 | 同时请求多个独立接口(如加载用户+商品数据) |
Promise.race([p1,p2]) | 谁先完成(成功/失败),就返回谁的结果 | 请求超时控制(如5秒没响应就提示超时) |
Promise.allSettled([p1,p2]) | 等所有任务都完成,不管成败,返回每个任务状态 | 批量上传文件(需知道每个文件的上传结果) |
5. async/await(ES7):异步编程的“终极优雅写法”
async/await是ES7引入的“语法糖”,底层还是基于Promise,但能让异步代码看起来和同步代码一模一样,彻底告别链式调用的思维负担。
核心语法(两步搞定)
- async:给函数加个“异步标记”,函数返回值会自动包装成Promise;
- await:只能在async函数里用,意思是“等一等”——等后面的Promise完成,再继续执行下面的代码。
简单例子
// async函数:自动返回Promise
async function demo() {
return 100; // 等同于Promise.resolve(100)
}
demo().then(res => console.log(res)); // 输出100
// await等待Promise结果
async function fetchData() {
// 等请求完成,再把结果赋值给user(同步写法,异步执行)
const user = await requestData('https://jsonplaceholder.typicode.com/users/1');
console.log('拿到用户信息:', user);
return user;
}改写串行请求:极致简洁
async function fetchAllData() {
try {
// 第一步:请求用户(等完成再走下一步)
const user = await requestData('https://jsonplaceholder.typicode.com/users/1');
console.log('用户信息:', user);
// 第二步:请求待办(依赖用户ID)
const todos = await requestData(`/users/${user.id}/todos`);
console.log('待办列表:', todos);
// 第三步:请求待办详情(依赖待办ID)
const detail = await requestData(`/todos/${todos[0].id}`);
console.log('待办详情:', detail);
return detail;
} catch (err) {
// 统一捕获所有错误(任何await失败都会进这里)
console.error('请求出错:', err);
} finally {
console.log('请求流程结束');
}
}
// 调用
fetchAllData();异步并行请求(提升性能)
await默认是“串行等待”,如果多个请求互不依赖,用Promise.all+await实现并行,节省时间:
async function fetchParallel() {
try {
// 同时发起三个请求(并行执行)
const userPromise = requestData('https://jsonplaceholder.typicode.com/users/1');
const todoPromise = requestData('https://jsonplaceholder.typicode.com/todos/1');
const postPromise = requestData('https://jsonplaceholder.typicode.com/posts/1');
// 等所有请求完成,一次性拿结果
const [user, todo, post] = await Promise.all([userPromise, todoPromise, postPromise]);
console.log('并行请求结果:', { user, todo, post });
} catch (err) {
console.error('有请求失败:', err);
}
}6. 补充方案:应对复杂场景
除了主流写法,还有两种方案针对特殊场景,作为补充:
RxJS:复杂异步流的“瑞士军刀”
适合处理“多源异步、高频交互”场景(如实时搜索、WebSocket数据),能轻松实现防抖、节流、取消请求等功能。缺点是学习曲线陡,简单场景没必要用。
Web Workers:解决主线程阻塞
如果有大数据计算、复杂逻辑处理(如图片压缩),用Web Workers开个“子线程”专门处理,不耽误主线程渲染页面。缺点是子线程不能操作DOM,只能和主线程传数据。
三、总结:不同场景选哪种方案?
按“简单到复杂”梳理,直接对号入座:
场景 | 推荐方案 |
|---|---|
简单异步(单次回调、定时器、DOM事件) | 回调函数 / 事件监听 |
组件通信、多回调响应 | 发布-订阅(EventBus) |
普通网络请求(串行/并行) | async/await + Promise(首选) |
实时数据、高频交互、复杂流处理 | RxJS |
大数据计算、耗时逻辑(避免页面卡死) | Web Workers |
最后:异步编程的核心,始终是“在单线程里,不阻塞主线程地处理耗时操作”。async/await是当前最优雅的写法,但理解它底层的Promise、回调函数,才能应对所有异步场景,真正做到“知其然也知其所以然”。
版权所属:SO JSON在线解析
原文地址:https://www.sojson.com/blog/572.html
转载时必须以链接形式注明原始出处及本声明。
如果本文对你有帮助,那么请你赞助我,让我更有激情的写下去,帮助更多的人。
