从零开始玩转JavaScript事件轮询:让你的代码跳舞的艺术

136 阅读7分钟

JavaScript 是一门单线程语言,这意味着它一次只能执行一个任务。为了处理异步操作如网络请求、定时器等,JavaScript 使用了事件轮询机制。接下来我们将深入探讨这一核心概念,并通过详细的文字描述帮助你构建一个清晰的事件轮询工作原理图。


🧠 一、JavaScript 是单线程语言,但如何实现异步?

JavaScript 最初设计为单线程语言,意味着它在同一时间只能执行一个任务。这种设计避免了多线程中常见的复杂问题(如死锁、竞态条件),但也带来了一个挑战:如果某个任务很耗时,整个页面就会“卡住”

为了解决这个问题,JavaScript 引入了 事件循环机制(Event Loop),通过将任务分为不同类型(宏任务和微任务),在主线程空闲时按优先级依次执行这些任务,从而实现了高效的异步编程。


📌 二、事件循环的五大核心组成部分

1. 调用栈(Call Stack)

调用栈是 JavaScript 执行函数的地方。当函数被调用时,它会被压入调用栈;函数执行完毕后,会从调用栈弹出。

function foo() {
  console.log('foo');
}
function bar() {
  foo();
}
bar(); // 函数调用顺序形成调用栈

调用栈变化:

  • bar() 入栈 → foo() 入栈 → foo() 出栈 → bar() 出栈

2. 浏览器 API(Web APIs)

对于像 setTimeoutfetchaddEventListener 这类异步操作,它们不是由 JavaScript 主线程直接执行的,而是交由浏览器的其他线程处理(如定时器线程、网络线程等)。

当这些操作完成后,它们会将回调放入相应的任务队列中等待执行。


3. 回调队列(Task Queue)

宏任务队列(Macrotask Queue)

  • 存放宏任务,如:
    • setTimeout
    • setInterval
    • DOM 事件
    • I/O
    • 整个 <script> 标签的内容

微任务队列(Microtask Queue)

  • 存放微任务,如:
    • Promise.then / .catch / .finally
    • MutationObserver
    • queueMicrotask
    • Node.js 中的 process.nextTick

📌 重点原则

在一次完整的事件循环中,先执行所有微任务,再执行下一个宏任务。


4. 事件循环(Event Loop)

事件循环是一个持续运行的机制,它的主要职责是:

  1. 检查调用栈是否为空;
  2. 如果调用栈为空,则查看微任务队列是否有待执行的任务;
  3. 如果微任务队列也为空,则查看宏任务队列是否有任务;
  4. 将选中的任务推入调用栈并执行。

🎯 三、宏任务 vs 微任务:优先级差异详解

类型示例特点
宏任务setTimeout, setInterval, DOM 事件, I/O每次事件循环只执行一个宏任务
微任务Promise.then/catch/finally, MutationObserver, queueMicrotask, process.nextTick (Node)一旦有微任务就立即执行,直到微任务队列清空

🔁 四、事件循环完整执行流程

为了更清晰地展示 JavaScript 事件轮询的工作原理,下面是一个详细的流程描述:

  1. 初始状态:程序开始运行,调用栈为空。
  2. 同步代码执行:所有同步代码被推入调用栈并依次执行。
  3. 异步操作触发
    • 当遇到如 setTimeout 或者网络请求等异步操作时,它们不会立即进入调用栈,而是通过浏览器 API 处理。
    • 这些操作完成后,其回调函数会被放入宏任务队列中。
  4. Promise 和其他微任务产生:当 Promise 被解析(fulfilled 或 rejected),它的 .then().catch() 方法中的回调会被添加到微任务队列。
  5. 检查微任务队列:在每次宏任务结束后,JavaScript 引擎会清空当前的微任务队列,确保所有微任务都得到处理。
  6. 宏任务执行:一旦微任务队列为空,事件轮询会选择下一个宏任务执行,重复上述过程。
  7. 页面渲染:通常,在宏任务执行完毕后且没有更多宏任务待处理时,浏览器会进行页面重绘或重新布局。

文字流程示例:

  • Step 1: 程序启动,首先执行全局脚本,这作为一个宏任务存在于宏任务队列中。
  • Step 2: 在执行过程中,如果遇到像 fetch 这样的异步请求,它会被传递给 Web APIs 处理,而不会阻塞后续代码的执行。
  • Step 3: 如果在此期间有任何 Promise 被解决,相关的回调将被加入到微任务队列。
  • Step 4: 全局脚本执行完毕后,JavaScript 引擎会检查并执行所有的微任务。
  • Step 5: 完成微任务后,如果存在任何宏任务(例如由 setTimeout 设置的回调),则选择一个宏任务执行。
  • Step 6: 如果此时没有更多的宏任务等待执行,浏览器可能会选择更新用户界面。

🧪 五、经典代码示例 + 详细解析

示例1:基本宏任务与微任务执行顺序

console.log("script start");

setTimeout(() => {
  console.log("setTimeout");
}, 0);

Promise.resolve().then(() => {
  console.log("promise");
});

console.log("script end");

执行流程分析:

  1. 同步代码:

    • 输出 "script start"
    • 注册 setTimeout 到 Web API
    • 注册 Promise.then 到微任务队列
    • 输出 "script end"
  2. 同步任务结束,调用栈为空。

  3. 执行微任务队列:

    • 输出 "promise"
  4. 微任务队列清空,进入宏任务队列:

    • 输出 "setTimeout"

✅ 输出结果:

script start
script end
promise
setTimeout

示例2:多个 Promise 和 DOM 操作混合使用

<script>
  const target = document.createElement("div");
  document.body.appendChild(target);
  const observer = new MutationObserver(() => {
    console.log("微任务:MutationObserver");
  });
  observer.observe(target, { attributes: true, childList: true });

  target.setAttribute("data-set", "1111");
  target.appendChild(document.createElement("span"));
  target.setAttribute("style", "background-color:red;");
</script>

解析:

  • 所有对 DOM 的修改都会被记录;
  • 当前宏任务结束后,触发 MutationObserver 回调,作为微任务执行;
  • 所以输出是在所有 DOM 修改完成之后,但在下一个宏任务之前。

✅ 输出结果:

微任务:MutationObserver

示例3:Node.js 中的 process.nextTick

console.log("Start");

Promise.resolve().then(() => {
  console.log("Promise resolved");
});

process.nextTick(() => {
  console.log("Process next tick");
});

setTimeout(() => {
  console.log("hhhh");
  Promise.resolve().then(() => {
    console.log("inner Promise");
  });
}, 0);

console.log("End");

执行流程分析:

  1. 同步输出:StartEnd
  2. Node.js 中 process.nextTick() 的优先级高于 Promise.then(),所以先执行:
    • Process next tick
  3. 再执行微任务队列中的 Promise.then()Promise resolved
  4. 下一轮事件循环,执行宏任务 setTimeouthhhh
  5. 在该宏任务中又产生了一个新的微任务 → inner Promise

✅ 输出结果:

Start
End
Process next tick
Promise resolved
hhhh
inner Promise

示例4:多个 Promise 与 setTimeout 混合使用

console.log("同步Start");

const promise1 = Promise.resolve("First Promise");
const promise2 = Promise.resolve("Second Promise");
const promise3 = new Promise(resolve => resolve("Third Promise"));

setTimeout(() => {
  console.log("setTimeout");
  const promise4 = Promise.resolve("Fourth Promise");
  promise4.then(value => console.log(value));
}, 0);

setTimeout(() => {
  console.log("setTimeout2");
});

[promise1, promise2, promise3].forEach(p => p.then(value => console.log(value)));

console.log("同步End");

执行流程分析:

  1. 同步输出:同步Start同步End
  2. 三个 Promise 已经解决(resolved),其 .then 回调加入微任务队列:
    • First Promise
    • Second Promise
    • Third Promise
  3. 微任务队列依次执行:
    • 输出以上三个 Promise 的值
  4. 下一轮事件循环开始,执行第一个 setTimeout
    • 输出 setTimeout
    • 里面的 promise4.then(...) 加入微任务队列 → 输出 Fourth Promise
  5. 第二个 setTimeout 执行 → 输出 setTimeout2

✅ 输出结果:

同步Start
同步End
First Promise
Second Promise
Third Promise
setTimeout
Fourth Promise
setTimeout2

示例5:queueMicrotask 的使用

<script>
  console.log("同步");
  queueMicrotask(() => {
    console.log("微任务: queueMicrotask");
  });
  console.log("同步结束");
</script>

解析:

  • queueMicrotask() 用于将一个函数添加到微任务队列,执行时机是在当前宏任务结束后,下一个宏任务开始前。
  • Promise.then() 类似,但更直观地表达了“我是一个微任务”的意图。

✅ 输出结果:

同步
同步结束
微任务: queueMicrotask

🖼️ 六、事件循环流程图

[ Call Stack ][ Macro Task Start ][ Execute Sync Code ][ Micro Tasks Execution ][ Optional Render Update ] ← 浏览器决定是否更新 UI
       ↓
[ Next Macro Task ][ Repeat ]

📌 渲染时机说明

  • 渲染通常发生在每个宏任务结束后;
  • 如果此时还有未执行的微任务,浏览器会推迟渲染,直到所有微任务执行完毕;
  • 因此,微任务之间不会出现视图更新,确保逻辑一致性。

❓七、常见问题解析

Q1:为什么 setTimeout(fn, 0) 不等于立即执行?

A:因为 setTimeout 是宏任务,必须等到当前宏任务(即当前 script 脚本)和所有微任务执行完才会被执行。

Q2:Promise.then()process.nextTick() 哪个先执行?

A:在 Node.js 中,process.nextTick() 优先于 Promise.then() 执行。

Q3:什么是“微任务爆炸”?

A:当大量微任务连续被创建(例如递归调用 Promise.then()),可能导致主线程长时间无法执行宏任务,造成页面“冻结”。


🎯 八、结语:掌握事件循环的重要性

事件循环是 JavaScript 异步编程的基石,理解其原理可以帮助我们:

  • 编写高性能、响应迅速的应用;
  • 避免因异步逻辑混乱导致的 bug;
  • 更好地优化用户体验(如提前更新 DOM 或延迟加载数据);
  • 应对前端开发中的高级场景(如动画、懒加载、服务端渲染 SSR)。

希望本文能帮助你系统地掌握 JavaScript 的事件循环机制,并在实际项目中灵活运用!如果你觉得这篇文章对你有帮助,欢迎收藏、点赞、转发!