事件循环笔记

65 阅读11分钟

事件循环

JavaScript代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行。整个执行过程,我们称为事件循环过程。一个线程中,事件循环是唯一的,但是任务队列可以拥有多个。任务队列又分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为task与jobs。

总的来说,步骤就是,执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环

宏任务

首先页面上的多数任务都是在主线程上完成的。

  • 渲染事件(解析DOM,计算布局,绘制)
  • 用户交互事件(点击,滚动,放缩)
  • js脚本执行
  • 网络请求完成,读写完成事件

为了协调这些任务在主线程上好好执行,引入了消息队列和事件循环机制,渲染进程内部维护多个消息队列,比如延迟执行队列和普通的消息队列。主线程使用for循环,不断从这些队列里面取出任务并执行,这些消息队列里面的任务被称为宏任务。

tips:为什么会有延迟队列?怎么跟事件循环互动?原理是什么?

如果只有普通的消息队列,那么任务只能按顺序执行,为了保证回调函数能在指定时间内执行,只能引入延迟队列。当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

跟事件循环的互动,这是一段消息循环代码:

void ProcessTimerTask(){
  // 从 delayed_incoming_queue 中取出已经到期的定时器任务
  // 依次执行这些任务}
 
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;void MainTherad(){
  for(;;){
    // 执行消息队列中的任务
    Task task = task_queue.takeTask();
    ProcessTask(task);
    
    // 执行延迟队列中的任务
    ProcessDelayTask()
 
    if(!keep_running) // 如果设置了退出标志,那么直接退出线程循环
        break; 
  }}

从上面可以看出:执行完一个消息队列以后开始执行 ProcessDelayTask 函数,该函数会根据发起时间和延迟计算出到期的任务,然后依次执行这些任务,等到到期任务执行完后,再继续下一个循环。

原理,自己实现一个setTimeout,关键就是发起时间和延迟计算出到期

let setTimeout = (fn, timeout, ...args) => {
  // 初始当前时间
  const start = +new Date()
  let timer, now
  const loop = () => {
    timer = window.requestAnimationFrame(loop)
    // 再次运行时获取当前时间
    now = +new Date()
    // 当前运行时间 - 初始当前时间 >= 等待时间 ===>> 跳出
    if (now - start >= timeout) {
      fn.apply(this, args)
      window.cancelAnimationFrame(timer)
    }
  }
  window.requestAnimationFrame(loop)}

function showName(){ 
console.log("Hello")}let timerID = setTimeout(showName, 1000);// 在 1 秒后打印 “Hello”

宏任务能满足大部分日常需求,不过当对时间精度有要求的时候,宏任务就很难胜任了。

页面的渲染事件、各种 IO 的完成事件、执行 JavaScript 脚本的事件、用户交互的事件等都随时有可能被添加到消息队列中,而且添加事件是由系统操作的,JavaScript 代码不能准确掌控任务要添加到队列中的位置,控制不了任务在消息队列中的位置,所以很难控制开始执行任务的时间。

比如说这样:

function timerCallback2(){
    console.log(2)
}
function timerCallback(){
    console.log(1)
    setTimeout(timerCallback2,0)
}
setTimeout(timerCallback,0)

我们本来想通过setTimeout 来设置两个回调任务,并让它们按照前后顺序来执行,中间也不要再插入其他的任务,因为如果这两个任务的中间插入了其他的任务,就很有可能会影响到第二个定时器的执行时间了。

但实际情况是我们不能控制的,比如在你调用 setTimeout 来设置回调任务的间隙,消息队列中就有可能被插入很多系统级的任务。像这样:

Image1.png

宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如后面要介绍的监听 DOM 变化的需求

微任务

先上概念:微任务就是需要异步执行的函数,执行时机是在主函数结束以前,当前宏任务以后。

那微任务是怎么产生的?又是怎么执行的呢?

先来看看微任务是如何产生的。产生微任务的方法有两个:

  1. 第一种方式是使用MutationObserver监控某个DOM节点。然后再通过js来修改这个节点,或者增删子节点,当DOM节点发生变化时,就会产生DOM变化记录的微任务。

  2. 使用Promise,当调用Promise.resolve()或Promise.reject()时产生微任务。

再来看看微任务放哪去了?

我们知道js在执行一段脚本的时候,V8会为它创建一个全局执行上下文,在创建全局上下文的同时,V8引擎也会在内部创建一个微任务队列。这个微任务队列就是存放微任务的地方。因为在当前宏任务执行的过程中,有时候会产生很多个微任务,这个微任务队列就是拿来存放微任务的。也就是说每个宏任务都关联了一个微任务队列。

那什么时候执行呢(执行微任务的时机)?

通常情况下,在当前宏任务中的js快执行完毕时,也就是js引擎在准备退出全局上下文并清空调用栈的时候,js引擎会检查全局执行上下文中的微任务队列,按照顺序执行队列中的微任务。

如果执行微任务的过程中产生了新的微任务,那么push,V8引擎会一直循环执行微任务队列里的任务直到栈为空。也就是说,执行微任务过程中产生新的微任务并不会推迟到下个宏任务执行,而是在当前宏任务中继续执行。

来个例子

Image2.png

Image3.png

该示意图是在执行一个 ParseHTML 的宏任务,在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。

从图中可以看到,全局上下文中包含了微任务列表。在 JavaScript 脚本的后续执行过程中,分别通过 Promise 和 removeChild 创建了两个微任务,并被添加到微任务列表中。接着 JavaScript 执行结束,准备退出全局执行上下文,这时候就到了检查点了,JavaScript 引擎会检查微任务列表,发现微任务列表中有微任务,那么接下来,依次执行这两个微任务。等微任务队列清空之后,就退出全局执行上下文。

小结:

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行

没看太懂,来个详细例子

看完好像还是不太懂,不够细致。缺少一个更具体地例子来讲解。

首先我们再来明确下定义:

Task(宏任务):同步代码、setTimeout 回调、setInteval 回调、IO、UI 交互事件、postMessage、MessageChannel。

MicroTask(微任务):Promise 状态改变以后的回调函数(then 函数执行,如果此时状态没变,回调只会被缓存,只有当状态改变,缓存的回调函数才会被丢到任务队列)、Mutation observer 回调函数、queueMicrotask 回调函数(新增的 API)。

然后我们来看看Event loop执行顺序

  1. 执行同步代码
  2. 执行完同步代码后且栈为空,判断是否有微任务需要执行
  3. 执行所有微任务且微任务队列为空
  4. 是否有需要渲染页面
  5. 执行一个宏任务

其实我在这里卡了很久,就是不懂,为什么同步代码被定义为了宏任务,但是不会push到setTimeout后面执行。

其实应该这样理解:其实上面的任务指的都是里面的回调函数。首先整个在script中的代码是一个宏任务,然后setTimeout里面的回调函数也是一个宏任务,所以,更加具体的应该这样来理解。

  1. 执行所有的同步代码,有异步代码(setTImeout回调,setInteval回调等)的话,新建一个宏任务push进去
  2. 执行完所有同步代码后,判断是否有微任务需要执行
  3. 执行所有微任务且微任务队列为空
  4. 是否有需要渲染页面
  5. 执行下一个宏任务

来个实例来讲解这个执行顺序

console.log('start')

setTimeout(function () {  //第一个setTimeout
  console.log('event loop2, macrotask')
  new Promise(function (resolve) {
    console.log('event loop2, macrotask continue')
    resolve()  //一号resolve
  }).then(function () {
    console.log('event loop2, microtask1')
  })}, 0)

new Promise(function (resolve) { 
  console.log('middle')
  resolve() //二号resolve
  }).then(function () {
  console.log('event loop1, microtask1')
  setTimeout(function () {  //第二个setTimeout
    console.log('event loop3, macrotask')
  })})

console.log('end')

// start
// middle
// end
// event loop1, microtask1
// event loop2, macrotask
// event loop2, macrotask continue
// event loop2, microtask1
// event loop3, macrotask
  • 首先遇到同步代码打印start
  • 遇到setTimeout,新建一个宏任务push进任务队列,此时宏任务队列['所有同步代码', '第一个setTimeout']
  • 遇到promise,进入promise,遇到同步代码打印middle
  • 然后遇到resolve,把then回调放进还在执行的宏任务的微任务队列中,此时宏任务'所有同步代码'下的微任务队列为['二号resolve']
  • 遇到同步代码打印end
  • 执行完所有同步代码,'所有同步代码'任务即将出队列,判断是否有微任务队列,有,['二号resolve']
  • 进入['二号resolve'],遇到同步代码打印event loop1, microtask1
  • 遇到setTimeout,新建一个宏任务push进任务队列,此时宏任务队列为['所有同步代码', '第一个setTimeout', '第二个setTimeout']
  • '所有同步代码'下的微任务队列执行完毕,为空,'所有同步代码'出队,此时宏任务队列为['第一个setTimeout', '第二个setTimeout']
  • 执行宏任务队列的下一个任务,'第一个setTimeout',遇到同步代码打印event loop2, macrotask
  • 遇到promise,进入promise,遇到同步代码打印event loop2, macrotask continue
  • 然后遇到resolve,在还在执行的宏任务'第一个setTimeout'下添加微任务队列,为['一号resolve']
  • 执行完所有同步代码,'第一个setTimeout'任务即将出队列,判断是否有微任务队列,有,为['一号resolve']
  • 进入['一号resolve'],遇到同步代码打印event loop2, microtask1
  • '第一个setTimeout'下的微任务队列执行完毕,为空,'第一个setTimeout'出队,此时宏任务队列为[ '第二个setTimeout']
  • 进入[ '第二个setTimeout'],遇到同步代码打印event loop3, macrotask
  • 执行完所有同步代码,'第二个setTimeout'任务即将出队列,判断是否有微任务队列,无
  • 判断宏任务队列是否为空,是,结束。
来点关于promise执行的知识
1

Promise中只有涉及到状态变化后才需要被执行的回调才打算是微任务,比如说then,catch,finally,其他所有代码都是同步执行(宏任务)

Image4.png

上图中蓝色为同步执行,黄色为异步执行(丢到微任务队列中)。

2
Promise.resolve()
  .then(() => {
    console.log("then1");
    Promise.resolve().then(() => {
      console.log("then1-1");
    });
  })
  .then(() => {
    console.log("then2");
  });

对于上面的代码,输出的是then1, then1-1, then2。

虽然then是同步执行,并且状态已经变更,这不代表每次遇到then我们都需要把它的回调丢进微任务队列,而是等待then回调执行完毕后在根据情况执行对应操作。

所以:在链式调用中,只有前一个then执行完,跟着后面的then的回调才会被加入到任务队列

3

首先是这样

let p = Promise.resolve();

p.then(() => {
  console.log("then1");
  Promise.resolve().then(() => {
    console.log("then1-1");
  });
}).then(() => {
  console.log("then1-2");
});

p.then(() => {
  console.log("then2");
}); 

打印的是then1, then1-1, then2, then1-2,为什么then2会在then1-2钱输出呢?因为每个链式调用的开端会首先依次进入微任务队列。 所以存在同级then,前面的会依次先输出。

let p = Promise.resolve().then(() => {
  console.log("then1");
  Promise.resolve().then(() => {
    console.log("then1-1");
  });
}).then(() => {
  console.log("then2");
});

p.then(() => {
  console.log("then3");
});

输出then1, then1-1, then2, then3

其实这里有个陷阱,因为每个then都会返回一个新的promise,所以p已经不是promise.resolve生成的了,而是最后一个then生成的,所以then3应该在then2后面输出。

前面的结论也可以优化为:同一个 Promise 的每个链式调用的开端会首先依次进入微任务队列。