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

前言

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

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

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

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

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

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

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

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

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

为什么需要事件循环?

JavaScript 是单线程语言

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

用户需要响应式体验

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

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

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

事件循环的流程

  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) ,并在事件循环中按顺序执行。

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

2. 微任务(Microtasks)

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

  • Promise.then()、MutationObserver、queueMicrotask()、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,至此队列全部为空,调用栈为空,任务结束。

为什么Timer2在Timer1之前?

我们都知道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");
});

啊哈哈哈解析来咯~

解析来咯~

相信大家对于Q1和Q2没有什么疑问,接下来我们来补充一下Q3和Q4的知识点吧!

async/await与Event 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()在微队列优先级最高。

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

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

略略略略略~

相关推荐
panxianren4 小时前
Bun 1.4 的 bun test --parallel 将 4 秒的测试套件缩短到约 1 秒
java·javascript
丑陋小蚊子6 小时前
邮件投简历,正文和附件各写什么?
面试·求职招聘·大学生·简历·简历下载
陈随易6 小时前
bm2,Node.js 与 Bun 项目部署新选择
前端·后端·程序员
架构技术专栏7 小时前
学习率:一次参数更新,到底应该改多少
面试
架构技术专栏7 小时前
AI Agent 框架怎么选:从一次制度查询拆出技术边界
后端·面试
可乐鸡翅yeah_7 小时前
FFmpeg 生成 HLS 独立分片 independent‑segments 参数到底有什么用
javascript·ffmpeg·音视频·safari·m3u8
福兮说7 小时前
canvas 旋转图片的六个坑:四角被裁、Math.ceil 多出 1px 黑边、JPG 角发黑、翻转方向反了
前端·javascript·图像处理·canvas
Ai-_Man9 小时前
您您这可以把Grok的多个会话比如说。左侧的多个会话一次性导出吗?不是单条会话里面的多次会对话。AI导出鸭
javascript·人工智能·ai·小程序·电脑
传人once9 小时前
多图片上传预览功能如何写
前端·css·html·jquery·html5
Eric.469 小时前
Wan‑Animate+MiniMax-H3 本地部署 AI 漫剧流水线:8G 显存动作迁移与动态分镜工程实战
前端·人工智能·comfyui·ai漫剧