Skip to content
Go back

JS中的事件循环(Event Loop)

Published:  at  07:00 AM

一、前言

浏览器有两个引擎:一个是渲染引擎用来渲染 HTML 和 CSS,一个是 JS 解释器。那么 JS 到底是如何执行的呢?本次分享会我想带大家了解 JS 代码的执行机制,也就是事件循环,以及加深关于调用栈、任务队列等概念的理解。

二、问题的引入

提问以下代码的执行顺序:

<script>
  const aaa = 'aaa'
  const bbb = 'bbb'
  const ccc = 'ccc'
  const ddd = 'ddd'
  const eee = 'eee' 
  console.log(aaa)
  setTimeout(function(){
    console.log(bbb)
  }, 1000)
  console.log(ccc)
  document.addEventListener('click', function(){
    console.log(ddd)
  })
  setTimeout(function(){
    console.log(eee)
  }, 0)
</script>

三、知识准备

3.1 同步任务和异步任务

3.1.1 同步(“只做一件事,一直往下进行”)

let a = 1;
console.log("同步任务1:", a); // 立即执行,输出 1
let b = a + 2;
console.log("同步任务2:", b); // 立即执行,输出 3

同步任务就像在银行排队办业务:只有前面的人完全办完(任务结束),后面的人才能开始办,中间不能跳过。

3.1.2 异步(“先放一放,等会再说”)

console.log("同步任务:ccc"); 

setTimeout(function() {
  console.log("异步任务1:bbb(1秒后执行)");
}, 1000);

document.addEventListener('click', function() {
  console.log("异步任务2:ddd(用户点击时才执行)");
});

// 验证同步阻塞
console.log(aaa);

3.1.3 同步和异步对比

对比维度同步(Synchronous)异步(Asynchronous)
执行顺序严格按照代码书写顺序,前一个任务完成后,才执行后一个任务不阻塞后续任务,前一个任务未完成时,直接执行后面的任务
阻塞性会阻塞(卡住)后续代码执行不会阻塞后续代码执行
适用场景简单、耗时短的操作(如变量赋值、简单计算)耗时较长的操作(如网络请求、定时器、事件监听)
执行结果立即获取结果结果需要 “等待” 或 “触发” 后才能获取

3.2 栈与队列

借助栈和队列这两种常见的数据结构概念帮助我们去了解事件循环的机制:

3.3 Promise

  1. Promise 依赖事件循环实现异步

    • Promise 的异步操作(如 fetchsetTimeout 回调)不会阻塞主线程,而是被放入事件循环的任务队列中
    • 当 Promise 状态从 pending 变为 resolved/rejected 时,其 .then()/.catch() 回调会被添加到微任务队列
    • 执行优先级:微任务队列会在当前宏任务执行完成后立即清空,再执行下一个宏任务
  2. 链式调用与事件循环

    • Promise 链式调用中的每个 .then() 都会创建新的微任务
    • 即使前一个回调同步完成,下一个回调仍会放入微任务队列
    • 这保证了异步操作的有序执行,避免回调嵌套地狱

四、事件循环

4.1 什么是事件循环

事件循环是 JavaScript 实现异步编程的核心机制,它让 JavaScript 这种单线程语言能够处理异步任务,避免阻塞,保证程序的流畅运行。

4.2 JavaScript 的单线程特性

单线程是 JS 语言的一大特点,也就是说程序在执行过程中,同一时间只能做一件事。在浏览器环境中,这一个线程既要负责执行 JavaScript 代码,又要处理 DOM 渲染、事件响应、网络请求等操作。

如果没有事件循环机制,一旦遇到耗时较长的任务(如网络请求、定时器计时),整个程序就会被阻塞,导致页面卡顿无响应。

思考:既然 JS 是单线程的,那么为什么浏览器可以同时执行异步任务呢?

回答:浏览器是多线程的,当单线程的 JS 需要执行异步任务的时候,浏览器会启动另外一个线程去执行这个任务。

4.3 执行栈与任务队列

4.3.1 事件循环执行步骤

在解析一段代码时,会将同步代码按顺序排在某个地方,即执行栈,然后依次执行里面的函数。当遇到异步任务时就交给其他线程处理,待当前执行栈所有同步代码执行完成后,会从一个队列中去取出已完成的异步任务的回调加入执行栈继续执行,遇到异步任务时又交给其他线程,如此循环往复。而其他异步任务完成后,将回调放入任务队列中待执行栈来取出执行。

4.3.2 宏任务和微任务(先微后宏)

通俗易懂版:宏任务我们比作普通的学生,微任务我们比作领导家的小孩,当他们一起排队打饭时,这时候领导家的孩子就可以插队,先去打饭。

任务队列不只一个,根据任务的种类不同,可以分为微任务(micro task)队列宏任务(macro task)队列

事件循环的过程中,执行栈在同步代码执行完成后,优先检查微任务队列是否有任务需要执行,如果没有,再去宏任务队列检查是否有任务执行,如此往复。微任务一般在当前循环就会优先执行,而宏任务会等到下一次循环。因此,微任务一般比宏任务先执行,并且微任务队列只有一个,宏任务队列可能有多个。另外我们常见的点击和键盘等事件也属于宏任务。

常见的微任务:

微任务类型说明
Promise.then/catch/finallyPromise 状态改变后触发的回调(new Promise(...) 内部代码是同步的,仅回调是微任务)

常见的宏任务:

宏任务类型说明
script 标签(整体代码)整个 <script> 内的代码是「第一个宏任务」,事件循环从执行它开始
setTimeout延迟指定时间后执行回调(最小延迟约 4ms,受事件循环阻塞影响)
setInterval每隔指定时间重复执行回调(同样受事件循环阻塞影响,可能不精准)

代码演示:

console.log('同步代码1');

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

new Promise((resolve) => {
  console.log('同步代码2')
  resolve()
}).then(() => {
  console.log('promise.then')
})

console.log('同步代码3');

// 最终输出:"同步代码1"、"同步代码2"、"同步代码3"、"promise.then"、"setTimeout"

动图演示:

为什么 Promise.then 总是比 setTimeout 先执行?

因为微任务优先级更高,他可是”关系户”,必须 “插队” 先做完~

五、实践

知识点都和大家介绍完了,下面我们来通过两个小例子来实战一下吧:

示例 1:

console.log('1');

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

new Promise((resolve) => {
  console.log('3');
  resolve();
}).then(() => {
  console.log('4');
});

console.log('5');

提问:输出顺序是什么?

示例 2:

setTimeout(() => {
  console.log('1');
  new Promise((resolve) => {
    resolve();
  }).then(() => {
    console.log('2');
  });
}, 0);

new Promise((resolve) => {
  console.log('3');
  resolve();
}).then(() => {
  console.log('4');
  setTimeout(() => {
    console.log('5');
  }, 0);
});

console.log('6');

提问:输出顺序是什么?

点击查看答案

示例 1 答案:1 → 3 → 5 → 4 → 2

示例 2 答案:3 → 6 → 4 → 1 → 2 → 5

六、结语

事件循环是 JavaScript 异步世界的隐形调度者,它用 “同步优先、微任务次之、宏任务殿后” 的简单规则,支撑起单线程语言的高效并发。

理解它,不仅能看透代码执行的先后顺序,更能掌握异步编程的底层逻辑。当你能清晰预判每一行异步代码的执行时机时,便真正打通了 JavaScript 的任督二脉。

愿我们在写异步逻辑时,都能想起这位默默工作的调度者,让代码更可控,让我们的思路更清晰。


Suggest Changes
Share this post on:

Previous Post
Typescript——泛型