🔥从菜鸟到高手:彻底搞懂 JavaScript 事件循环只需这一篇(下)

前言

在上一篇文章中,我们从操作系统的进程与线程 讲起,深入浏览器的多进程架构 ,最终理解了为什么 JavaScript 被设计为一门单线程语言

但问题也随之而来:既然是单线程,JavaScript 是如何做到"异步非阻塞"的?

为什么我们能写出 setTimeoutPromisefetch 等异步代码?为什么这些操作不会卡住主线程?又是谁在背后默默替我们"安排时间"?

要回答这些问题,我们必须深入理解 JavaScript 的核心执行机制------事件循环(Event Loop)

事件循环并不是 JavaScript 独立完成的机制,它是 JS 引擎(如 V8)与宿主环境(如浏览器)紧密配合的结果。它背后涉及到:

  • 任务队列(宏任务、微任务)
  • 事件触发(用户交互、定时器、网络请求)
  • 回调调度(从异步到同步的桥梁)

在这一篇中,我们将从 JavaScript 主线程的执行流程出发,结合浏览器的异步线程能力,逐步揭开事件循环的神秘面纱。你将理解:

  • 为什么 Promise.thensetTimeout 执行得更快?
  • 为什么 setTimeout(fn, 0) 不等于"立刻执行"?
  • JavaScript 单线程如何通过"事件循环"实现"看起来的异步"。

现在让我们一起,从头读懂事件循环!

为什么需要事件循环?

JavaScript 是单线程语言

  • JS 最初设计时是单线程,只有一个主线程处理代码逻辑。
  • 这意味着: "一次只能做一件事" ,如果遇到阻塞操作(比如死循环、长时间任务),整个页面就会"卡住"。

用户需要响应式体验

  • Web 页面需要实时响应用户交互(点击、滚动、输入等)。
  • 同时还需要处理异步操作(网络请求、定时器、动画等)。
  • 如果没有事件循环,JS 就只能顺序执行异步几乎不可能实现

什么是事件循环(Event Loop)?

Event Loop(事件循环)是 JavaScript 单线程异步编程 的核心机制,它决定了 JS 如何执行 同步任务异步任务 (如 setTimeoutPromisefetch 等)。

事件循环的流程

  1. 同步代码进入 调用栈,依次执行。

  2. 遇到异步代码(如 setTimeout)则交给 Web API 处理,继续执行后续代码(不会阻塞)。

  3. Web API 完成后,回调函数进入任务队列

  4. 当调用栈空时(同步代码执行完),Event Loop 检查任务队列:

  • 如果队列里有任务(如 setTimeout 的回调),就取出第一个,推入 调用栈 执行。
  • 重复上述过程。
graph TD A[调用栈] --> |执行同步代码| B{调用栈空?} B -->|否| A B -->|是| C[事件循环检查任务队列] C --> |队列有任务| D[取出第一个任务] D --> E[推入调用栈执行] E --> A

举个例子

现在你就能够明白为什么setTimeout()为什么总在同步代码执行完之后才会执行了:

js 复制代码
console.log('start');
setTimeout(()=>{console.log('Timer')},0); // 0 ms后输出
console.log('end');

最终输出为: start ,end,Timer.

因为计时器属于异步任务,对于事件循环来说,要先把所有的同步任务执行完毕 之后,才会检查队列,再取出异步任务执行。

既然这样你能不能猜一猜以下代码输出顺序是怎么样的?

js 复制代码
console.log('Script start'); 

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

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

console.log('Script end');

输出结果其实为:

text 复制代码
Script start
Script end
Promise
setTimeout

嘻嘻嘻,想知道为什么吗?这就关系到了一个非常重要的概念:任务队列优先级

任务队列优先级

没错,任务队列也是有特权的,有的队列生来富贵,享受着特权,有的队列就没有那样的特权咯,自然它们的所包含的任务的紧急程度是不一样的,越紧急的任务执行的越先被执行

任务类型&队列类型

JS 里的任务分为 两种

1. 宏任务(Macrotasks)

宏任务是 JavaScript 引擎和浏览器之间协作处理的基本任务单位 ,每个宏任务都会进入宏任务队列(MacroTask Queue) ,并在事件循环中按顺序执行

  • setTimeoutsetIntervalDOM 事件script(整体代码)requestAnimationFrame(仅浏览器)。
  • 每次 Event Loop 只执行一个宏任务

2. 微任务(Microtasks)

微任务是比宏任务优先级更高 的一种任务。它们会被加入微任务队列(MicroTask Queue) ,在当前宏任务执行结束后立即执行

  • Promise.then()MutationObserverqueueMicrotask()process.nextTick()
  • 每次调用栈空时,会先清空所有微任务,再执行宏任务。

接下来我们来上一个栗子,深入了解一下他们的执行顺序吧!

举个栗子来了解事件循环的执行顺序

只要你做出来了这个例子,那么Event Loop就正式被你秒杀了!

js 复制代码
console.log("start");
Promise.resolve().then(() => {
    console.log('Promise1'); 
});
setTimeout(()=>{
console.log("Timer1~")
},100);
setTimeout(()=>{
console.log("Timer2~")
},0);
Promise.resolve().then(() => {
    console.log('Promise2'); 
});
console.log('end');

OK,my Baby,Tell me,How is it gonna be~

Look in my eyes! Baby! Tell me! Why !

他会输出什么呢!

答案是:

text 复制代码
start
end
Promise1
Promise2
Timer2~
Timer1~

so,为什么呢?

解析吖~

我们知道引擎是按照一个宏任务+所有微任务 来执行代码的,因为<script>整体是一个宏任务,所以我们的引擎会先执行整个<script>,

首先遇到第一句,为console.log("start");,是同步代码,直接执行!

接着遇见下一句Promise.resolve().then(() => { console.log('Promise1'); });,为异步代码,交给对应模块处理,模块处理完毕之后,加入微任务队列

接下来,到了setTimeout(()=>{ console.log("Timer1~") },100);,为异步代码,交给其他模块处理,处理完毕后,加入宏任务队列

再接着是setTimeout(()=>{ console.log("Timer2~") },0);,为异步代码,交给其他模块处理,处理完毕后,加入宏任务队列

随后,是Promise.resolve().then(() => { console.log('Promise2'); });,为异步代码,交给对应模块处理,模块处理完毕之后,加入微任务队列

最终,console.log('end');,为同步代码,直接执行!

OK,此时的宏任务,微任务队列应该是这样的了:

接下来我们知道,此时<script>这个宏任务执行完了,接下来就该清空所有微任务队列了 ,所以接下来要执行所有在队列的Promise

之后调用栈又空了,又要开始执行宏任务 ,于是进入宏任务队列 ,取出任务执行,首先取出Timer2,再检查微任务队列,看到是空的之后,再执行下一个宏任务,也就是Timer1,至此队列全部为空,调用栈为空,任务结束。

为什么Timer2Timer1之前?

我们都知道JavaScript代码是按照顺序执行的,先执行了Timer1的语句,再执行了Timer2的语句,但是它们加入到队列的时间是不一样的:

js 复制代码
setTimeout(()=>{
console.log("Timer1~")
},100);
setTimeout(()=>{
console.log("Timer2~")
},0);

Timer2用了0ms加入宏任务队列,也就是说模块处理完后立刻就加入了队列,而Timer1要经过100ms后才会加入队列,这就造成了它们进入队列的顺序不同。

我相信这个小弯弯大家都能绕过来的吧嘻嘻嘻~

面试题

相信大家对于事件循环有了一定的了解了,大家来做几道基础(超级难)的面试题吧!

Q1:setTimeout(fn, 0) 会立即执行吗?

Q2:以下代码输出什么?

js 复制代码
console.log(1);

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

Promise.resolve().then(() => console.log(3));

console.log(4);

Q3:以下代码输出什么?(难⚠⚠)

js 复制代码
async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}

async function async2() {
  console.log("async2");
}

console.log("script start");

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

async1();

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("promise2");
});

console.log("script end");

Q4:以下代码输出什么?(难⚠⚠⚠)

js 复制代码
async function test() {
  console.log("start");
  await Promise.resolve();
  console.log("end");
}

test();

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

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

啊哈哈哈解析来咯~

解析来咯~

相信大家对于Q1Q2没有什么疑问,接下来我们来补充一下Q3Q4的知识点吧!

async/awaitEvent Loop

async/await实际上就是把Promise.resolve().then()包装了一下,之所以await语句后面的代码能够在await代码执行后再执行,本质上是因为await语句以外的后面的代码全部被加入了微队列。 比如:

js 复制代码
async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}

async function async2() {
  console.log("async2");
}
async1();

在这个例子中,我将用Promise代替async/await来写一下:

js 复制代码
async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}
// 等价于
Promise.resolve(async2()).then(()=>{
console.log("async1 end");  // 这里的内容被加入到微队列中
})

注意:await虽然等同于Promise.then() ,但是await后面的语句在微队列中执行优先度比Promise.then()要高 让我们再看一眼Q3:

js 复制代码
async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}

async function async2() {
  console.log("async2");
}

console.log("script start");

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

async1();

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("promise2");
});

console.log("script end");

所以上面Q3的答案为:

text 复制代码
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

process.nextTick()

process.nextTick()Node.js 独有的异步 API ,用于将回调函数放入当前执行栈的末尾、在下一次事件循环开始前立即执行 。它的优先级比 Promise.then()setImmediate() 更高,是 Node.js 中最"急切"的微任务

也就是说在Node.js执行模式下,它的优先级是微队列中最高的。 我们再看一眼Q4:

js 复制代码
async function test() {
  console.log("start");
  await Promise.resolve();
  console.log("end");
}

test();

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

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

所以Q4答案为:

text 复制代码
start
nextTick
end
promise

总结

通过本篇文章的学习我们知道了 事件循环(Event Loop) 的运行机制,即一次宏任务->所有微任务->一次宏任务->所有微任务的循环,在此我们又学习了两种任务队列与任务类型,微任务队列优先级 >宏任务队列优先级 ,我们由此也了解了JavaScript作为单线程语言的局限性事件循环给它带来的各种无敌的能力,使它能够处理更复杂的逻辑情况,实现更多的功能。

最后我们又了解了async/await的微任务优先级大于 Promise.then()的优先级,并且在Node.js的模式下,process.nextTick()在微队列优先级最高

这一期就是这样了,如果客官您还看着舒服,就给个赞吧!如果有错误,也欢迎各位指出~

如果你看的不爽的话.......

略略略略略~

相关推荐
北'辰25 分钟前
DeepSeek智能考试系统智能体
前端·后端·架构·开源·github·deepseek
前端历劫之路43 分钟前
🔥 1.30 分!我的 JS 库 Mettle.js 杀入全球性能榜,紧追 Vue
前端·javascript·vue.js
在未来等你1 小时前
RabbitMQ面试精讲 Day 16:生产者优化策略与实践
中间件·面试·消息队列·rabbitmq
爱敲代码的小旗2 小时前
Webpack 5 高性能配置方案
前端·webpack·node.js
Murray的菜鸟笔记2 小时前
【Vue Router】路由模式、懒加载、守卫、权限、缓存
前端·vue router
苏格拉没有底了2 小时前
由频繁创建3D火焰造成的内存泄漏问题
前端
阿彬爱学习2 小时前
大模型在垂直场景的创新应用:搜索、推荐、营销与客服新玩法
前端·javascript·easyui
我是哪吒3 小时前
分布式微服务系统架构第164集:架构懂了就来了解数据库存储扩展千亿读写
后端·面试·github
UrbanJazzerati3 小时前
PowerShell 自动化实战:自动化为 Git Staged 内容添加 Issue 注释标记
后端·面试·shell
橙序员小站3 小时前
通过trae开发你的第一个Chrome扩展插件
前端·javascript·后端