JS中的事件循环机制与异步处理
JavaScript 是单线程的,这意味着它一次只能执行一个任务。为了处理异步操作,JavaScript 依赖 事件循环(Event Loop) 机制——它允许 JS 引擎在等待异步操作完成时继续执行其他任务,从而实现非阻塞的异步编程。
1. 事件循环机制
事件循环机制依靠 调用栈(Call Stack) 和 任务队列(Task Queue) 协同工作,共同实现 JavaScript 的异步编程模型。
调用栈:一种后进先出(LIFO)的数据结构,用于跟踪正在执行的函数。函数被调用时入栈,执行完毕后出栈。
任务队列:一种先进先出(FIFO)的数据结构,用于存放等待执行的异步回调。
事件循环:持续监控调用栈的状态——当调用栈为空且任务队列中存在待处理任务时,事件循环会将任务队列中的第一个任务移入调用栈执行。
核心概念
事件循环的本质是一个调度机制,它决定了同步代码、微任务、宏任务的执行顺序。理解这个调度顺序是掌握 JavaScript 异步编程的关键。
1.1 同步任务与异步任务
JavaScript 中的任务分为同步任务与异步任务,二者的执行时机不同:
- 同步任务:直接进入调用栈执行,前一个任务完成后才能执行下一个。例如
console.log()。 - 异步任务:不进入主线程,而是先进入任务队列等待,待调用栈清空后再由事件循环调度执行。
1.2 微任务与宏任务
异步任务又进一步分为 微任务(Microtask) 和 宏任务(Macrotask),它们对应不同优先级的任务队列,事件循环按特定顺序执行这些任务。
宏任务主要由宿主环境(浏览器、Node.js)发起,微任务由 JS 引擎自身发起。
微任务:Promise.then()、Promise.catch()、Promise.finally()、queueMicrotask()、async/await(本质是 Promise 的语法糖)
宏任务:setTimeout、setInterval、I/O 操作、<script> 代码块
注意
process.nextTick() 是 Node.js 环境特有的 API,不属于浏览器标准的微任务队列。在浏览器中应使用 queueMicrotask() 或 Promise 来派发微任务。
1.3 执行过程
事件循环的执行流程为:同步任务执行 → 微任务队列清空 → 宏任务逐个执行。下面用代码说明:
console.log(1); // 同步任务,直接执行
setTimeout(() => {
console.log(2); // 回调进入宏任务队列
}, 0);
const p = new Promise((resolve) => {
// Promise 执行器本身是同步的
console.log(3);
console.log(4);
resolve(5); // Promise 状态变为 fulfilled
});
p.then((data) => {
console.log(data); // 回调进入微任务队列
});
console.log(6); // 同步任务,直接执行上述代码在事件循环中的调度过程:
第一步:同步代码逐行执行
1. console.log(1) → 输出: 1
2. setTimeout(回调) → 回调进入宏任务队列
3. new Promise(执行器) → 执行器同步执行:
- console.log(3) → 输出: 3
- console.log(4) → 输出: 4
- resolve(5) → Promise 状态变为 fulfilled
4. p.then(回调) → 回调进入微任务队列
5. console.log(6) → 输出: 6
同步代码执行完毕,当前输出: 1 3 4 6
第二步:清空微任务队列(优先于宏任务)
微任务队列: [() => console.log(data)] // data = 5
执行微任务 → 输出: 5
第三步:执行宏任务
宏任务队列: [() => console.log(2)]
执行宏任务 → 输出: 2
最终输出: 1 3 4 6 5 2关键结论
无论宏任务的延迟时间多短(即使是 0ms),微任务的执行优先级始终高于宏任务。因为事件循环在每一轮中会清空微任务队列,而每次只取一个宏任务执行。
2. 异步操作
在理解事件循环机制后,接下来介绍 JavaScript 中三种主要的异步编程方式:异步回调、Promise 和 async/await。
2.1 异步回调
先看一段同步回调的代码:
function A(callback) {
callback(); // 同步调用回调函数
console.log("A"); // 等待 callback 执行完毕后才执行
}
function B() {
for (let i = 0; ; i++) {
console.log(i);
if (i === 99) {
console.log("B");
break;
}
}
}
A(B);
// 输出: 0 1 2 ... 99 B A
// B 的耗时循环阻塞了 A 的后续执行函数 B() 模拟了一个耗时操作。从运行结果可以看到,直到打印完 99 和 "B" 之后才输出 "A"——函数 A 必须等待 B 完全执行完毕才能继续。这是因为二者都是同步函数,执行流程自上而下严格顺序进行。
要让 B 的执行不阻塞 A,需要将 B 改为异步执行:
function A(callback) {
setTimeout(callback, 0); // 将回调变为异步(宏任务)
console.log("A"); // 同步代码,立即执行
}
function B() {
for (let i = 0; ; i++) {
console.log(i);
if (i === 99) {
console.log("B");
break;
}
}
}
A(B);
// 输出: A 0 1 2 ... 99 B
// A 不再被 B 阻塞即使将 setTimeout 的延迟设为 0(或 1000),输出结果依然是先 "A" 后 "B"。因为 setTimeout 的回调属于宏任务,不会阻塞当前同步代码——console.log("A") 先执行,"B" 在后续事件循环轮次中输出。
回调地狱
虽然回调函数可以解决异步问题,但如果回调嵌套层数过多,代码会变得难以阅读和维护,这就是常说的回调地狱(Callback Hell)。Promise 的出现正是为了解决这一问题。
2.2 Promise
Promise 是异步编程的另一种解决方案。它是一个对象,代表一个尚未完成但预期在未来完成的操作,从而避免了传统回调函数的层层嵌套。
Promise 本质上是一个容器,其中保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上讲,Promise 是一个对象,通过它可以获取异步操作的状态和结果,且提供统一的 API 来处理各种异步操作。
Promise 有三种状态:
| 状态 | 含义 | 转变方式 |
|---|---|---|
| Pending | 进行中,尚未有结果 | 初始状态 |
| Fulfilled | 已成功完成 | 调用 resolve() |
| Rejected | 已失败 | 调用 reject() |
状态的转变通过 resolve() 和 reject() 函数触发,且一旦状态改变就不可逆转。
Promise 构造函数接收一个执行器函数作为参数,该函数接收 resolve 和 reject 两个回调。异步操作成功时调用 resolve 返回结果,失败时调用 reject 返回错误信息。then() 方法用于接收成功结果,catch() 方法用于接收失败结果。
const promise = new Promise(function (resolve, reject) {
// 异步操作
if (/* 异步操作成功 */) {
resolve(value); // 状态 → fulfilled
} else {
reject(error); // 状态 → rejected
}
});Promise 方法
then():获取 Promise 成功(fulfilled)后的结果。
promise.then((result) => {
console.log(result); // 处理成功结果
});catch():捕获 Promise 失败(rejected)的错误。catch() 方法相当于 then(undefined, onRejected),但它还能捕获 then 回调中抛出的异常,不会让程序中断执行。
promise.catch((error) => {
console.error(error); // 处理失败原因
});除此之外,Promise 还提供了 Promise.all()、Promise.race()、Promise.finally() 等静态方法,详情可参考 Promise 详解 或 MDN Promise 文档。
2.3 async/await
Promise 虽然解决了回调地狱,但连续的 then() 调用仍会产生大量链式代码。async/await 是 ES2017 引入的语法糖,让异步代码看起来像同步代码,可读性大幅提升。
以下对比展示了 Promise 链式调用和 async/await 的写法差异:
function takeTimeDelay(n) {
return new Promise((resolve) => {
setTimeout(() => resolve(n + 1), 1000); // 模拟 1 秒延迟
});
}
function step1(n) {
console.log("step1 = " + n);
return takeTimeDelay(n);
}
function step2(n) {
console.log("step2 = " + n);
return takeTimeDelay(n);
}
function step3(n) {
console.log("step3 = " + n);
return takeTimeDelay(n);
}
// Promise 链式调用
function _promise() {
const n = 0;
step1(n)
.then((time2) => step2(time2))
.then((time3) => step3(time3))
.then((result) => {
console.log("result is " + result);
});
}
// async/await 实现
async function _async() {
const n = 0;
const value1 = await step1(n); // 等待 step1 完成
const value2 = await step2(value1); // 等待 step2 完成
const result = await step3(value2); // 等待 step3 完成
console.log("result is " + result);
}
_promise();
_async();
// 两种写法输出结果相同,但 async/await 更简洁清晰对比总结:
- Promise 链式调用:通过
.then()串联,流程清晰但嵌套较多时仍显冗长。 - async/await:语法近似同步代码,易于阅读和调试,适合处理由多个 Promise 组成的顺序依赖。
- 两者在事件循环中的行为一致——
await本质上是Promise.then()的语法糖,等待的部分进入微任务队列。
async 用于将函数声明为异步函数。如果 async 函数有返回值,JS 引擎会隐式调用 Promise.resolve() 将其包装为 Promise 对象;如果没有返回值,则返回 undefined。
await 会暂停当前 async 函数的执行,等待右侧 Promise 完成,但不会阻塞外部代码,也不会阻塞事件循环。
async function asyncFunction() {
return "1"; // 等价于 return Promise.resolve("1")
}
asyncFunction().then((val) => {
console.log(val); // 微任务,等待同步代码执行完毕
});
console.log("外部函数1"); // 同步代码,先执行
async function main() {
const res = await asyncFunction(); // 暂停 main,等待 Promise 完成
console.log(res); // 恢复执行后输出
}
main();
console.log("外部函数2"); // 同步代码,先于 main 中的 await 之后的代码执行
// 输出顺序: 外部函数1 → 外部函数2 → 1 → 1由于 async 函数返回的是 Promise 对象,以下两种写法等价:
// 方式一:显式返回 Promise
function _function() {
return Promise.resolve("Hello");
}
// 方式二:async 隐式包装
async function _function() {
return "Hello";
}
// 两种方式均通过 .then() 获取结果
_function().then((res) => {
console.log(res); // 输出: Hello
});