JS事件循环(EventLoop)

688 阅读6分钟

EventLoop

JS的事件循环系统叫EventLoop,可以叫页面循环系统。是JavaScript代码的一种执行机制。是融入在我们日常开发中的。虽然我们天天在写bug,但理解EventLoop能让我们不写那么多的bug,也能让我们的代码更健壮、更合理。

很多人把JavaScript说成是单线程的,虽然是可以这样说,但个人认为描述不是很准确。JavaScript应该是单线程执行的。

现在我们所使用的浏览器大多数都是多进程架构的浏览器。就拿我们经常使用的Chrome浏览器来说。启动浏览器最少都会开启四个进程,它们分别是:浏览器进程渲染进程网络进程CPU进程。如果有插件则还有插件进程。每个进程都会有个主线程,页面的渲染和JavaScript的执行都是在渲染进程的主线程上执行的,所以说JavaScript是单线程执行的更为恰当一点。

由于JavaScript是运行在渲染进程的主线程中的,而主线程是相当繁忙的,且在代码运行的过程中会有其他任务的产生,所以为了能够执行在代码运行中产生的任务,就需要采用事件循环机制;同时还要接收和保存其他线程产生的任务就需要引入消息队列(队列特性:先进先出),以此构成JS的事件循环系统。简单实现如下:

let queue = [...] // 任务队列
while (true) {
	let task = queue.shift()
    if (task) task()
}

事件循环机制文字表述如下:

  • 所有的JavaScript代码都会在主线程执行,当代码执行过程中如果产生了任务,则任务会被放入队列末尾。
  • 当主线程的代码执行完,会从任务队列取出任务,后进入主线程执行。
  • 任务执行完又会从队列中取,以此不断循环。

先看个简单的示例代码:

console.log('a')
setTimeout(() => {
    console.log('b')
}, 0)
console.log('c')
// 输出 a c b

以上代码中setTimeout会产生一个任务,这个任务会放入任务队列中。所以console.log('b')这段代码要等主线程的其它代码执行完才回进入主线程执行。因此输出: a c b。

在我们开发过程中显然不会有这么简单的代码,在理解复杂的代码环境前,还需要明白任务也是有优先级的。以上说的消息队列里的任务都是宏任务,除了宏任务还有之外还有个叫微任务的东西。

首先为什么要有微任务呢?因为JavaScript是单线程的,且使用消息队列来处理任务。然后有些任务需要较高的优先级,即时效性。如果放入消息队列则不能保证任务的时效性,如果不放如队列,让任务在主线程中执行又会影响代码的执行效率。所以为了权衡这两方面的需要,JavaScript引入了微任务。微任务依赖于宏任务,每个宏任务都会维护一个微任务队列,在宏任务执行完前,会检查微任务队列中是否有待执行的任务,有则执行微任务队列里面的任务,没有则执行下个宏任务。

setTimeout(() => {
    console.log(1)
    setTimeout(() => {
    	console.log(4)
    },200)
},0)
console.log(2)
setTimeout(() => {
	console.log(7)
}, 200)
new Promise((resolve, reject) => {
    console.log(5)
    resolve()
}).then(() => {
    console.log(6)
})
console.log(3)
// 输出 2 5 3 6 1 7 4

以上代码中,由于setTimeout产生的是宏任务,而Promise.resolve() 产生的是微任务。我们先前讲的在执行下个宏任务之前会执行当前任务的微任务队列,所以6会在 1 4 7之前输出。你可能会问为什么7会在4之前输出呢?那是因为同时间间隔下7的回调先进入了队列。

宏任务

  • 渲染事件(如DOM解析、计算布局、绘制)
  • 用户交互事件(如滚动页面、放大缩小等)
  • JavaScript 脚本执行事件
  • 网络请求完成、文件读写完成事件

常见的宏任务(macrotask)有:

  • 事件回调
  • XHR 回调
  • IndexDB 数据库操作等 I/O
  • setTimeout / setInterval / setImmediate(ps: setImmediate有兼容性问题)
  • MessageChannel
  • history.back

常见的微任务(microtask)有:

  • Promise.resolve()/Promise.reject()
  • MutationObserver
  • Object.observe

setTimeout

上一段代码中,7会在4之前输出,那是因为他们时间间隔设置相同,如果不同呢?如果把输出4的setTimeout的时间间隔设置成0结果将会不一样。为什么会出现这种情况呢?不是7的setTimeout回调先进入队列吗?对于其他宏任务可能不会有问题,但JavaScript引擎对setTimeout的处理是有区别。

我们知道setTimeout是用来设置延时的,如果把它的任务放入消息队列就不能保证回调在这个时间间隔执行了。所以JavaScript又维护了一个延迟执行队列。这个队列就是专门用于存放延时任务的。那延迟队列的任务又是什么时候执行的呢?就是当消息队列的一个任务执行完后,会检查延时队列里是否有已经到期的任务,有则执行。执行完之后才进入下个事件循环。

let queue = [...] // 任务队列
while (true) {
	let task = queue.shift()
    if (task) task()
    // 延时队列
    let delayQueueTask = delayQueue.get()
    if (delayQueueTask) delayQueueTask()
}

所以从这里可以看出setTimeout的一个问题:如果消息队列的任务执行时间过长,那么当setTimeout回调执行时可能已经超过了它所设置的时间间隔。

setTimeout的另一个注意点就是:时间间隔设置为0是不起作用的,setTimeout一般最短的时间间隔是4毫秒,然而未激活的页面,setTimeout 执行最小间隔是 1000 毫秒。如果超过最大时间间隔(32bit 最大只能存放的数字是 2147483647 毫秒,大约 24.8 天,),值溢出时,延迟时间会被重置为0

requestAnimationFrame和setTimeout比较

  • requestAnimationFrame 提供一个原生的API去执行动画的效果,它会在一帧(一般是16ms)间隔内根据选择浏览器情况去执行相关动作。(raf是按照系统刷新的节奏调用的)
  • setTimeout是在特定的时间间隔去执行任务,不到时间间隔不会去执行,这样浏览器就没有办法去自动优化。

学习资料:《浏览器工作原理与实践》

其它资料:

JS事件循环机制(event loop)之宏任务/微任务

[译]Tasks, microtasks, queues and schedules

深入探究 eventloop 与浏览器渲染的时序问题

github.com/YvetteLau/B…