JS 异步编程核心 —— 事件循环与 Promise/async-await 实战
JS 是单线程语言,事件循环是异步编程的底层执行机制,解决单线程的阻塞问题;同步任务 → 所有微任务 → 一个宏任务 → 所有微任务,周而复始;Promise 是异步编程的标准解决方案,状态不可逆,链式调用解决回调地狱;async-await 是 Promise 的语法糖,将异步代码写为同步风格,是开发的首选方式;多个异步任务的处理:并行用allSettled,竞态用raceany;异步编程的核心
正文
引言:为什么异步代码的执行顺序总是出乎意料?
先看一道经典的异步执行顺序题,你能准确说出输出结果吗?
javascript
运行
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3);
});
}, 0);
Promise.resolve().then(() => {
console.log(4);
setTimeout(() => {
console.log(5);
}, 0);
});
console.log(6);
// 输出结果:1 6 4 2 3 5
为什么setTimeout的延迟设为 0,却不是立即执行?为什么 Promise 的then回调总是比setTimeout先执行?这一切的答案,都藏在 JavaScript 的单线程和事件循环中。
JavaScript 是一门单线程语言,同一时间只能执行一个任务,为了解决单线程的阻塞问题,JS 引入了异步编程,而事件循环(Event Loop) 是异步编程的底层执行机制,Promise、async-await 都是基于事件循环的上层封装。本文将从单线程的本质出发,拆解事件循环的执行流程,讲解宏任务 / 微任务的分类,以及 Promise、async-await 的底层实现,让你彻底掌握 JS 异步编程的执行顺序,解决开发中异步回调的各类问题。
一、JS 异步编程的基础:单线程与事件循环
1.1 为什么 JS 是单线程?
JavaScript 的设计初衷是浏览器端的脚本语言,主要用于操作 DOM、处理用户交互。如果 JS 是多线程,会引发DOM 操作的竞争问题:比如一个线程修改 DOM,另一个线程删除 DOM,浏览器无法判断执行顺序。
为了避免 DOM 操作的冲突,JS 被设计为单线程:同一时间,只有一个执行栈,只能执行一个任务。但单线程的缺点也很明显:同步任务会阻塞后续任务的执行,比如一个耗时的网络请求、定时器,会导致页面卡死。
示例:同步任务阻塞异步任务
javascript
运行
// 同步阻塞
function sleep(time) {
const start = Date.now();
while (Date.now() - start < time) {}
}
console.log(1);
setTimeout(() => { console.log(2); }, 0);
sleep(2000); // 同步阻塞2秒
console.log(3);
// 输出:1 3 2(setTimeout被同步任务阻塞,2秒后才执行)
1.2 事件循环:异步编程的底层执行机制
为了解决单线程的阻塞问题,JS 引入了事件循环,将任务分为同步任务和异步任务,并通过执行栈、任务队列实现异步任务的调度执行。
核心概念:
- 执行栈(Call Stack):用于执行同步任务的栈结构,遵循后进先出原则,同步任务会依次入栈,执行完毕后出栈;
- 任务队列(Task Queue):用于存放异步任务的回调函数,异步任务完成后,其回调会被加入任务队列,等待执行栈为空时执行;
- 事件循环:不断循环的过程,核心逻辑是检查执行栈是否为空,若为空,将任务队列中的第一个任务入栈执行,周而复始。
事件循环的基础执行流程(必记):
- 执行所有同步任务,依次加入执行栈,执行完毕后出栈;
- 执行栈为空后,检查任务队列,将第一个异步回调任务入栈执行;
- 执行完毕后,再次检查任务队列,重复上述过程,形成循环。
注意:setTimeout(fn, 0)的含义不是立即执行,而是将回调函数加入任务队列的时间延迟 0 毫秒,实际执行时间取决于执行栈是否为空,因此实际延迟会大于 0 毫秒。
二、宏任务与微任务:事件循环的核心分类
ES6 之后,事件循环将异步任务细分为宏任务(Macro Task) 和微任务(Micro Task),这是决定异步执行顺序的核心,微任务的执行优先级高于宏任务。
2.1 宏任务与微任务的分类
宏任务:耗时较长的异步任务,由浏览器 / Node.js 引擎提供,每次事件循环仅执行一个宏任务;微任务:耗时较短的异步任务,由 ES6 标准提供,每次事件循环会执行所有微任务,直到微任务队列为空。
常用任务分类(开发中高频,必记):
表格
| 类型 | 包含的异步任务 |
|---|---|
| 宏任务 | setTimeout、setInterval、DOM 事件、AJAX 请求、Node.js 的 fs/process、script 整体代码 |
| 微任务 | Promise 的 then/catch/finally、async-await(底层是 Promise)、MutationObserver、process.nextTick(Node.js,优先级最高) |
核心执行规则(事件循环的进阶流程,必记):
- 执行同步任务(属于宏任务的 script 整体代码),执行栈为空;
- 执行所有微任务,依次入栈执行,直到微任务队列为空;
- 执行一个宏任务,执行完毕后,执行栈为空;
- 再次执行所有微任务,直到微任务队列为空;
- 重复步骤 3-4,形成事件循环;
- 微任务执行过程中产生的新微任务,会加入当前微任务队列,立即执行。
开篇问题解答:按规则拆解执行顺序
javascript
运行
console.log(1); // 同步任务,立即执行 → 输出1
setTimeout(() => { /* 宏任务1,加入宏任务队列 */ }, 0);
Promise.resolve().then(() => { /* 微任务1,加入微任务队列 */ });
console.log(6); // 同步任务,立即执行 → 输出6
// 同步任务执行完毕,执行所有微任务(微任务1)
console.log(4); // 输出4
setTimeout(() => { /* 宏任务2,加入宏任务队列 */ }, 0);
// 微任务执行完毕,执行一个宏任务(宏任务1)
console.log(2); // 输出2
Promise.resolve().then(() => { /* 微任务2,加入微任务队列 */ });
// 宏任务1执行完毕,执行所有微任务(微任务2)
console.log(3); // 输出3
// 微任务执行完毕,执行一个宏任务(宏任务2)
console.log(5); // 输出5
// 最终输出:1 6 4 2 3 5
2.2 浏览器与 Node.js 事件循环的差异
Node.js 的事件循环也是基于宏任务 / 微任务,但比浏览器更复杂,分为 6 个阶段,微任务的执行时机不同:
- 浏览器:宏任务执行完毕后,立即执行所有微任务;
- Node.js(v10+):与浏览器一致,微任务优先级高于宏任务,process.nextTick 是 Node.js 独有的微任务,优先级高于其他微任务;
- 开发中无需过度关注差异,核心记住微任务先于宏任务执行即可。
三、Promise:异步编程的标准解决方案
回调地狱是早期 JS 异步编程的痛点,而 Promise 是 ES6 引入的异步编程标准解决方案,用于解决回调地狱问题,其底层基于事件循环,属于微任务。
3.1 Promise 的核心概念
Promise 是一个构造函数,用于封装异步操作,并返回一个可以获取异步操作结果的对象。Promise 有三个状态,且状态不可逆(核心特性):
- pending(进行中):初始状态,异步操作未完成;
- fulfilled(已成功):异步操作完成,调用
resolve(),状态变为 fulfilled,返回结果值; - rejected(已失败):异步操作失败,调用
reject(),状态变为 rejected,返回错误原因。
核心特性:
- 状态不可逆:一旦从 pending 变为 fulfilled/rejected,就无法再修改;
- 链式调用:
then/catch/finally方法返回一个新的 Promise,支持链式调用,解决回调地狱; - 微任务:
then/catch/finally的回调属于微任务,会加入微任务队列。
3.2 Promise 的基本使用
javascript
运行
// 创建Promise
const p = new Promise((resolve, reject) => {
// 异步操作:比如AJAX、定时器
setTimeout(() => {
const random = Math.random();
if (random > 0.5) {
resolve("成功结果"); // 状态变为fulfilled
} else {
reject(new Error("失败原因")); // 状态变为rejected
}
}, 1000);
});
// 链式调用
p.then(res => {
console.log(res); // 成功时执行
}).catch(err => {
console.error(err); // 失败时执行
}).finally(() => {
console.log("无论成功失败都会执行"); // 最终执行
});
3.3 Promise 的链式调用:解决回调地狱
早期异步编程使用回调函数,多层嵌套会形成回调地狱,代码可读性差、维护困难;Promise 的链式调用可以将嵌套的回调改为线性的调用,解决回调地狱。
回调地狱示例:
javascript
运行
// 多层嵌套,可读性差
setTimeout(() => {
console.log(1);
setTimeout(() => {
console.log(2);
setTimeout(() => {
console.log(3);
}, 1000);
}, 1000);
}, 1000);
Promise 链式调用解决:
javascript
运行
function delay(time, val) {
return new Promise(resolve => {
setTimeout(() => {
console.log(val);
resolve(val);
}, time);
});
}
// 线性调用,可读性高
delay(1000, 1)
.then(() => delay(1000, 2))
.then(() => delay(1000, 3));
3.4 Promise 的常用静态方法(开发高频)
Promise 提供了多个静态方法,用于处理多个异步任务,解决并行、串行、竞态等问题:
- Promise.resolve(value):快速创建一个已成功的 Promise;
- Promise.reject(reason):快速创建一个已失败的 Promise;
- Promise.all([p1, p2, p3]):并行执行多个 Promise,所有都成功则返回结果数组,有一个失败则立即返回失败;
- Promise.allSettled([p1, p2, p3]):并行执行多个 Promise,无论成功失败,都返回所有结果(包含 status 和 value/reason);
- Promise.race([p1, p2, p3]):竞态执行,返回第一个完成的 Promise(无论成功失败);
- Promise.any([p1, p2, p3]):竞态执行,返回第一个成功的 Promise,所有都失败则返回 AggregateError。
开发中常用场景:
- 并行请求多个接口,所有请求成功后处理数据:使用
Promise.all; - 并行请求多个接口,需要获取所有请求的结果(无论成败):使用
Promise.allSettled; - 超时处理:使用
Promise.race(将异步任务与定时器 Promise 结合)。
超时处理示例(开发高频):
javascript
运行
// 模拟接口请求
function request() {
return new Promise((resolve) => {
setTimeout(() => {
resolve("请求成功");
}, 3000);
});
}
// 超时Promise
function timeout() {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("请求超时"));
}, 2000);
});
}
// 竞态执行:2秒内请求未完成则触发超时
Promise.race([request(), timeout()])
.then(res => console.log(res))
.catch(err => console.error(err)); // 输出:Error: 请求超时
四、async-await:Promise 的语法糖(开发首选)
ES2017 引入了async-await,是Promise 的语法糖,底层依然是 Promise 和事件循环,其最大的优点是将异步代码写得像同步代码,可读性更高,是目前 JS 开发中异步编程的首选方式。
4.1 async-await 的核心语法
- async 关键字:用于修饰函数,使函数返回一个Promise 对象;
- 函数内部的返回值,会作为 Promise 的 resolve 值;
- 函数内部抛出的错误,会作为 Promise 的 reject 值。
- await 关键字:只能在
async函数内部使用,用于等待 Promise 的执行结果;- 等待 Promise 的状态变为 fulfilled,返回结果值;
- 若 Promise 被 rejected,且未被捕获,会抛出错误,终止函数执行。
基本使用:
javascript
运行
// 异步函数
function delay(time, val) {
return new Promise(resolve => {
setTimeout(() => resolve(val), time);
});
}
// async-await使用
async function fn() {
const res1 = await delay(1000, 1);
console.log(res1); // 1
const res2 = await delay(1000, 2);
console.log(res2); // 2
return res1 + res2;
}
// async函数返回Promise
fn().then(res => console.log(res)); // 3
4.2 async-await 的错误处理
await 的 Promise 被 rejected 时,会抛出错误,需要通过try/catch捕获,这是 async-await 的标准错误处理方式。
javascript
运行
async function fn() {
try {
// 等待失败的Promise
await Promise.reject(new Error("异步失败"));
} catch (err) {
console.error(err); // 捕获错误:Error: 异步失败
} finally {
console.log("无论成败都会执行");
}
}
fn();
批量异步的错误处理:
javascript
运行
// 并行执行多个异步任务,单独捕获每个任务的错误
async function fn() {
const p1 = delay(1000, 1).catch(err => err);
const p2 = Promise.reject(new Error("失败")).catch(err => err);
const [res1, res2] = await Promise.all([p1, p2]);
console.log(res1); // 1
console.log(res2); // Error: 失败
}
4.3 async-await 的并行执行
async-await 中,直接连续使用 await 会导致串行执行,若需要并行执行多个异步任务,需先创建 Promise 实例,再 await。
javascript
运行
// 串行执行:总耗时2000ms
async function serial() {
const res1 = await delay(1000, 1);
const res2 = await delay(1000, 2);
}
// 并行执行:总耗时1000ms(推荐)
async function parallel() {
const p1 = delay(1000, 1);
const p2 = delay(1000, 2);
const [res1, res2] = await Promise.all([p1, p2]);
}
五、异步编程的常见问题与解决方案
5.1 回调地狱(早期问题)
问题:多层回调函数嵌套,代码可读性差、维护困难;解决方案:
- 使用 Promise 的链式调用;
- 使用 async-await(推荐),将异步代码写为同步风格。
5.2 异步执行顺序错误
问题:未理解事件循环的宏任务 / 微任务规则,导致异步代码执行顺序不符合预期;解决方案:
- 熟记宏任务 / 微任务的分类和执行规则;
- 复杂异步流程使用 Promise/async-await,而非原生定时器。
5.3 多个异步任务的并行 / 串行混淆
问题:async-await 中直接使用 await 导致串行执行,浪费性能;解决方案:
- 并行执行使用
Promise.all+await; - 串行执行直接使用连续的 await。
5.4 未捕获的 Promise 错误
问题:Promise 的 rejected 状态未被 catch,控制台抛出未捕获错误;解决方案:
- Promise 链式调用末尾添加 catch;
- async-await 使用 try/catch 捕获错误;
- 全局捕获未处理的 Promise 错误(浏览器:
window.addEventListener('unhandledrejection'))。
六、异步编程的最佳实践
- 首选 async-await:代码可读性最高,是目前开发的主流方式;
- 并行执行用 Promise.all:多个异步任务无依赖时,优先并行执行,提升性能;
- 必做错误处理:Promise 添加 catch,async-await 使用 try/catch,避免未捕获错误;
- 超时处理用 Promise.race:异步任务(如接口请求)必须添加超时处理,提升用户体验;
- 多个异步结果用 Promise.allSettled:需要获取所有异步任务的结果时,使用 allSettled;
- 避免嵌套的 async-await:保持代码的线性结构,提升可读性;
- 全局捕获未处理的 Promise 错误:防止控制台报错,提升代码的健壮性。
总结
- JS 是单线程语言,事件循环是异步编程的底层执行机制,解决单线程的阻塞问题;
- 异步任务分为宏任务和微任务,执行规则:同步任务 → 所有微任务 → 一个宏任务 → 所有微任务,周而复始;
- Promise 是异步编程的标准解决方案,状态不可逆,链式调用解决回调地狱;
- async-await 是 Promise 的语法糖,将异步代码写为同步风格,是开发的首选方式;
- 多个异步任务的处理:并行用
Promise.all/allSettled,竞态用race/any; - 异步编程的核心是掌握执行顺序和做好错误处理。
事件循环是 JS 异步编程的底层基础,Promise 和 async-await 是上层封装,掌握这些知识点,能解决开发中所有异步相关的执行顺序、回调地狱、错误处理问题,也是理解前端框架异步
更多推荐


所有评论(0)