🤔

彻底搞懂 JavaScript 事件循环:从银行排队说起

在JS的世界里,事件循环(Event Loop) 就像是一个神秘的幕后调度员。无论你是想写出更健壮的代码,还是需要优化页面性能,理解它都是必经之路。

今天,我们不背枯燥的定义,而是通过生活中的例子,一步步拆解它的奥秘。


1. 为什么需要事件循环?

想象一下,浏览器是一个繁忙的办事大厅。它有两个核心部门:

  1. JS 执行引擎:负责算账、逻辑处理(比如 1 + 1)。
  2. GUI 渲染线程:负责画图、装修页面。

尴尬的是: 这两个部门是“共用一个办公室”的,而且同一时间只能有一个人在工作。这就是我们常说的 JavaScript 是单线程的。

如果 JS 引擎在执行一个耗时 10 秒的死循环,渲染线程就得在门口等着,结果就是用户发现页面“卡死了”,点什么都没反应。

为了让 JS 既能处理复杂逻辑,又不阻塞页面渲染,事件循环应运而生。


2. 类比:银行办业务

为了理解事件循环,我们把 JS 引擎想象成一位银行柜员

场景:

  1. 宏任务(Macrotask):由宿主环境(浏览器或 Node.js)发起的任务。它代表一段离散的、独立的工作单元。就像大厅里排队拿号的客户。每个客户代表一个独立的大任务(比如:存钱、取钱)。
  2. 微任务(Microtask):由 JavaScript 引擎本身发起的任务。它通常用于处理需要在当前脚本执行完后立即执行的小型任务。想象下客户站在柜台前,办完主业务后,突然对柜员说:“顺便帮我改个密码”或者“顺便查个余额”。

规则:

  • 柜员每次只能接待一位排队的客户(执行一个宏任务)。
  • 关键点:在叫下一个号之前,柜员必须把当前客户所有的“顺便要求”(微任务)全部处理完。
  • 只有当柜员空闲了(当前任务和所有顺便任务都完了),大厅的保洁阿姨(渲染线程)才有机会进来打扫一下卫生(UI 渲染)。

3. 深入解析:执行机制与优先级

事件循环流程图

任务分类表

类型常见 API记忆点
宏任务 (Macrotask)setTimeout, setInterval, I/O, 事件点击像“排队拿号”的大客户
微任务 (Microtask)Promise.then, MutationObserver像“顺便办一下”的小需求

流程图解

graph TD A[开始] --> B{调用栈是否为空?} B -- 否 --> C[执行同步代码] C --> B B -- 是 --> D{微任务队列是否为空?} D -- 否 --> E[取出并执行一个微任务] E --> D D -- 是 --> F[尝试 UI 渲染] F --> G[从宏任务队列取出一个任务] G --> B

4. 代码实战:你会预测顺序吗?

看看下面这段代码,试着按照“银行办业务”的逻辑推导一下:

事件循环示例代码

执行结果:

  1. 1. 柜员上班
  2. 5. 柜员忙完了
  3. 3. 顺便改个密码(微任务优先于下一个宏任务)
  4. 4. 顺便查个余额(微任务会一次性清空)
  5. 2. 下一位客户(最后才轮到下一个宏任务)

5. 实际开发中的运用

理解了这些,你在写代码时就能像上帝视角一样避开陷阱:

A. 解决长任务卡顿

如果你要处理 100 万条数据,直接写循环会导致页面卡死。

解决方案:利用 setTimeout 把大任务拆分成很多个小宏任务,给浏览器留出时间去渲染 UI。

事件循环示例代码片段

B. Vue 的 nextTick 原理

当你修改了 Vue 里的数据,DOM 并不会立即更新。

Vue 会把更新 DOM 的操作放进一个微任务队列里。 如果你想在数据改变后立即获取新的 DOM 高度,你就需要用到 nextTick,它的本质就是把你自己的逻辑也塞进那个微任务队列的末尾。

C. 避免微任务死循环

如果你在 Promise.then 里又递归调用了自己,微任务队列就永远清不空。

后果:浏览器会完全失去响应,因为事件循环被困在了微任务阶段,永远无法到达“UI 渲染”和“处理点击事件”的步骤。

D. 错误的定时器预期

事件循环示例代码片段

后果:你会发现输出远大于 100ms(通常是 500ms+)。

原因:定时器到期后只是将回调放入宏任务队列,它必须等待当前的同步代码和微任务队列全部执行完。

总结

JavaScript 的事件循环是一个平衡艺术

  • 同步代码:立即执行。
  • 微任务:紧随其后,一次性清空。
  • 宏任务:排队等待,一次只做一个。

掌握了它,你就掌握了 JavaScript 异步编程的灵魂。下次当你遇到复杂的异步 Bug 时,不妨想一想:“现在柜员正在处理谁的业务?大厅里还有谁在排队?”