事件循环(Event Loop)

  • 2020.11.07

javascript 发展历史

在我们理解事件循环机制之前先来了解下Javascript语言的发展史。

首先我们需要知道,一个Javascript引擎会常驻于内存中,他等待这我们(宿主)把Javascript代码或者函数传递给他执行。

在早期的Javascript语言版本中,本来并没有异步执行代码的能力,这也叫意味着只能等待宿主环境把代码传递给引擎,然后引擎直接把代码顺次执行,这个任务也就是宿主发起的任务。

从 ES6 开始,Javascript引入了 Promise, 这样,不需要浏览器的安排, Javascript本身也可以发起任务了。

至此,这里就诞生了两个概念, 宏观任务微观任务

  • 宏观任务: 指代那些有宿主环境发起的任务。

  • 微观任务: 指代由Javascript引擎本身发起的任务。

执行栈和消息队列

在解析 Event Loop 运行机制之前,我们要先理解栈(stack)队列(queue)的概念。

队列,两者都是线性结构

  • 但是栈遵循的是后进先出(last in first off LIFO),开口封底。

  • 队列遵循的是先进先出 (fisrt in first out,FIFO),两头通透。

栈和队列

Event Loop得以顺利执行,它所依赖的容器环境,就和这两个概念有关。

我们知道,在 js 代码执行过程中,会生成一个当前环境的执行上下文( 执行环境 / 作用域),用于存放当前环境中的变量,这个上下文环境被生成以后,就会被推入 js 的执行栈。一旦执行完成,那么这个执行上下文就会被执行栈弹出,里面相关的变量会被销毁,在下一轮垃圾收集到来的时候,环境里的变量占据的内存就能得以释放。

这个执行栈,也可以理解为 JavaScript 的单一线程,所有代码都跑在这个里面,以同步的方式依次执行,或者阻塞,这就是同步场景

那么异步场景呢?显然就需要一个独立于“执行栈”之外的容器,专门管理这些异步的状态,于是在“主线程”、“执行栈”之外,有了一个 Task 的队列结构,专门用于管理异步逻辑。所有异步操作的回调,都会暂时被塞入这个队列。Event Loop 处在两者之间,扮演一个大管家的角色,它会以一个固定的时间间隔不断轮询,当它发现主线程空闲,就会去到 Task 队列里拿一个异步回调,把它塞入执行栈中执行,一段时间后,主线程执行完成,弹出上下文环境,再次空闲,Event Loop 又会执行同样的操作。。。依次循环,于是构成了一套完整的事件循环运行机制。

为什么需要是单线程?

单线程与之用途有关,单线程能够保证一致性,如果有两个线程,一个线程点击了一个元素,另一个删除了一个元素,浏览器以哪一个为准?

TIP

单线程指的是单个脚本只能在一个线程上运行,而不是 JavaScript 引擎只有一个线程。

我们先看一张经典的图

Event Loop

javascript中为了协调事务的处理,交互等必须采用Event Loop机制。任务主要分为同步任务异步任务

异步任务分成了宏任务微任务

  • 宏任务:包括整体代码scriptsetTimeoutsetIntervalUI 渲染I/OpostMessageMessageChannelsetImmediate(Node.js 环境)

  • 微任务:Promise.then(非new Promise)process.nextTick(node环境)MutaionObserver

单次循环的事件的执行顺序,是先执行宏任务,然后执行微任务,这个是基础,任务可以有同步任务和异步任务,同步的进入主线程,异步的进入 Event Table 并注册函数,异步事件完成后,会将回调函数放入Event Queue中(宏任务和微任务是不同的Event Queue),同步任务执行完成后,会从Event Queue中读取事件放入主线程执行,回调函数中可能还会包含不同的任务,因此会循环执行上述操作。

Event Loop

  • JavaScript是单线程的,所有的同步任务都会在主线程中执行。

  • 主线程之外,还有一个任务队列。每当一个异步任务有结果了,就往任务队列里塞一个事件。

  • 当主线程中的任务,都执行完之后,系统会 “依次” 读取任务队列里的事件。与之相对应的异步任务进入主线程,开始执行。

  • 异步任务之间,会存在差异,所以它们执行的优先级也会有区别。大致分为 微任务 和 宏任务。同一次事件循环中,微任务永远在宏任务之前执行。

  • 主线程会不断重复上面的步骤,直到执行完所有任务。

浏览器渲染时机

TIP

在上面浏览器 Event Loop 的执行机制中,有很重要的一块内容,就是浏览器的渲染时机,

浏览器会等到当前的 micro-task 为空的时候,进行一次重新渲染。所以如果你需要在异步的操作后重新渲染 DOM 最好的方法是将它包装成 micro 任务,这样 DOM 渲染将会在本次 Tick 内就完成。

Event Loop 常见面试题

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    console.log('async2');
}

console.log('script start');

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

async1();

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

/**
 * 1. script start // 同步任务
 * 2. async1 start // async1函数中的同步任务
 * 3. async2       // async1函数中遇到await先立即执行
 * 4. promise1     // 遇到promise 执行同步任务
 * 5. script end   // 执行外层同步任务
 * 6. async1 end   // 执行微任务中先注册的
 * 7. promise2     // 执行promise中的then 优先级大于setTimeout
 * 8. setTimeout   // 执行setTimeout函数
 * /

引申:如果我写了一个setTimeout(()=>console.log(1111), 500);这段代码会在 500 毫秒后执行吗?

不一定会执行,这段代码只是告诉浏览器我往异步任务队列塞了一个延迟 500 毫秒后执行的任务,如果当前异步任务队列是空闲的,在得到执行权后会延时 500 毫秒执行。否则的话必须等到获得执行权。

引申: 如何根据此特性实现一个红绿灯,把一个圆形的 div 按照绿色 3s,黄色 1s,红色 2s 循环改变背景色?

  • 2020.11.07
<div ref="light" class="traffic-light" :class="[className]" />

方法 1: 思路最简单,使用setTimeout来显示递归显示。

let that = this;

const fn = function() {
  that.className = "green";
  setTimeout(() => {
    that.className = "yellow";
    setTimeout(() => {
      that.className = "red";
      setTimeout(() => {
        fn();
      }, 2000);
    }, 1000);
  }, 3000);
};

fn();

方法 2: setTimeoutPromise结合,本质上没什么区别

const fn = function() {
  that.className = "green";
  setTimeout(() => {
    return new Promise((res, rej) => {
      that.className = "yellow";
      res(
        setTimeout(() => {
          return new Promise((res, rej) => {
            that.className = "red";
            setTimeout(() => {
              res(fn());
            }, 2000);
          });
        }, 1000)
      );
    });
  }, 3000);
};
上次更新时间: 2021-04-26 20:43:00