详解浏览器中的事件循环

734 阅读5分钟

浏览器中的事件循环

开门见山的说

无论在什么环境当中,javascript都是单线程的。不管是什么新框架新语法糖实现的所谓异步,其实都是用同步的方法去模拟的。而事件循环是js实现异步的一种方法,也是js的执行机制。

事件循环在浏览器中的表现和在node.js环境下的表现不同。

浏览器中的事件循环

同步任务与异步任务

在浏览器中,每一句js都是一个任务,任务分为两种——同步任务和异步任务。这两种任务会各自进入不同的执行场所。同步任务进入主线程依次执行;异步任务进入Event Table。只要异步任务有了运行结果,就会将回调函数放入Event Queue。一旦主线程中所有任务执行完毕,就会将任务队列中的回调函数移到主线程,形成新的执行栈并开始执行。

如下图:

宏任务macrotasks和微任务microtasks

在宏观上,浏览器又有一个不同的任务概念——宏任务与微任务。同步异步任务是相对于js语句而言,而宏任务与微任务则范围更大。在js中,他们通常指的是代码块。

Macrotasks包含生成dom对象、解析HTML、执行主线程js代码、更改当前URL还有其他的一些事件如页面加载、输入、网络事件和定时器事件。从浏览器的角度来看,macrotask代表一些离散的独立的工作。当执行完一个task后,浏览器可以继续其他的工作如页面重渲染和垃圾回收。
Microtasks则是完成一些更新应用程序状态的较小任务,如处理promise的回调和DOM的修改,这些任务在浏览器重渲染前执行。Microtask应该以异步的方式尽快执行,其开销比执行一个新的macrotask要小。Microtasks使得我们可以在UI重渲染之前执行某些任务,从而避免了不必要的UI渲染,这些渲染可能导致显示的应用程序状态不一致。

在浏览器中,任务队列分为宏任务队列和微任务队列。而在同一时间内,只执行一个任务。并且任务一直执行到完成,不能被其他任务抢断。

macrotask(宏任务):整体script,setInterval, setTimeout,UI 渲染、 I/O、postMessage、 MessageChannel、setImmediate(Node.js 环境)
microtask(微任务):promise,mutation observable,process.nextTick(Node.js 环境)

如下图:

在浏览器环境中,事件循环的大致顺序为:

  • 检查宏任务队列,如果有等待执行的宏任务,那么执行该任务。在宏任务执行过程中,遇到同步任务丢进主线程执行。遇到异步任务,则丢进Event Table等待完成。一旦异步任务完成,其中的回调函数将按照宏任务和微任务的划分进入不同的任务队列中等待执行。

  • 检查微任务队列,将所有的微任务逐条执行完毕。

  • 执行中最靠前的一条宏任务。

  • 循环上述步骤。

直接看代码

先来看一个简单的例子:

console.log(1)
setTimeout( function () {
  console.log(2)
}, 0)
console.log(3)
// 1
// 3
// 2

让我们一步步分析:

  • 按照事件循环顺序,先执行第一个macrotask(整体script代码),遇到console.log(1)打印出1

    宏任务 微任务
  • 遇到setTimeout,将其中的回调函数丢到下一个macrotask。

    宏任务 微任务
    回调函数
  • 遇到console.log(3),打印出3

    宏任务 微任务
    回调函数
    至此,代码执行完毕,即第一个macrotask执行完。引擎开始检查microtask是否有任务。这里我们的microtask里面也为空,那么开始执行下一个macrotask内的任务。
  • 4ms后(HTML标准中setTimeout最低为4ms)执行回调函数,打印2,代码执行完毕。

    宏任务 微任务

至此,所有代码执行完毕。

看看两种类型任务混合起来的例子:

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

new Promise(function(resolve) {
  console.log('2');
  resolve()
}).then(function() {
  console.log('3');
})

console.log('4');
// 2
// 4
// 3
// 1

还是一步步分析,为了方便我们给所有console编号:

  • 执行整体script,遇到setTimeout,将回调丢到macrotask。

    宏任务 微任务
    回调函数1
  • 遇到new Promise执行内部的创建函数,打印出2。同时将then中的回调函数丢到microtask。

    宏任务 微任务
    回调函数1 回调函数3
  • 遇到末尾的console打印出4

    宏任务 微任务
    回调函数1 回调函数3
    此时开始到下一步,发现microtask中有任务,执行microtask内部任务。
  • 执行回调函数3,打印出3

    宏任务 微任务
    回调函数1
    microtask队列清空,执行下一个macrotask任务。
  • 执行回调函数1,打印出1

    宏任务 微任务

至此,所有代码执行完毕。

即使是互相嵌套,也依然遵守事件循环的规则:

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

new Promise(function(resolve) {
  setTimeout(function() {
    console.log('2');
  }, 0)
  resolve()
}).then(function() {
  console.log('3');
})

console.log('4');
// 4
// 3
// 1
// 2

解析:

  • 执行整体script,遇到setTimeout,将回调丢到macrotask。

    宏任务 微任务
    回调函数1
  • 遇到new Promise执行内部的创建函数,遇到setTimeout,将回调丢到macrotask。同时将then中的回调函数丢到microtask。

    宏任务 微任务
    回调函数1 回调函数3
    回调函数2
  • 遇到末尾的console打印出4

    宏任务 微任务
    回调函数1 回调函数3
    回调函数2
    此时开始到下一步,发现microtask中有任务,执行microtask内部任务。
  • 执行回调函数3,打印出3

    宏任务 微任务
    回调函数1
    回调函数2
    microtask队列清空,执行下一个macrotask任务。
  • 执行回调函数1,打印出1

    宏任务 微任务
    回调函数2
  • 执行回调函数2,打印出2

    宏任务 微任务

至此,所有代码执行完毕。

最后再来分析一段较复杂的代码,看看你是否真的掌握了js的执行机制:

console.log('1');

setTimeout(function() {
  console.log('2');
  new Promise(function(resolve) {
    console.log('3');
    resolve();
  }).then(function() {
    console.log('4')
  })
}, 0)
new Promise(function(resolve) {
  console.log('5');
  resolve();
}).then(function() {
  console.log('6')
})

new Promise(function(resolve) {
  setTimeout(function() {
    console.log('7');
  }, 0)
  resolve()
}).then(function() {
  console.log('8');
})
// 1
// 5
// 6
// 8
// 2
// 3
// 4
// 7