JavaScript - 事件循环

103 阅读6分钟

1、进程、线程

  • 进程:资源(CPU、内存等)分配的基本单位
  • 线程:程序执行时的最小单位

image.png

2、宏任务和微任务

宏任务:
  • 宏任务是指消息队列中等待被主线程执行的事件
  • 常见宏任务
    • script 脚本的执行、
    • setTimeout ,setInterval 
    • setImmediate:
    • 还有如 I/O 操作
    • UI 渲
微任务:
  • 微任务也是需要异步执行的事件
  • 主函数执行结束之后,当前宏任务结束之前
  • 常见微任务
    • process.nextTick ( 在 then 之前调用 )
    • Promise:本身是立执行函数
    • then、catch、finally:(微任务)
    • 对 Dom 变化监听的 MutationObserver

3、主线程、调用栈、消息队列、微任务队列

image.png

  • 主线程
    • JavaScript 是基于单线程设计的。为了方便使用 JavaScript 来操纵 DOM,所以从一开始,JavaScript 就被设计成了运行在 UI 线程中,UI线程是指运行窗口的线程,即当前窗口的主线程
  • 调用栈
    • 调用栈是用来管理主线程上函数调用关系的数据结构,和基本栈结构一样,先进后出
  • 消息队列
    • 消息队列用于存 放当前窗口中待执行的事件 ( 存放宏任务 )。当运行一个窗口时,需要处理不同的事件,我们为 UI 线程提供一个消息队列,并将这些待执行的事件添加到消息队列中
  • 微任务队列
    • V8会为 每个宏任务维护一组微任务队列,当V8执行一段 js 代码时会为这段代码创建一个环境对象。微任务队列就是存放在该环境对象中的

4、案例 - 执行流程

我们先看一段代码,分析一下其执行流程

function bar() {
    console.log('bar');
    // 微任务 - 进微任务队列
    Promise.resolve().then((str) => console.log('micro-bar'));
    // 宏任务 - 进消息队列
    setTimeout((str) => console.log('macro-bar'), 0);
}
function foo() {
    console.log('foo');
    // 微任务 - 进微任务队列
    Promise.resolve().then((str) => console.log('micro-foo'));
    // 宏任务 - 进消息队列
    setTimeout((str) => console.log('macro-foo'), 0);
    bar();
}
foo();
console.log('global');
// 微任务 - 进微任务队列
Promise.resolve().then((str) => console.log('micro-global'));
// 宏任务 - 进消息队列
setTimeout((str) => console.log('macro-global'), 0);

执行结果如下:

foo
bar
global
micro-foo
micro-bar
micro-global
macro-foo
macro-bar
macro-global

首先,执行这段代码时,会先创建一个全局执行上下文,并且会在全局执行上下文中创建一个空的微任务队列。这里是重点,微任务队列是放在全局执行上下文中的。

image.png

然后执行 foo 函数,V8 创建 foo 函数的执行上下文,并将其压入调用栈: 当执行到 foo 函数中的 Promise.resolve 时,会产生一个微任务,V8 会将该任务添加到微任务队列。之后执行到 setTimeout 会产生一个宏任务,V8 会将该任务添加到消息队列。这里我们注意,微任务添加到微任务队列,宏任务被添加到消息队列

image.png

执行到 bar 时,会将 bar 函数的执行上下文压入调用栈。 同上,执行到 Promise.resolve 时,会产生一个微任务,V8 会将该任务添加到微任务队列。之后执行到 setTimeout 会产生一个宏任务,V8 会将该任务添加到消息队列。这里我们注意,消息队列是先进先出,调用栈是后进先出

image.png

接下来,bar 函数执行结束,bar 函数执行上下文被弹出调用栈。接着 foo 函数执行结束,foo函数执行上下文被弹出调用栈(倒序弹出)。调用栈中剩下全局执行上下文、消息队列以及微任务队列中内容不变。

image.png

执行完 foo 函数之后,V8 开始执行全局环境中的代码了。同上,执行到 Promise.resolve 时,会产生一个微任务,V8 会将该任务添加到微任务队列。之后执行到 setTimeout 会产生一个宏任务,V8 会将该任务添加到消息队列

此时,调用栈中包含的是全局执行上下文; 微任务队列中的微任务是 micro-foo、micro-bar、micro-global; 消息队列中宏任务的状态是 macro-foo、macro-bar、macro-global

image.png

等全局环境中的代码也执行完成,这段代码就即将要完成了。V8 将要销毁这段代码的环境对象。这里就是 V8 执行微任务的一个检查点。这时候 V8 会检查微任务队列,如果其中有待执行的微任务,V8 会依次取出,并按顺序执行。执行顺序依次是:micro-foo、micro-bar、micro-global。

image.png

等微任务队列中所有微任务都执行完成,当前宏任务就算执行完成了。此时 V8 也不会空闲下来,而是去消息队列中查看是否有等待执行的宏任务,并依次取出执行。宏任务放在消息队列中,执行顺序是先进先出。故执行顺序为:macro-foo、macro-bar、macro-global。 等所有任务都执行完成,主线程、消息队列、微任务队列就都被清空了。

image.png

到这里我们这段代码的执行过程分析就结束了。这样彻底了解微任务到底在什么时候被执行了吧。 那么,来一个思考题,上一篇文章 我讲过循环调用中可以用宏任务解决栈溢出问题,如果把宏任务换成微任务会怎样呢?

function foo() {
    return Promise.resolve().then(foo);
}
foo();

当执行 foo 函数时,会将 foo 函数执行上下文压入调用栈。遇到 Promise.resolve,产生一个微任务,将其放到全局执行上下文中的微任务队列,之后,foo 函数就执行完毕。其执行上下文从调用栈中弹出。此时这段代码即将执行完毕,V8 会去检查微任务队列,从中取出刚刚放进去的微任务执行,即重新调用 foo 函数。。。 这个过程不断循环,但是 foo 函数中前套的 foo 函数是被放在微任务放队列中的,所以不会造成栈溢出。但是,因为微任务队列中的微任务是在当前宏任务中执行的。所以,这样就永远退不出当前宏任务,消息队列中的其他宏任务,如点击事件等也就无法被执行,会造成页面卡顿。 总结:执行一段代码时,会创建一个执行环境,微任务队列即放在该执行环境中。且是被放在全局执行上下文中的。所以其执行时机是在主函数执行结束之后,当前宏任务结束之前。