编辑
2025-01-20
前端
00
请注意,本文编写于 110 天前,最后修改于 73 天前,其中某些信息可能已经过时。

目录

1. 浏览器事件循环
2. Node.js 事件循环
1. 宏任务
2. 微任务
3. 总结规律

事件循环是 JS 中重要的概念,用于处理同步代码、异步代码的执行顺序问题

因为 JS 的执行环境不同,分为浏览器事件循环和 Node.js 事件循环

1. 浏览器事件循环

浏览器事件循环比较简单

JS 的代码分为同步代码和异步代码,异步代码像这样

js
setTimeout(() => console.log(1)); // 像setTimeout这种函数的回调是异步的 console.log(2); // 普通的同步代码

JS 执行代码的顺序是,先清空执行栈,再执行异步代码,也就是同步代码先执行

我们再分析异步代码

首先我们把异步代码的回调分为:宏任务和微任务,同时有一条宏任务队列和一条微任务队列。整个事件循环的过程可以为以下几步

  1. 清空执行栈中的代码
  2. 清空微任务队列中的代码,放到执行栈中执行
  3. 如果微任务中包含有宏任务,放入宏任务队列中,如果有微任务,放入微任务队列中,直到微任务清空完毕
  4. 再清空宏任务队列,先取一个宏任务,如果其中包含微任务,那执行完宏任务之后需要再清空微任务队列之后再取下一个宏任务
  5. 清理完所有微任务和宏任务队列中的任务,事件循环完毕

典型的宏任务和微任务如下

  • 宏任务:setTimeoutsetIntervalrequestAnimationFrame
  • 微任务:Promise.resolve().then()await之后的代码MutationObserver

比如下面

js
setTimeout(() => console.log(1)); // 宏任务 Promise.resolve().then(() => console.log(2)); // 微任务

显然是 Promise.resolve().then() 先执行

其中有一种特殊情况,就是使用了 async await 关键字的函数,在使用 await 关键字之后的部分是异步代码

js
async function async1() { console.log('async1'); await async2(); // await 关键字之后的代码 可以 认为是Promise.then() console.log('async1 end'); } async function async2() { console.log('async2'); } async1()

小试牛刀

试试多层嵌套的宏任务微任务

js
async function fn() {} setTimeout(() => { console.log("Timeout 1"); Promise.resolve().then(() => { setTimeout(() => { console.log("Timeout 2"); }, 0); console.log("Promise 1"); }); }, 0); Promise.resolve().then(() => { console.log("Promise 2"); setTimeout(async () => { console.log("Timeout 3"); await fn(); console.log("await 1"); setTimeout(() => { console.log("Timeout 4"); }, 0); }, 0); });

可以尝试模拟任务插入队列,推出队列到执行栈中执行的过程

// 以下p1 t1 等为简写 (x) 表示已推出队列 // *1* // 微任务:p2(x) // 宏任务:t1 t3 // 输出:p2 // *2* // 微任务:p2(x) p1(x) // 宏任务:t1(x) t3 t2 // 输出:p2 t1 p1 // *3* // 微任务:p2(x) p1(x) a1(x) // 宏任务:t1(x) t3(x) t2 t4 // 输出:p2 t1 p1 t3 a1 // *4* // 微任务:p2(x) p1(x) a1(x) // 宏任务:t1(x) t3(x) t2(x) t4(x) // 输出:p2 t1 p1 t3 a1 t2 t4

2. Node.js 事件循环

Node.js 的运行机制如下:

  1. V8 引擎解析 JS 脚本
  2. 解析后的代码,调用 Node API
  3. libuv 负责 Node API 的执行,它将不同任务分配给不同的线程,形成一个事件循环,以异步的方式将任务的执行结果返回给 V8 引擎
  4. V8 引擎再将结果返回给用户

1. 宏任务

Node.js 中会复杂一,同样还是宏任务、微任务,但是宏任务又分为几个阶段,如下

┌───────────────────────────┐ ┌─>│ timers │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ pending callbacks │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ │ │ idle, prepare │ │ └─────────────┬─────────────┘ ┌───────────────┐ │ ┌─────────────┴─────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └─────────────┬─────────────┘ │ data, etc. │ │ ┌─────────────┴─────────────┐ └───────────────┘ │ │ check │ │ └─────────────┬─────────────┘ │ ┌─────────────┴─────────────┐ └──┤ close callbacks │ └───────────────────────────┘

一般是从输入数据开始整个事件循环

常见的几个阶段对应的 api

  • poll:一些 IO 操作,比如 fs.readFile
  • check: setImmediate
  • close callback: 一般不考虑,但是可以知道 readStream.close
  • timerssetTimeoutsetInterval
  • pending callbacks:执行推迟到下一次循环迭代的 I/O 回调,比如 tcp 错误回调
  • idle, prepare:仅 Node.js 内部使用

比如下面代码

js
setImmediate(() => { console.log('check'); }) setTimeout(() => { console.log('timer'); }) fs.readFile('./index.js', () => { console.log('poll'); }) // 执行结果: // timer // check // poll

这里顺序基本和上面一样,但是令人疑惑的是,为什么 poll 在 check 之后执行,不是说 poll -> check 吗

实际上如果是正常的同步代码,那么 poll 的回调应该马上交给队列,但是因为需要 libuv 读取文件,因此 fs.readFile 的回调延迟交给了队列,所以先执行了 timer 和 check

比如像下面这种情况,执行顺序就和上述的一样了

js
fs.readFile("./index.js", () => { setTimeout(() => { console.log("timer"); }); setImmediate(() => { console.log("check"); }); console.log("poll"); }); // 执行结果 // poll // check // timer

对应宏任务的每个阶段,它们都是使用队列管理的,比如下面代码

js
fs.readFile("./index.js", () => { setTimeout(() => { console.log("timer1"); fs.readFile("./index.js", () => { console.log("poll2"); }); setImmediate(() => { console.log("check3"); }); }); setTimeout(() => { console.log("timer2"); }); setImmediate(() => { console.log("check1"); fs.readFile("./index.js", () => { console.log("poll1"); }); }); console.log("poll"); setImmediate(() => { console.log("check2"); }); }); // 输出结果 // poll // check1 // check2 // timer1 // timer2 // check3 // poll1 这两个poll的顺序不一定 // poll2

像这里,只要是同一个事件循环内 push 到队列中的任务,都需要清空相应的队列之后才会执行下一阶段队列,比如 checktimer 这两个阶段

而其中的 poll 因为需要 libuv 读取文件处理,有一定的延时,因此几乎不用考虑队列,因为基本不太可能两个 poll 任务在同一个事件循环内执行

2. 微任务

Node.js 环境中,微任务还有一种叫做 process.nextTickNode.js 官网给出的解释是

Looking back at our diagram, any time you call process.nextTick() in a given phase, all callbacks passed to process.nextTick() will be resolved before the event loop continues. 回顾我们的图表,任何时候在给定阶段调用 process.nextTick(),传递给 process.nextTick() 的所有回调都将在事件循环继续之前得到解决。

我的理解是,process.nextTick 的设计是为了能够在事件循环任何阶段开始前执行,比如下面代码

js
// 这是一轮事件循环 setTimeout(() => { console.log("timer1"); }) setImmediate(() => { console.log("check1"); }) Promise.resolve().then(() => { console.log("promise1"); }) process.nextTick(() => { console.log("nextTick1"); }) fs.readFile("./index.js", () => { // 这里是新的一轮事件循环 console.log("poll1"); setImmediate(() => { console.log("check2"); }) setTimeout(() => { console.log("timer2"); }) process.nextTick(() => { console.log("nextTick2"); }) }) // 输出结果 /** nextTick作为微任务,优先级比Promise还高,所以每次事件循环都是nextTick先执行 **/ // nextTick1 // promise1 // timer1 // check1 /** 这是新的一轮事件循环,nextTick也是在check之前就执行了,印证了官网的说法 **/ // poll1 // nextTick2 // check2 // timer2

Node.js 中,宏任务和微任务的执行顺序依然遵循,先清空微任务,再执行一个宏任务,再清空微任务这种执行顺序

小试牛刀

js
const fs = require("fs"); const start = Date.now(); setTimeout(() => { console.log("timer1"); }, 200); new Promise((resolve) => { fs.readFile("./index.js", () => { console.log("poll1"); while (Date.now() - start < 500); setImmediate(() => { console.log("immediate1"); }); process.nextTick(() => { console.log("nextTick1"); Promise.resolve().then(() => { console.log("promise1"); }); }); }); setTimeout(() => { console.log("timer2"); resolve(); }, 100); }).then(() => { console.log("promise2"); }); // 输出结果 /** fs.readFile 完毕 开始一轮事件循环 **/ // poll1 /** nextTick 作为微任务,又有最高优先级最先执行 **/ // nextTick1 /** 推入微任务队列的Promise 立即执行 **/ // promise1 /** 到了宏任务的check阶段 **/ // immediate1 /** 到了宏任务的timer阶段,因为这个延迟时间更少,所以先执行 **/ // timer2 /** resolve promise之后,执行微任务 **/ // promise2 /** 最后执行timer1 其实当前还是在一轮事件循环的timer阶段 **/ // timer1

3. 总结规律

  1. 知道基本的事件循环机制,即先清空微任务队列,再执行一个宏任务,再清空微任务队列,以此往复
  2. 知道当前属于哪一轮事件循环,切勿将不是一轮循环的两个回调进行对比
  3. 知道当前执行到宏任务的哪个阶段,下一个阶段会先清空微任务再执行,微任务中 process.nextTick 优先级最高
如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:pepedd864

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!