一文搞懂JavaScript事件循环 (Event Loop)

你是否曾写过这样的代码,并对它的输出结果感到困惑?

javascript 复制代码
console.log('脚本开始');

setTimeout(() => {
  console.log('setTimeout 回调');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise 回调');
});

console.log('脚本结束');

许多开发者会下意识地认为,setTimeout 的延迟是 0 毫秒,所以它会紧接着"脚本开始"之后立即执行。

然而,最终的控制台输出却是:

javascript 复制代码
脚本开始
脚本结束
Promise 回调
setTimeout 回调

为什么会这样?为什么 Promise 的回调插在了 setTimeout 前面?

这个看似简单的现象背后,隐藏着 JavaScript 异步编程的核心------事件循环(Event Loop)


为什么我们需要事件循环?

JavaScript 是一门 单线程 语言,这意味着在任何给定时刻,它只能执行一件任务。

优点是避免了多线程竞争、锁等问题;缺点是,一旦执行耗时任务,就会阻塞所有其他操作,造成页面卡顿。

为了既保持单线程的简单性,又能处理耗时任务,JavaScript 依赖宿主环境(浏览器 / Node.js)来协作,通过事件循环调度任务的执行。


事件循环的关键组成部分

1. 调用栈 (Call Stack)

后进先出(LIFO)的数据结构,存放所有正在执行的函数调用。

2. Web APIs / Node.js APIs

宿主环境提供的能力,例如:

  • setTimeout / setInterval
  • DOM 事件监听(addEventListener
  • AJAX / Fetch 网络请求
  • Node.js 的文件 I/O 等

3. 宏任务队列 (Macrotask Queue)

先进先出的队列,存放宏任务:

  • setTimeout
  • setInterval
  • DOM 事件回调(clickscroll 等)
  • message channel

4. 微任务队列 (Microtask Queue)

先进先出的队列,存放微任务:

  • Promise.then / catch
  • MutationObserver
  • Node.js 的 process.nextTick

微任务优先级高于宏任务,一次事件循环会先清空所有微任务,再取一个宏任务执行。


DOM 事件与事件循环的关系

DOM 事件监听器是事件循环最常见的来源之一。来看一个简单例子:

html 复制代码
<button id="myBtn">点我</button>
javascript 复制代码
const myBtn = document.getElementById('myBtn');

console.log('同步代码:开始监听');

myBtn.addEventListener('click', () => {
  console.log('按钮被点击了!这是一个宏任务');
});

console.log('同步代码:监听设置完毕');

执行过程:

  1. addEventListener 同步执行,注册回调给浏览器 Web API。
  2. 浏览器后台监听点击事件,不阻塞 JavaScript 主线程。
  3. 用户点击按钮 → 浏览器将回调函数作为一个宏任务加入宏任务队列。
  4. 事件循环检测到队列有此任务,在清空微任务后执行它。

所以:DOM 事件的回调属于宏任务 ,与 setTimeout 同类。


事件循环的运转流程(图解)

graph TD A[执行调用栈中的同步任务] --> B[清空所有微任务队列] B --> C[取一个宏任务进入调用栈执行] C --> A

简化流程如下:

  1. 执行 同步任务(调用栈)。
  2. 清空 微任务队列(一次性全部)。
  3. 执行 一个 宏任务。
  4. 重复以上过程。

实战分析:开头示例的执行顺序

javascript 复制代码
console.log('脚本开始');

setTimeout(() => {
  console.log('setTimeout 回调');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise 回调');
});

console.log('脚本结束');

执行过程:

  1. 输出:脚本开始
  2. 注册 setTimeout 回调(宏任务)
  3. 注册 Promise.then 回调(微任务)
  4. 输出:脚本结束
  5. 清空微任务队列 → 输出:Promise 回调
  6. 执行宏任务队列 → 输出:setTimeout 回调

更多任务类型分类表

来源 类型 优先级
setTimeout / setInterval 宏任务
DOM 事件回调 宏任务
Promise.then/catch 微任务
MutationObserver 微任务
Node.js process.nextTick 微任务 最高

复杂混合场景示例

javascript 复制代码
document.body.addEventListener('click', () => {
  console.log('DOM click 宏任务');
});

setTimeout(() => {
  console.log('setTimeout 宏任务');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise 微任务');
});

(async function(){
  await Promise.resolve();
  console.log('async/await 之后的微任务');
})();

点击一次页面时可能的输出顺序:

javascript 复制代码
Promise 微任务
async/await 之后的微任务
setTimeout 宏任务
DOM click 宏任务

总结与性能优化提示

  • 宏任务 之间会执行所有微任务
  • 微任务可用于一些快速、紧急的异步逻辑(如数据校验、批量操作合并)。
  • 在需要延迟执行且不影响当前流程时,可用宏任务(如轻量的 UI 更新或延迟提示)。
  • 对性能优化的启示:
    • 合并多个 DOM 更新到一次宏任务中,减少回流/重绘。
    • 合理利用微任务处理短链异步,保持操作连贯性。

现在你已经掌握了 JavaScript 事件循环的基础与常见场景,不妨改写例子,加入更多事件和异步 API,亲自验证执行顺序。

你还有哪些和事件循环相关的经验?欢迎在评论区分享!

相关推荐
Hi_kenyon8 小时前
VUE3套用组件库快速开发(以Element Plus为例)二
开发语言·前端·javascript·vue.js
起名时在学Aiifox8 小时前
Vue 3 响应式缓存策略:从页面状态追踪到智能数据管理
前端·vue.js·缓存
李剑一9 小时前
uni-app实现本地MQTT连接
前端·trae
EndingCoder9 小时前
Any、Unknown 和 Void:特殊类型的用法
前端·javascript·typescript
oden9 小时前
代码高亮、数学公式、流程图... Astro 博客进阶全指南
前端
GIS之路9 小时前
GDAL 实现空间分析
前端
JosieBook10 小时前
【Vue】09 Vue技术——JavaScript 数据代理的实现与应用
前端·javascript·vue.js
pusheng202510 小时前
算力时代的隐形防线:数据中心氢气安全挑战与技术突破
前端·安全
起名时在学Aiifox10 小时前
前端文件下载功能深度解析:从基础实现到企业级方案
前端·vue.js·typescript