一篇文章搞懂浏览器Js事件循环机制

浏览器事件循环机制

前言

在初次入门学习和使用 JavaScript 的过程中,相信遇到过许多程序执行顺序及结果与预期不一致的问题,在查阅资料的过程中了解到原来是程序的执行有同步与异步之分;与此同时也会看到许多有关概念,例如回调函数、执行栈、任务队列、事件循环机制(Event Loop)、宏任务、微任务、Promise(ES6)等等。此时对于一个刚入门不久的小白来说,要理解消化这些概念真的不容易。对于入门不久的我来说也一样,所以写一篇博客记录一下,有关 JavaScript 的运行机制,以及上述的这些概念为什么会出现,又解决了什么问题。

一、JavaScript 是单线程

我们知道多线程是可以并行执行程序的,能提高程序运行效率。但是 JS 是一门单线程语言,同一时间内做一件事。

最初作为服务于浏览器的脚本语言,很多时候都是在与用户交互,这个过程涉及了许多 DOM 的操作,倘若使用多线程,那么就容易出现几个线程同时操作一个 DOM 的问题,那么浏览器此时要以哪一个线程为主呢?这样一来无疑增加了复杂性,所以 JS 成为了单线程。虽然说多线程处理起来也很高效,但对于当时直接服务于浏览器用户的 JS 来说,尽可能避免过度复杂,能更简单的处理相对好点吧。

二、异步任务及其回调函数

虽然单线程降低了复杂性,但是也有了新的问题。单线程是顺序执行程序,每一个任务要等待上一个任务执行完毕才执行,如果遇到执行时间太长或者出现了别的问题,那么就会一直卡在那,导致整个程序无法顺利执行完毕。为了解决问题,语言设计者希望在程序执行时,将一些耗时、有延迟的任务先挂起,让能快速执行完毕的任务先执行;按照这样的方式执行完整个程序后,在返回去执行那些被挂起的任务。因此有了同步任务与异步任务之分;在执行过程中,当前执行程序的线程称为主线程,同步任务直接在主线程立即执行,而那些异步任务,先给它挂在一边放着,等到主线程执行完了所有同步任务,再回来读取挂在一旁的异步任务,并且执行他们。

(1) 任务队列

任务队列是一系列事件组成的一个队列,也就是上面说到的异步任务挂起的地方。程序执行时会将定义的异步任务送入任务队列,或者用户点击鼠标触发的异步任务送入队列。等待主线程来执行它们。例如常见的各种事件(鼠标点击、键盘敲击、滚动等等)、又或者是 Ajax 那样等待响应的异步任务。

实际上,任务队列不止一种,因为处理的异步任务种类可能不同

(2) 回调函数 (callback)

回调函数往往就是异步任务所定义的代码。主线程执行完同步任务,就会回来开始读取任务队列中的异步任务并执行这些代码,同时也称为回调函数。

(3) 宏任务和微任务

异步任务又可以看为两种,通常由宿主环境(浏览器、node)提供的为宏任务,由语言标准提供的为微任务。 JavaScript 可能会在不同的宿主环境下运行,所以宏任务来自于宿主环境,而微任务作为语言标准,在任何环境下都可以使用。

常见宏任务

  • setTimeout
  • setInterval
  • setImmediate (仅 node 提供)
  • requestAnimationFrame (仅浏览器提供)
  • 各种交互 (鼠标点击、滚动等等)
  • I/O

常见微任务

  • Promise.then catch finally
  • MutationObserver (仅浏览器提供)
  • process.nextTick (仅 node 提供)

三、事件循环机制 (Event Loop)

主线程执行程序时会将定义的异步任务放入任务队列中,宏任务会放在宏任务队列,微任务放在微任务队列,当触发 UI 事件时,也会把相应任务放入队列。为了确保事件处理正常进行,主线程不阻塞。所以有了解决方案 Event Loop,事件循环线程是独立于主线程的,并且一直存在直到整个脚本环境被关闭。无论是主线程执行时添加的异步任务,还是 UI 交互触发后添加的异步任务,事件循环机制都会按一定规则循环读取并且执行。

那么该循环机制如何运行呢?

  • (1) 打开某个宿主环境时,主线程执行同步任务的所有代码,形成一个执行栈;把遇到的异步任务放入相应的队列里;同时一个独立于主线程的事件循环线程也被创建并一直存在。

  • (2) 当主线程执行完同步任务,会将该执行过程中添加的微任务全部执行完,之后由事件循环机制协调。

  • (3) 事件循环读取当前宏任务队列的一个宏任务,并放入执行栈中执行

  • (4) 在执行过程中遇到宏任务和微任务,按照相同的方式放入相应队列

  • (5) 该宏任务执行完毕后立即执行此次宏任务中所添加的所有微任务

  • (6) 回到第 (3) 步开始重复后面步骤。

  • 说那么多,看个例子

console.log('1-1');

Promise.resolve().then(() => console.log('微任务 1-1'));

new Promise((resolve) => {
  console.log('1-2');
  resolve();
}).then(() => {
  console.log('微任务 1-2')
});

setTimeout(() => console.log('宏任务 1-1'), 100);

console.log('1-3');
//1-1
//1-2
//1-3
//微任务 1-1
//微任务 1-2
//宏任务 1-1
  • 主线程开始执行,形成一个执行栈

  • 碰到第一个 console.log('1-1'),并打印 -> 1-1

  • 碰到第一个 Promise,已为成功状态,将其 then() 加到微任务中

  • 碰到第二个 Promise,先执行其中的 console.log('1-2'),打印 -> 1-2,并将其 then() 放入微任务队列

  • 碰到第一个宏任务,放入宏任务队列

  • 碰到 console.log('1-3'),打印 -> 1-3

  • 主线程执行完所有同步任务,开始执行本次添加的所有微任务

  • 读取微任务队列

  • 遇到先进去的第一个 then() ,打印 -> 微任务 1-1

  • 遇到后进去的 then() 打印 -> 微任务 1-2

  • 本次主线程任务完成,下面由事件循环机制来协调。开始读取宏任务队列

  • 遇到第一个放入的宏任务 setTimeout(),将其丢到执行栈延时 100ms 执行,打印 -> 宏任务 1-1

  • 第一次宏任务执行完毕,读取微任务队列,发现没有微任务。进入第二次循环

  • 读取宏任务队列,发现没有宏任务。JS 执行栈开始摸鱼...

到这里其实会发现,微任务都会紧跟在当前执行栈执行同步任务后执行,而存好的宏任务被放在下次执行,好似重新开始一样。

按个人总结来就是(不一定对),主线程的执行栈是专门用来执行代码的;当事件循环线程读取到一个宏任务时,将其放入执行栈执行,主线程会执行其中定义的同步任务,将遇到的宏任务和微任务存起来,在本次同步任务执行完之后立即执行微任务。而此次存好的宏任务又会按照相同的方式在下一次循环中进行。因为事件循环机制一次循环只读取执行一个宏任务。

由此看来其实整个程序也可以看成是一个宏任务,而首次添加的宏任务和微任务是按照上面的方式一层层刨开,按照一次执行一个宏任务和里面所有微任务的规则进行

  • 再看个例子说明宏任务是一次循环读取一次,并且会执行宏任务下所有微任务
console.log('开始执行主线程');
console.log('0-1');

Promise.resolve().then(() => console.log('微任务 0-1\n-----'));

setTimeout(() => {//宏任务 1
  console.log('第一个宏任务');
  console.log('宏任务 1-1');
  Promise.resolve().then(() => console.log('微任务 1-1'));
  Promise.resolve().then(() => console.log('微任务 1-2\n-----'));

  setTimeout(() => {//宏任务3
    console.log('第三个宏任务');
    console.log('宏任务 3-1')
    Promise.resolve().then(() => console.log('微任务 3-1\n-----'))
  },10);

},100);

setTimeout(() => {//宏任务2
  console.log('第二个宏任务');
  console.log('宏任务2-1');
  Promise.resolve().then(() => console.log('微任务 2-1\n-----'));
},100);

console.log('0-2');

***************************

执行结果

开始执行主线程
0-1
0-2
微任务 0-1
-----
第一个宏任务
宏任务 1-1
微任务 1-1
微任务 1-2
-----
第二个宏任务
宏任务 2-1
微任务 2-1
-----
第三个宏任务
宏任务 3-1
微任务 3-1
-----
  • 开始执行主线程后,将 微任务 0-1 、 宏任务1 、 宏任务2 存入队列,并先打印其同步任务代码,又打印微任务代码
  • 开始第一次事件循环,读取宏任务1(第一个定时),将 微任务 1-1 、微任务 1-2、和宏任务3 存入队列。打印方式如上一条。
  • 开始第二次事件循环,读取宏任务2(第二个定时),将 微任务 2-1 存入队列,打印方式如上。
  • 开始第三次事件循环,读取宏任务队列中最后一个进去的宏任务3(宏任务1中定义的定时器),将 微任务 3-1 存入队列,打印方式如上。

大概流程图

流程图

提示,虽然说是一次循环只读取一个宏任务,但是他没说要等当前宏任务执行完才进行下一次循环哦!!,事件循环读取到队列中的任务并且让它开始执行后,就可以开始下次循环,不需要等待

  • 下面改动的例子,留给自己做练习吧
console.log(1);

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

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

  setTimeout(() => {
    console.log(5);
  },10);

},200);

setTimeout(() => {
  console.log(6);
  Promise.resolve().then(() => console.log(7));
  setTimeout(() => {
    console.log(8)
  }, 300);
},100);

console.log(9);

自己在纸上写了一下,将代码在浏览器上运行之后对比,发现完全正确。你也可以自己写一下哦。

2020/9/22 更新

有一种情况,那就是 then() 之后接着 then() ,那么此时的顺序呢?

console.log('1-1');

Promise.resolve().then(() => console.log('微任务 1-1')).then(() => console.log('微任务 1-3'));

new Promise((resolve) => {
  console.log('1-2');
  resolve();
}).then(() => {
  console.log('微任务 1-2')
}).then(() => console.log('微任务 1-4'));

setTimeout(() => console.log('宏任务 1-1'), 100);

//1-1
//1-2
//1-3
//微任务 1-1
//微任务 1-2
//微任务 1-3
//微任务 1-4
//宏任务 1-1

可以看到他的运行顺序,说明在 then() 执行之后,如果后面还接着 then() 那么按照同样的方式添加到微任务队列,等到之前添加的第一层 then() 都执行完后,在到微任务队列里面读取后面添加的 then(),运行方式如上。并且只有当微任务队列为空时,事件循环机制才会进行到下一轮并读取新的宏任务。

参考链接

阮一峰的网络日志 JavaScript 运行机制详解:再谈Event Loop

知乎作者:tigerHee js中的宏任务与微任务

博客园作者:daisy,gogogo JavaScipt 中的事件循环 event loop,以及微任务和宏任务的概念

国外作者写的一篇文章 Tasks, microtasks, queues and schedules

本文为原创文章,若文章内容出现抄袭雷同,请联系文章发布人或者网站管理员,我们将认真核实并及时删除。 除非另有说明,否则此博客中的所有文章均根据CC BY-NC-SA 4.0许可。如需转载请标明出处,谢谢配合!

END--感谢阅读

来发表你的感想吧~