用 “餐厅模型” 吃透事件循环:同步、宏任务、微任务谁先执行?

深入浅出JavaScript事件循环:宏任务、微任务与你的面试官

✨ 为什么JavaScript是"单线程非阻塞"的?

单线程语言:

因为在浏览器中,需要对各种的DOM操作;

当JS是多线程的话,如果有两个线程同时对同一个DOM进行操作,一个是在这个DOM上绑定事件,另外一个是删除该DOM,此时就会产生歧义;

因此为了保证这种事情不会发生,所以JS以单线程来执行代码,保证了一致性

JS非阻塞:

当JS代码从上往下执行,遇到需要进行一项异步任务的时候, 主线程会挂起这个任务,继续往下执行代码,然后在异步任务返回结果的时候再根据一定规则去执行

你有没有想过,为什么JavaScript在浏览器里能"一手遮天"?答案就是:它是一门单线程的语言。这就像一个餐厅,只有一个厨师(主线程)在工作。这个厨师很厉害,他能处理所有的点餐、炒菜、上菜等任务。如果同时来了两个客人,一个说"我要把桌子擦干净",另一个说"我要把桌子搬走",如果厨师是多线程的,他可能就会陷入"擦一半搬一半"的尴尬境地,甚至把桌子擦没了,客人还怎么吃饭?为了避免这种"左右互搏"的混乱局面,JavaScript从诞生之初就被设计成单线程,保证了操作的一致性和顺序性。

但是,单线程就意味着效率低下吗?比如,厨师在炒一道需要炖很久的菜(比如红烧肉),难道他就要一直盯着锅,什么都不干吗?当然不是!这就是JavaScript"非阻塞"的精髓所在。当遇到像炖红烧肉(异步任务,比如网络请求、定时器)这种耗时任务时,聪明的厨师会把它交给"后厨小弟"(浏览器或Node.js的API)去处理,自己则继续去炒其他的菜(执行后续的同步代码)。等红烧肉炖好了,"后厨小弟"会通知厨师,然后厨师再根据一定的规则(事件循环)来处理这道菜的后续工作。

所以,JavaScript的"单线程非阻塞"特性,就像一个高效的餐厅:一个主厨负责统筹安排,遇到耗时任务就交给帮手,自己绝不干等,从而保证了餐厅的持续高效运转。那么,这个"一定规则"到底是什么呢?它就是我们今天要深入探讨的------事件循环(Event Loop)

🔄 事件循环机制大揭秘

想象一下,你来到一家热门餐厅,门口排着长队。这家餐厅的运营模式,就和JavaScript的事件循环机制有异曲同工之妙。

首先,餐厅有一个主厨(JS引擎) ,他手头有一个点餐板(执行栈)。所有客人点的主菜(同步任务)都会直接写在点餐板上,主厨会一道一道地立即制作。这些菜品都是立等可取的,比如"来份拍黄瓜",主厨立马就给你拍好了。

但是,有些菜品需要时间制作,比如"来份红烧肉",或者"来杯现榨果汁"。这些不能立等可取的任务,主厨会把它们交给后厨小弟(Web APIs/Node.js APIs) 去处理,并把这些任务记录在任务表(Task Table) 上。后厨小弟处理完后,并不会直接把菜端给主厨,而是把做好的菜放到一个传菜口(任务队列)

这个传菜口可不是只有一个,它分成了两个区域:

  • 宏任务队列(MacroTask Queue) :这里放的是那些"大菜",比如红烧肉(setTimeout, setInterval)、烤鸭(I/O操作)、或者一桌子的宴席(UI渲染)。这些任务通常耗时较长,而且数量可能比较多。
  • 微任务队列(MicroTask Queue) :这里放的是那些"小吃",比如餐前小点心(Promise.then().catch().finally())、或者一些需要立即处理的"加急件"(MutationObserver)。这些任务通常执行速度快,优先级相对较高。

那么,主厨是如何从传菜口取菜的呢?这就是循环(Loop) 的关键所在了!

  1. 主厨优先处理点餐板上的所有主菜(执行栈中的所有同步任务)。他会一道接一道地做,直到点餐板空了为止。
  2. 点餐板空了之后,主厨会先去微任务队列看看有没有"小吃"。如果微任务队列里有,他会把所有的小吃都取出来,一口气全部处理完。因为这些都是"加急件",必须优先处理。
  3. 微任务队列清空后,主厨才会去宏任务队列取一道"大菜"来处理 。注意,每次循环只会从宏任务队列中取一个任务来执行。
  4. 处理完这道"大菜"后,主厨会再次回到第2步,检查微任务队列。因为在处理"大菜"的过程中,可能会产生新的"小吃"。
  5. 如此循环往复,直到两个队列都清空,主厨才能休息。

这个过程,就是JavaScript事件循环的完整机制。它保证了JavaScript在单线程环境下,既能高效处理同步任务,又能异步处理耗时任务,并且对不同类型的异步任务进行了优先级管理。

JS引擎

  • 执行栈(Call Stack):所有同步任务都在主线程上执行,形成一个执行栈。当函数被调用时,会被推入栈中;当函数执行完毕,会被弹出栈。
  • 任务队列(Task Queue) :用于存放异步任务的回调函数。分为宏任务队列和微任务队列。
    • 宏任务(MacroTask)setTimeout, setInterval, I/O, UI Rendering等。
    • 微任务(MicroTask)Promise.then().catch().finally(), MutationObserver, process.nextTick (Node.js环境)等。

理解了这个"餐厅模型",你是不是对事件循环有了更直观的认识呢?接下来,我们通过一个实际的代码例子,来看看这个机制是如何运作的。

🔧 代码实战:宏任务与微任务的舞蹈

理论知识讲了一大堆,是不是有点晕?别急,我们来点实际的!下面这段代码,就是面试官最喜欢拿来"考考你"的经典案例。我们用它来模拟一下宏任务和微任务在事件循环中的"舞蹈"过程。

javascript 复制代码
// 同步任务
console.log("首次同步任务开始");

// 异步任务 (宏任务)
setTimeout(() => {
  console.log("setTimeout 1");
  new Promise((resolve) => {
    console.log("promise1");
    resolve();
  }).then(() => {
    console.log("Promise then 1");
  });
}, 1000);

// 同步任务
console.log("首次同步任务结束");

// 异步任务 (微任务)
new Promise((resolve) => {
  console.log("promise2");
  resolve();
}).then(() => {
  console.log("Promise then 2");
});

这段代码的输出顺序是什么呢?别急着说答案,我们一步步来"拆解"它。

第一步:执行同步任务

就像餐厅的主厨,他会先把点餐板上的"拍黄瓜"们(同步任务)全部处理掉。所以,最先输出的是:

复制代码
首次同步任务开始
首次同步任务结束

在执行这两个console.log的同时,setTimeoutPromise这两个异步任务也被"登记"了。setTimeout被扔进了宏任务队列,而Promisethen方法(注意,是then方法里的回调,而不是Promise本身)被扔进了微任务队列。

第二步:处理微任务队列

同步任务执行完毕,执行栈空了。这时候,主厨会先去微任务队列看看有没有"小吃"。嘿,果然有!promise2Promise then 2正在那里等着呢。所以,它们紧接着被执行:

javascript 复制代码
promise2
Promise then 2

第三步:处理宏任务队列

微任务队列清空了,主厨终于可以去宏任务队列里取"大菜"了。此时,setTimeout的回调函数被取出并执行。在这个回调函数内部,又有一个新的Promise。这个Promiseconsole.log("promise1")是同步任务,会立即执行。而它的then方法又会生成一个微任务,被添加到微任务队列中。

arduino 复制代码
setTimeout 1
promise1

第四步:再次处理微任务队列

setTimeout的回调函数执行完毕,执行栈再次空了。事件循环会再次检查微任务队列。此时,之前在setTimeout回调中产生的微任务Promise then 1正在队列中等待。所以,它会被立即执行:

javascript 复制代码
Promise then 1

最终输出顺序:

javascript 复制代码
首次同步任务开始
首次同步任务结束
promise2
Promise then 2
setTimeout 1
promise1
Promise then 1

是不是感觉像看了一场精彩的舞蹈?同步任务是开场舞,微任务是插曲,宏任务是主旋律,而事件循环就是那个掌控全场的指挥家!

划重点:

  • 异步任务分类: 宏任务 (setTimeout, setInterval, I/O, UI Rendering),微任务 (Promise.then().catch().finally(), MutationObserver, process.nextTick)
  • 所有同步任务都在主线程上执行,形成一个执行栈。
  • 遇到异步任务,它们会被丢到任务表中,等事件执行完成(比如ajax请求完成、setTimeout设置时间到期),之后放入相应的任务队列。
  • 当执行栈的同步任务执行完成之后,事件循环会先清空微任务队列,然后从宏任务队列中取出一个任务执行。每次宏任务执行完毕,都会再次检查并清空微任务队列,然后才进行下一次循环。

⚠️ 总结与思考

好了,各位未来的前端大神们,今天我们一起深入探讨了JavaScript事件循环的奥秘。你现在应该明白了:

  1. JavaScript是单线程的,但它通过事件循环实现了非阻塞。 就像餐厅的主厨,一个人也能把所有事情安排得井井有条。
  2. 事件循环是JavaScript实现异步的关键机制。 它协调着同步任务、宏任务和微任务的执行顺序。
  3. 微任务拥有更高的优先级。 在每个事件循环周期中,微任务队列会在宏任务队列之前被完全清空。

在面试中,当面试官问到事件循环时,你不仅要能说出宏任务和微任务的分类,更要能结合代码示例,清晰地解释它们的执行顺序和背后的原理。如果你还能用一些生动形象的比喻(比如我们今天的"餐厅模型"),那绝对能让面试官眼前一亮,觉得你不仅懂技术,还能把复杂的问题讲明白!

希望这篇博客能帮助你更好地理解JavaScript事件循环,并在面试中脱颖而出!如果你有任何疑问或者想分享你的理解,欢迎在评论区留言,我们一起交流学习!

相关推荐
奕辰杰3 小时前
关于npm前端项目编译时栈溢出 Maximum call stack size exceeded的处理方案
前端·npm·node.js
JiaLin_Denny4 小时前
如何在NPM上发布自己的React组件(包)
前端·react.js·npm·npm包·npm发布组件·npm发布包
_Kayo_5 小时前
VUE2 学习笔记14 nextTick、过渡与动画
javascript·笔记·学习
路光.6 小时前
触发事件,按钮loading状态,封装hooks
前端·typescript·vue3hooks
我爱996!6 小时前
SpringMVC——响应
java·服务器·前端
咔咔一顿操作6 小时前
Vue 3 入门教程7 - 状态管理工具 Pinia
前端·javascript·vue.js·vue3
kk爱闹7 小时前
用el-table实现的可编辑的动态表格组件
前端·vue.js
漂流瓶jz7 小时前
JavaScript语法树简介:AST/CST/词法/语法分析/ESTree/生成工具
前端·javascript·编译原理
换日线°7 小时前
css 不错的按钮动画
前端·css·微信小程序