高级前端进阶,你了解事件循环吗?

849 阅读7分钟

前言:

无论在工作中,还是在面试题中,event loop(事件循环)都十分重要,浏览器与nodejs中事件循环略有差异,本文只讨论浏览器中的事件循环,nodejs以后再单独写一篇。由于涉及到的点会比较多,可能显得比较啰嗦,请大家选择性观看,如果。

为什么需要事件循环

大家都知道JavaScript是单线程脚本语言,同一时间只能做一件事。而JavaScript中又存在异步http请求(ajax)、定时器、事件绑定等等的方法,如何检测异步请求是否完成、如何检测定时器时间、如何监听事件触发?这时候就需要浏览器的帮助了,浏览器是多线程的,这些异步或者事件触发亦或者定时器触发后浏览器会将相应的回调方法加入到任务队列中,等待JavaScript调用,而JavaScript循环的从任务队列中调用方法,我们就称之为事件循环。

浏览器有哪些线程

  1. GUI渲染线程
  • 负责渲染页面,例如解析html,css,布局并渲染页面
  1. js引擎线程
  • 最出名的就是我们熟知的V8引擎,解析并执行js
  • 当执行栈为空时会将任务队列头部的任务放入执行栈中执行
  1. 事件触发线程
  • 事件绑定(鼠标点击、页面滚动等事件)会将任务注册到时间触发线程中,待相应的事件触发时会将相应的回调放入任务队列中
  1. 定时器触发线程
  • 定时器计时,当定时器时间到时,会将回调函数放入任务队列。
  1. 异步请求线程
  • 浏览器会新开一个线程进行请求,当检测到请求状态发生变化时,如果设置了回调函数,会将此函数放入任务队列中。

JavaScript是如何执行的

JavaScript并不是一行一行的分析并执行代码的,而是分段一段一段的进行分析并执行。

这里要说明一下,怎么才算一段。这里的段指的是JavaScript中的执行上下文。

执行上下文分为三种

  • 全局执行上下文
    • 这里指的就是全局代码
  • 函数执行上下文
    • 这里指的是一个一个函数,一个函数执行前会先创建一个执行上下文
  • eval执行上下文
    • eval不推荐使用,这里不做过多说明

由此可见当JavaScript运行时会有很多执行上下文,为了方便管理,js引擎会创建一个执行上下文栈来对这些执行上下文进行管理,遵循先进后出原则。

全局上下文在栈的最底部,全局上下文只有一个在浏览器关闭时出栈。

函数在执行时会创建函数执行上下文,并放入执行栈中执行,执行完毕后出栈。

event loop 循环机制

当JavaScript执行时,会将全局执行上下文放入执行栈中,接下来遇到函数执行上下文时会将这个上下文也放入执行栈中,执行完毕会出栈,当执行栈为空时,会从任务队列头部拿取一个任务,创建上下文并放入执行栈中执行。每当执行栈为空时总会循环的从任务队列获取任务,并创建执行上下文放入执行栈执行。这个循环我们称之为事件循环。

任务队列的分类

任务队列分为两种,一种叫宏任务(macrotask),一种叫微任务(microtask),这也是本文的重点。

接下来看看都有哪些属于宏任务,哪些属于微任务。

宏任务script( 整体代码)、setTimeoutsetIntervalI/O(http请求)、UI 渲染

微任务Promise.then()MutationObserver(监听dom的更改)、Object.observer(这玩意已经废弃了,可能有些文章还有提到这个)

new Promise(()=>{}),这个匿名函数内的代码数据同步代码,会立即执行

执行原则

执行完一个宏任务回去检测微任务队列是否为空,如果不为空则执行完队列内的所有微任务,如果队列为空,则继续执行下一个宏任务,下一个宏任务执行完后会继续检测微任务队列是否为空,往复循环。

接下来利用示例来加深理解

示例1:
console.log(1)
setTimeout(()=>{
	console.log(2)
})
console.log(3)
//输出
//1
//3
//2
解析:
  • 打印1
  • setTimeout交由浏览器定时器线程,时间到了后会将回调函数放入宏任务队列
  • 打印3
  • 微任务队列为空,从宏任务队列取一个任务
  • 打印2
示例2:
console.log(1)
new Promise((resolve)=>{
	console.log(2)
	resolve()
}).then(()=>{
	console.log(3)
}).then(()=>{
	console.log(4)
})

setTimeout(()=>{
	console.log(5)
})
console.log(6)

//输出
//1
//2
//6
//3
//4
//5
解析:
  • 打印1
  • 执行new Promise(),打印2
  • 将第一个then放入微任务队列
  • setTimeout交由定时器线程,时间到后将回调放入宏任务
  • 打印6
  • 同步任务执行完毕,查询微任务列表,从列表取出一个任务
  • 打印3,同时将第二个then放入微任务队列
  • 查询微任务队列,取出一个任务
  • 打印4
  • 查询微任务队列,发现为空,查询宏任务队列
  • 取出一个宏任务,打印5
示例3:
console.log(1)
new Promise((resolve)=>{  ①
	console.log(2)
	resolve()
}).then(()=>{             ②
	console.log(3)
}).then(()=>{             ③
	console.log(4)
})
new Promise((resolve)=>{  ④
	console.log(5)
	resolve()
}).then(()=>{             ⑤
	console.log(6)
}).then(()=>{             ⑥
	console.log(7)
})
setTimeout(()=>{          ⑦
	console.log(8)
})
console.log(9)
//输出
//1
//2
//5
//9
//3
//6
//4
//7
//8
解析:
  • 打印1
  • 执行①,打印2 ,将②放入微任务。当前微任务 [②]
  • 执行④,打印5,将⑤放入微任务。当前微任务[②,⑤]
  • 将setTimeout放入宏任务(此处略去定时器线程的操作,下同)。当前宏任务[⑦]
  • 打印9
  • 执行②,打印3,将③放入微任务。当前微任务[⑤,③]
  • 取出⑤,打印6,将⑥放入微任务。当前微任务[③,⑥]
  • 取出③,打印4,
  • 取出⑥,打印7
  • 微任务执行完毕,取出⑦
  • 打印8
示例4:

接下来我们在示例中加入asyncawait

await后的方法会立即执行,await下面的本作用域代码会加入到微任务队列

console.log(1)
new Promise((resolve)=>{       ①
	console.log(2)
	resolve()
}).then(()=>{                  ②
	console.log(3)
}).then(()=>{                  ③
	console.log(4)
})
test()
setTimeout(()=>{               ④
	console.log(8)
})
console.log(9)
async function test(){
	await test1();
	console.log(10)            ⑤
}
function test1(){
	console.log(11)
}
//输出
//1
//2
//11
//9
//3
//10
//4
//8
解析:
  • 打印1
  • 执行①内的代码,打印2
  • 将②放入微任务队列。微任务队列[②]
  • 执行test方法,进入test1,打印11
  • 将⑤放入微任务队列。微任务队列[②,⑤]
  • 将setTimeout放入宏任务队列。宏任务[④]
  • 打印9
  • 同步任务执行完毕,查询微任务队列
  • 取出②,打印3
  • 取出⑤,打印10,将③放入微任务队列。微任务队列[③]
  • 取出③,打印4
  • 微任务执行完毕,查询宏任务
  • 取出宏任务内的④,打印8
示例5:
async function test1() {
    console.log('1');
    await test2();
    console.log('2');           ⑥
}
async function test2() {
    console.log('3');
    await test3()
    console.log(4)              ⑤
}
async function test3() {
    console.log('5');
    await test4()
    console.log(6)              ④
}
async function test4() {
    await console.log('7');
    console.log(8)              ①
}
console.log(9);
setTimeout(function() {         ②
    console.log(10);
}, 0)
test1();
new Promise(function(resolve) {
    console.log(11);
    resolve();
}).then(function() {
    console.log(12);            ③
});
console.log(13);
//输出
//9
//1
//3
//5
//7
//11
//13
//8
//12
//6
//4
//2
//10
解析:
  • 打印9
  • 执行test1,打印1
  • 进入test2, 打印3
  • 进入test3,打印5
  • 进入test4,打印7
  • 将①加入微任务队列。微任务队列[①]
  • 继续向下执行,将②加入宏任务队列。宏任务队列[②]
  • 执行new Promise,打印11,将③加入微任务队列。微任务队列[①,③]
  • 打印13
  • 同步任务结束,开始执行微任务队列,取出①
  • 打印8
  • 取出微任务③,打印12
  • 微任务执行完毕
  • test4方法执行完毕,进行出栈,将④加入微任务队列。微任务队列[④]
  • 取出④,打印6
  • test3出栈,将⑤加入微任务队列。微任务队列[⑤]
  • 取出⑤,打印4
  • test2出栈,将⑥加入微任务队列。微任务队列[⑥]
  • 取出⑥,打印2,test1出栈
  • 微任务队列为空,执行宏任务
  • 取出②
  • 打印10

结尾:

浏览器的事件循环,到这里基本结束了,如果大家觉得掌握的差不多了可以用下面的例子来测测自己对事件循环的理解程度。

示例6:
async function test1() {
  console.log('1');
  await test2();
  console.log('2');
}
async function test2() {
  console.log('3');
  await test3()
  console.log(4)
}
async function test3() {
  console.log('5');
  await test4()
  console.log(6)
}
async function test4() {
  await console.log('7');
  console.log(8)
}
console.log(9);
setTimeout(function() {
  console.log(10);
}, 0)
test1();
new Promise(function(resolve) {
  console.log(11);
  resolve();
}).then(function() {
  console.log(12);
}).then(function() {
  console.log(14);
}).then(function() {
  console.log(15);
});
console.log(13);