jannahuang / blog

MIT License
0 stars 0 forks source link

Event Loop 事件循环是什么 #13

Open jannahuang opened 2 years ago

jannahuang commented 2 years ago

JavaScript 本质上是一门单线程语言,即不能同时执行多个任务。而事件循环,就是浏览器或 Node.js 解决 JavaScript 单线程运行时不阻塞的一种机制,也就是异步的原理。

浏览器中的事件循环

浏览器执行环境的核心思想基于:同一时刻只能执行一个代码片段,即所谓的单线程执行模型。 所有已生成的事件都会放在同一个事件队列中,以它们被浏览器检测到的顺序排列。

事件处理的过程可以描述为一个简单的流程:

  1. 浏览器检查事件队列头;
  2. 如果浏览器没有在队列中检测到事件,则继续检查;
  3. 如果浏览器在队列头中检测到了事件,则取出该事件并执行相应的事件处理器(如果存在)。在这个过程中,余下的事件在事件队列中耐心等待,直到轮到它们被处理。

事件循环不仅仅包含事件队列,而是具有至少两个队列,除了事件,还要保持浏览器执行的其他操作。这些操作被称为任务,并且分为两类:宏任务(或通常称为任务)和微任务。

事件循环的实现至少应该含有一个用于宏任务的队列和至少一个用于微任务的队列。 事件循环基于两个基本原则

  1. 一次处理一个任务。
  2. 一个任务开始后直到运行完成,不会被其他任务中断。

浏览器中的事件循环

在一次迭代中,事件循环将首先检查宏任务队列,如果宏任务等待,则立即开始执行宏任务。直到该任务运行完成(或者队列为空),事件循环将移动去处理微任务队列。如果有任务在该队列中等待,则事件循环将依次开始执行,完成一个后执行余下的微任务,直到队列中所有微任务执行完毕。注意处理宏任务和微任务队列之间的区别:单次循环迭代中,最多处理一个宏任务(其余的在队列中等待),而队列中的所有微任务都会被处理。 当微任务队列处理完成并清空时,事件循环会检查是否需要更新 UI 渲染,如果是,则会重新渲染 UI 视图。至此,当前事件循环结束,之后将回到最初第一个环节,再次检查宏任务队列,并开启新一轮的事件循环。

所有微任务会在下一次渲染之前执行完成,因为它们的目标是在渲染前更新应用程序状态。

仅含宏任务的示例

<!-- 单一任务队列示例的伪代码 -->
<button id="firstButton"></button>
<button id="secondButton"></button>
<script>
 const firstButton = document.getElementById("firstButton");
 const secondButton = document.getElementById("secondButton");  firstButton.addEventListener("click", function firstHandler() {
  /*Some click handle code that runs for 8 ms*/  ⇽--- 在第一个按钮上注册 点击事件处理器
 });
 secondButton.addEventListener("click", function secondHandler() {
   /*Click handle code that runs for 5ms*/  ⇽--- 在第二个按钮上注册另一个点击事件处理器
 });
 /*Code that runs for 15ms*/
 </script>

假设有一个手快的用户在代码执行后5ms时 单击第一个按钮,随后在12ms时单击第二个按钮。 仅含宏任务

时间表显示了当事件发生时任务是如何添加到队列中的。当一个任务执行完成,事件循环将该任务移除队列,并开始执行下一个任务。 注意,事件监测和添加任务是独立于事件循环的,尽管主线程仍在执行,仍然可以向队列添加任务。

同时含有宏任务和微任务的示例

<!-- 同时包含两个任务队列的事件循环伪代码 -->
<button id="firstButton"></button>
<button id="secondButton"></button>
<script>
  const firstButton = document.getElementById("firstButton");
  const secondButton = document.getElementById("secondButton");   firstButton.addEventListener("click", function firstHandler(){
    Promise.resolve().then(() => {
    /*Some promise handling code that runs for 4 ms*/
    });  ⇽--- 立即对象promise,并且执行then方法中的回调函数    /*Some click handle code that runs for 8 ms*/
 });
 secondButton.addEventListener("click", function secondHandler(){    /*Click handle code that runs for 5ms*/
 });
/*Code that runs for 15ms*/
</script>

假设进行以下操作:

如果微任务队列中含有微任务,不论队列中等待的其他任务,微任务都将获得优先执行权。在本例中,promise 微任务优先于 secondButton 单击任务开始执行。 事件循环

Node.js 中的事件循环

Node.js 是依赖 libuv 进行事件循环,将各种函数(也叫任务或回调)分成至少 6 类,按先后顺序调用,因此将时间分为六个阶段:

  1. timers:这个阶段执行 setTimeout() 和 setInterval() 的回调函数。
  2. I/O callbacks:不在 timers 阶段、close callbacks 阶段和 check 阶段这三个阶段执行的回调,都由此阶段负责,这几乎包含了所有回调函数。该阶段不用管。
  3. idle, prepare:event loop 内部使用的阶段。该阶段不用管。
  4. poll:最为重要的阶段,获取新的 I/O 事件。在某些场景下 Node.js 会阻塞在这个阶段。轮询阶段,停留时间最长,可以随时离开。 主要用来处理 I/O 事件,该阶段中 Node 会不停询问操作系统有没有文件数据、网络数据等。 如果 Node 发现有 timer 快到时间了或者有 setImmediate 任务,就会主动离开 poll 阶段。
  5. check:执行 setImmediate() 的回调函数。主要处理 setImmediate 任务。
  6. close callbacks:执行关闭事件的回调函数,如 socket.on('close', fn) 里的 fn。该阶段不用管。

Node.js 会不停的从 1 ~ 6 循环处理各种事件,这个过程叫做事件循环(Event Loop)。

-- Node.js event loop 概览:
   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

setImmediate(Node.js 特有)

setImmediate() 的作用是在当前 poll 阶段结束后调用一个函数。 setTimeout() 的作用是在一段时间后调用一个函数。 这两者的回调的执行顺序取决于 setTimeout 和 setImmediate 被调用时的环境。

process.nextTick(Node.js 特有)

process.nextTick(fn) 的 fn 会在什么时候执行呢? 在 Node.js 11 之前,会在每个阶段的末尾集中执行(俗称队尾执行)。 在 Node.js 11 之后,会在每个阶段的任务间隙执行(俗称插队执行)。 浏览器跟 Node.js 11 之后的情况类似。可以用 window.queueMicrotask 模拟 nextTick。

async / await

这是 Promise 的语法糖,所以直接转为 Promise 写法即可。 async/await 转 Promise,引用网络图片

总结

事件循环算法

  1. 从 宏任务 队列(例如 “script”)中出队(dequeue)并执行最早的任务。
  2. 执行所有 微任务: 当微任务队列非空时: 出队(dequeue)并执行最早的微任务。
  3. 如果有变更,则将变更渲染出来。
  4. 如果宏任务队列为空,则休眠直到出现宏任务。
  5. 转到步骤 1。

创建任务(宏任务)

  1. script
  2. setTimeout
  3. setInterval
  4. setImmediate(Node.js 特有)
  5. requestAnimationFrame(浏览器特有)

创建微任务

  1. process.nextTick(Node.js 特有)
  2. MutationObserver(浏览器特有)
  3. Promise.then catch finally
  4. window.queueMicrotask(浏览器特有)

执行顺序

微任务会在任务间隙执行,俗称插队执行。但微任务不能插微任务的队。

任务队列和微任务队列的区别

任务队列和微任务队列的区别很简单,但却很重要:

以上笔记参考于《JavaScript 忍者秘籍第2版》,《现代 JavaScript 教程》,MDN 及其他网络资料

练习

  1. 以下代码会输出什么?
    
    console.log('script start');

setTimeout(function () { console.log('setTimeout'); }, 0);

Promise.resolve() .then(function () { console.log('promise1'); }) .then(function () { console.log('promise2'); });

console.log('script end');

/ 答案 'script start' // 先立即执行主线代码 'script end' 'promise1' // 然后处理微任务 'promise2' 'setTimeout' // 再处理新一轮任务 参考链接,有动图示范 https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/ /

2. 以下代码在 Node.js 运行会输出什么?
```javascript
setTimeout(() => {
  console.log('setTimeout')
})
setImmediate(() => {
  console.log('setImmediate')
})
/*
答案
先打印 setTimeout 或者 setImmediate 都有可能。
因为 Node.js 是依赖 libuv 库(底层是 C++)来实现事件循环,而 JS 引擎在执行 JS 代码。
所以打印的先后顺序取决于 libuv 和 JS 引擎的启动顺序。

1. 当 libuv 先启动时,timers 一开始为空,poll 在等待任务。
然后 JS 引擎启动,timers 出现任务,然后到 check 阶段时打印 setImmediate。
然后开始新的循环,轮到 timers 打印 setTimeout。

2. 当 JS 引擎先启动时,Node.js 先存着需要给 timers 的内容。
等 libuv 启动后,timers 中已经有任务,就开始处理任务,先打印 setTimeout。
然后到 check 阶段时打印 setImmediate。
*/
  1. 以下代码会输出什么?
    
    console.log(1);

setTimeout(() => console.log(2));

Promise.resolve().then(() => console.log(3));

Promise.resolve().then(() => setTimeout(() => console.log(4)));

Promise.resolve().then(() => console.log(5));

setTimeout(() => console.log(6));

console.log(7);

/* 答案 1 7 3 5 2 6 4 先执行主线代码 1,然后将 setTimeout 按顺序添加到宏任务队列, 将 Promise 按顺序添加到微任务队列,输出主线代码 7。

然后按顺序处理微任务队列,先输出 3,下一个 Promise 里有 setTimeout, 添加到宏任务队列。输出5,微任务处理完成。

然后按顺序处理宏任务队列,先输出 2,然后输出 6,最后输出 4。 */


4. 以下代码会输出什么?
```javascript
async function async1(){
    console.log('1')
    await async2()
    console.log('2')
    // 可以改写为 Promise 写法,将 await 后面的代码,放到 Promise.then() 中
    // async2().then(()=>{
    //   console.log('2')
    // })
}
async function async2(){
    console.log('3')
}
console.log('4')
setTimeout(function(){
    console.log('5') 
},0)  
async1();
new Promise(function(resolve){
    console.log('6')
    resolve();
}).then(function(){
    console.log('7')
})
console.log('8')

/*
答案
4 1 3 6 8 2 7 5
1. 前两个函数声明 async1 和 async2 忽略。
2. 执行主线代码打印 4。
3. setTimout 添加到宏任务队列。
4. 然后执行 async1(),打印 1,然后执行 await async2(),将其改写成 Promise 写法便于判断,
   意思是先执行 async2(),打印 3,然后将 async2.then() 的内容添加到微任务队列。
5. new Promise 函数是立即执行的,打印 6,这里的 resolve() 只是把 Promise 标记为完成状态。
   然后将 then() 的内容添加到微任务队列。
6. 执行主线代码打印 8。
7. 按顺序处理微任务队列,打印 2,7。
8. 处理宏任务,打印 5。
*/
  1. 以下代码会输出什么?
    
    Promise.resolve()
    .then(() => {
        console.log(0);
        return Promise.resolve(4);
    })
    .then((res) => {console.log(res)})

Promise.resolve().then(() => {console.log(1);}) .then(() => {console.log(2);}) .then(() => {console.log(3);}) .then(() => {console.log(5);}) .then(() => {console.log(6);})

/* 答案 0 1 2 3 4 5 6 理论:一个 return Promise.resolve() 的效果等于两层嵌套的 Promise.resolve().then。 根据上述理论,可以将第一个 Promise 改写为: Promise.resolve() .then(() => { console.log(0); }) .then(() => { Promise.resolve().then(() => { Promise.resolve().then(() => { console.log(4); }) }) })

注意:连续 then 的情况,要将第一个 then 执行完成,再将第二个 then 添加到微任务队列。

以本题分析:

  1. 第一个 Promise 的第一个 then 添加到微任务队列,第二个 Promise 的第一个 then 也添加到微任务队列,然后开始执行。 所以先打印 0;打印 1。
  2. 然后将第一个 Promise 的第二个 then 添加到微任务队列,将第二个 Promise 的第二个 then 也添加到微任务队列。 执行 Promise,没有打印,将 then 加入微任务队列;打印 2。
  3. 将第二个 Promise 的第三个 then 也添加到微任务。 再执行 Promise,将 then 加入微任务队列;打印 3。
  4. 将第二个 Promise 的第四个 then 也添加到微任务。 经过两层嵌套的 Promise.resolve().then 之后,第一个 Promise 终于能打印了,打印 4;打印 5。
  5. 剩下第二个 Promise 的第五个 then,执行后打印 6。 */