JavaScript 中的事件循环(Event Loop):从基础到深入
JavaScript 是一种单线程语言,这意味着它一次只能执行一个任务。这种设计简化了开发过程,避免了多线程编程中的复杂性,但也带来了问题——如何在不阻塞主线程的情况下处理耗时操作(如网络请求、定时器等)。事件循环(Event Loop) 正是 JavaScript 用来解决这个问题的核心机制。
一、JavaScript 的单线程特性
JavaScript 最初被设计为单线程语言,主要是为了防止浏览器中多个脚本同时操作 DOM 所带来的同步问题。例如:
document.getElementById('btn').addEventListener('click', () => {
console.log('Button clicked!');
});
如果多个线程可以同时修改 DOM,就会导致状态不一致的问题。因此,JavaScript 引擎只允许在一个线程上执行代码。
二、调用栈(Call Stack)
JavaScript 使用 调用栈(Call Stack) 来管理函数的执行顺序。每当遇到一个函数调用,引擎就会将其推入调用栈中执行;当函数返回后,它会被弹出调用栈。
function foo() {
console.log('foo');
}
function bar() {
foo();
console.log('bar');
}
bar();
console.log('end');
调用栈的变化如下:
bar()被调用 → 推入栈。bar中调用foo()→foo()推入栈。foo()执行完毕 → 弹出栈。bar()继续执行 → 输出'bar',然后弹出栈。- 最后输出
'end'。
这就是 JavaScript 同步执行的基本流程。
三、异步回调与 Web APIs
JavaScript 虽然是单线程的,但浏览器并不是。浏览器提供了许多 Web APIs(如 setTimeout、fetch、DOM 事件等),它们可以在 JavaScript 主线程之外运行。例如:
console.log('Start');
setTimeout(() => {
console.log('Timeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise');
});
console.log('End');
输出顺序为:Start、end、Promise、Timeout
为什么异步代码不是按代码顺序执行?这就引出了我们接下来要讲的 事件循环机制。
四、事件循环(Event Loop)的工作原理
Event Loop 事件循环是 JavaScript 运行时中用于协调代码执行、处理异步操作(如定时器、网络请求、用户交互等)的一种机制。
事件循环的主要职责是协调 调用栈 和 消息队列(Message Queue),确保 JavaScript 在单线程下也能高效地处理异步任务。
-
事件循环工作原理:
- 事件循环从宏任务队列中取出一个宏任务来执行,(例如,由
setTimeout、setInterval、I/O事件或用户交互等触发的任务)js脚本也是一个宏任务。 - 执行宏任务中的同步代码。当遇到异步操作,若该任务是宏任务则将其放入宏任务队列,若是微任务则将其放入微任务队列。
- 一旦当前宏任务执行完毕,事件循环不会立即去取下一个宏任务来执行。而是连续不断地执行微任务队列中的每一个微任务(如
Promise的.then()或.catch()回调、MutationObserver回调等),直到微任务队列为空。 - 微任务都执行完后,则会进行页面渲染
- 最后,事件循环才会回到宏任务队列,选择下一个宏任务来继续这个循环。
以如下为例:
console.log("第一轮宏任务开始"); console.log("第一轮同步代码执行"); // Promise 本身是同步的,它后面的then方法是异步的 const promise1 = Promise.resolve("Promise1"); const promise2 = Promise.resolve("Promise2"); const promise3 = new Promise((resolve) => { console.log("promise3"); resolve("Promise3"); }); // 这是第一轮的微任务 promise1.then((res) => { console.log(res); }); promise2.then((res) => { console.log(res); }); promise3.then((res) => { console.log(res); }); setTimeout(() => { console.log("第二轮宏任务开始"); console.log("第二轮同步代码执行"); const promise4 = Promise.resolve("Promise4"); promise4.then((res) => { console.log(res); }); console.log("第二轮宏任务结束"); }, 0); setTimeout(() => { console.log("第三轮宏任务开始"); }, 0); console.log("第一轮宏任务结束");Promise.then是微任务,会在当前宏任务结束后立即执行;setTimeout是宏任务,虽然设置为 0ms,但依然排在下一个宏任务阶段;执行结果如下:
- 事件循环从宏任务队列中取出一个宏任务来执行,(例如,由
-
JavaScript中任务被分为宏任务(macrotask)和微任务(microtask)
宏任务队列(Macro Task Queue)
每次事件循环迭代会从宏任务队列中取出一个任务放入调用栈执行。
常见的宏任务包括:
setTimeoutsetIntervalsetImmediate(Node.js)- I/O 操作
- UI 渲染(浏览器)
微任务队列(Micro Task Queue)
微任务具有更高的优先级,在每次宏任务执行完之后,会清空微任务队列。
常见的微任务包括:
-
Promise.then()/catch/finally这里promise本身是同步的,但Promise.then()是异步的
例如:
console.log('同步代码开始执行') const promise = new Promise((resolve)=>{ console.log('promise执行') resolve('微任务执行') }) promise.then((res)=>{ console.log(res) }) console.log('同步代码执行结束')执行结果如下,可以看出Promise本身是同步的
同步代码开始执行 promise执行 同步代码执行结束 微任务执行 -
queueMicrotask这个方法是用于将一个函数作为微任务(microtask)加入到微任务队列中
queueMicrotask(function); -
MutationObserverMutationObserver是 JavaScript 中用于监视 DOM 元素变化的 API。它允许你观察特定 DOM 元素的变化(例如属性、子节点、文本内容等),并在发生变化时执行回调函数。
Node.js 中可以通过 process.nextTick() 插入比微任务还优先的任务
六、实际应用与优化建议
-
避免长时间阻塞主线程
不要在主线程中执行大量计算或同步阻塞操作,否则会导致页面卡顿甚至崩溃。
// 不推荐 for (let i = 0; i < 1e9; i++) {} // 推荐使用异步方式或 Web Worker setTimeout(() => { // 执行耗时操作 }, 0); -
合理使用微任务和宏任务
- 如果希望尽快执行某个任务,使用
Promise.resolve().then(...); - 如果需要延迟执行,使用
setTimeout(fn, 0); - 如果是动画相关,使用
requestAnimationFrame; - 如果是 Node.js,考虑使用
setImmediate或process.nextTick。
- 如果希望尽快执行某个任务,使用
-
注意微任务爆炸问题
过多的微任务可能导致主线程无法处理其他任务,造成性能瓶颈。例如递归调用
Promise.then可能导致堆栈溢出或性能下降。