通俗易懂的事件循环event loop

45 阅读6分钟

事件循环event loop (通俗易懂版)

一 堆、栈、列

1.1 栈(Stack):像叠盘子,先进后出

想象你在餐厅收拾盘子,洗干净的盘子会一个一个往上叠。拿的时候呢?只能从最上面那个开始拿,不能从中间抽,也不能从底下掏。

  • 特点:最后放进去的,最先被拿走(先进后出,LIFO)
  • 例子
    • 函数调用栈:你调用 A()A() 里又调用 B()B() 执行完才能回到 A(),最后 A() 执行完才结束(像叠盘子一样,B 压在 A 上面,先拿 B
    • 浏览器的前进后退其实也类似栈:打开新页面是 “压栈”,点后退是 “弹栈”

1.2 队列(Queue):像排队买奶茶,先进先出

奶茶店排队,第一个来的人先点单,第二个排在后面,必须等前面的人都走完,后面的才能轮到。

  • 特点:最先放进去的,最先被拿走(先进先出,FIFO)
  • 例子
    • 事件队列:浏览器里的点击事件、定时器回调,会按触发顺序排队,JS 引擎一个一个处理(先触发的事件先执行)
    • 消息队列:比如 React 的更新任务,会按优先级排队,依次执行

1.3 堆(Heap):像杂乱的储物间,按需查找

你有个储物间,东西随便堆,但每个东西上贴了标签(比如 “最贵的”“最大的”)。需要的时候不用按顺序翻,直接根据标签找(比如每次都先拿 “最贵的”)。

  • 特点:没有固定顺序,但能快速找到 “最值”(最大或最小)
  • 例子
    • 优先级队列:比如任务调度,高优先级的任务(如用户输入)要比低优先级的(如日志打印)先执行,这时候就用堆来管理
    • 内存分配:JS 里的对象数据存在堆里,变量只是引用它(就像你记得储物间里某个东西的标签,直接去拿)

1.4 一句话总结区别

  • 栈:叠盘子(后进先出)
  • 队列:排队(先进先出)
  • 堆:带标签的储物间(快速找最值)

二 事件循环(Event Loop)

2.1 先明确三个角色

  • 你(JS 引擎):相当于后厨里唯一的厨师,一次只能做一件事(单线程)。
  • 任务(Tasks):客人点的菜,分两种:
    • 同步任务:简单的菜(比如拍个黄瓜),你能立刻做完。
    • 异步任务:复杂的菜(比如炖鸡汤),你做不了,得交给别人(浏览器的其他线程,比如定时器线程、网络线程)。
  • 任务队列:像取餐台,异步任务做好后会放在这里排队,等你有空了就来拿。

2.2 事件循环的过程:就像厨师的工作流程

  1. 先处理同步任务 你面前有一堆菜,先挑同步任务(简单的),按顺序一个接一个做,做完为止。

    比如:执行 console.log('1')(同步),直接输出;遇到 setTimeout(...)(异步),就交给 “定时器师傅” 处理,自己继续做下一个同步任务。

  2. 异步任务交给 “帮手”setTimeout(等时间)、fetch(发请求)、click 事件(用户点击)这些异步任务,你处理不了,就分给浏览器的其他 “师傅”(线程):

    • 定时器师傅负责计时,时间到了就把回调函数放到任务队列里。
    • 网络师傅负责发请求,拿到数据后把回调放到任务队列里。
    • 事件师傅负责监听点击,用户点了就把事件处理函数放到任务队列里。
  3. 同步任务做完了,就去 “取餐台” 找任务 当你把所有同步任务都做完(当前 “执行栈” 空了),就会去看任务队列:

    • 队列里有任务的话,就按顺序(先进先出)拿一个过来执行。
    • 执行完这个异步任务的回调后,再检查队列里还有没有任务,有就再拿一个,循环往复。

2.3 例子:为什么 setTimeout 不准时?

console.log('1'); // 同步任务,先执行
setTimeout(() => {
  console.log('2'); // 异步任务,交给定时器师傅
}, 0);
console.log('3'); // 同步任务,继续执行
  • 你先做同步任务:输出 1 → 输出 3(此时同步任务做完)。
  • 再去任务队列看,发现定时器师傅放了 console.log('2'),就执行它,输出 2
  • 哪怕 setTimeout0 毫秒,也得等同步任务做完才会执行,所以它的 “准时” 是相对于队列顺序,不是绝对时间。

2.4 一句话总结

事件循环就是 “先干完手头的活(同步任务),再去队列里按顺序拿新活(异步任务)” 的循环过程。因为 JS 是单线程(一个厨师),靠这个机制才能同时 “处理”(其实是排队处理)各种异步操作,比如定时器、网络请求、用户事件。

三 微任务和宏任务

还是用 “餐厅后厨” 的场景接着讲,这次加入 “VIP 客户” 的设定

3.1 先明确:微任务和宏任务都是异步任务(都要进队列等 JS 引擎处理),但优先级不同。

  • 宏任务(Macro Task):普通客人点的菜,优先级低。
  • 微任务(Micro Task):VIP 客人点的菜,优先级高。

3.2 具体场景

假设你(JS 引擎)刚做完所有同步任务(手头的活干完了),现在要处理任务队列里的异步任务。这时候规则变了:

  1. 先清微任务队列(VIP 优先) 任务队列分两个:一个是 “微任务队列”(VIP 专属),一个是 “宏任务队列”(普通客人)。

    你会先把微任务队列里的所有任务按顺序全做完(不管有多少个),一个都不能剩。

  2. 再做一个宏任务 微任务全清完后,才去宏任务队列里拿一个任务来做(注意:只拿一个)。

  3. 然后再回到微任务队列 这个宏任务做完后,不管宏任务队列里还有没有其他任务,都要先回头检查微任务队列:如果有,还是先把所有微任务全清完,再去拿下一个宏任务。

    循环往复……

3.3 哪些是微任务?哪些是宏任务?

微任务(VIP)

  • Promise 的then/catch/finally(比如new Promise(...).then(...)里的回调)
  • async/await(本质是 Promise 的语法糖,await 后面的代码都是微任务)
  • queueMicrotask()(直接创建一个微任务)

宏任务(普通客人)

  • setTimeoutsetInterval(定时器)
  • DOM事件(比如 click、scroll 的回调)
  • fetch等网络请求的回调
  • script标签整体(整个 JS 文件的执行,算是第一个宏任务)

3.4 例子:看执行顺序

// 同步任务:先执行
console.log('1');

// 宏任务:扔进宏任务队列
setTimeout(() => {
  console.log('2'); 
}, 0);

// 微任务:扔进微任务队列
Promise.resolve().then(() => {
  console.log('3');
});

// 同步任务:继续执行
console.log('4');

执行步骤

  1. 先做同步任务:输出 1 → 输出 4(同步任务完)。
  2. 检查微任务队列:有一个console.log('3'),执行 → 输出 3(微任务全清)。
  3. 去宏任务队列拿一个任务:console.log('2'),执行 → 输出 2

最终结果1432(微任务比宏任务先执行)。

3.5 一句话总结

  • 微任务是 “VIP”,宏任务是 “普通客人”。
  • 事件循环的规则:做完同步任务 → 清完所有微任务 → 做一个宏任务 → 再清完所有微任务 → 做下一个宏任务……

这就是为什么Promise.then总是比setTimeout先执行 —— 因为微任务优先级更高,必须 “插队” 先做完~

进阶版