编辑
2025-01-20
前端
00

目录

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 许可协议。转载请注明出处!