likes
comments
collection

从JS的单线程特性说起,带你超快理解JavaScript中的事件循环机制(Event Loop)

作者站长头像
站长
· 阅读数 12

前言

JavaScript作为一门单线程的语言,但却拥有高效的性能与优秀的异步解决方案。这是由于JS引入了事件循环机制(Event Loop)。事件循环是一个非常底层的概念,但它极其重要——掌握它能让你理解浏览器页面到底是如何运行的,从而写出高性能的代码。它也是前端面试常考的题目,了解它对前端工程师是一堂必修课。基于此我决定写下这篇文章来系统地梳理相关知识。本篇旨在用最小的学习成本快速理解事件循环机制,因此比较通俗易懂,适合新手进阶的入门,欢迎大家来一起探讨呀~

首先我们要先了解渲染进程,即浏览器内核,它是事件循环的基础。

浏览器的多进程架构与渲染线程

为了提升浏览器的稳定性、流畅性和安全性,现代浏览器的架构为多进程架构,它由浏览器主进程、GPU 进程、网络进程、多个渲染进程和多个插件进程组成。

从JS的单线程特性说起,带你超快理解JavaScript中的事件循环机制(Event Loop)

其中渲染进程的核心任务是解析 HTML、CSS 和 JavaScript ,绘制页面、执行JS脚本,处理事件等,从而展现出用户可以与之交互的网页。浏览器中的每个 Tab 都是一个渲染进程。每个渲染进程内部都拥有一个主线程,主线程任务繁杂,包括处理 DOM,计算样式、布局,解析执行JavaScript脚本,处理各种输入事件,控制事件循环等。

如此多不同类型的任务在主线程中执行,为了防止任务之间打架和阻塞,自然需要建立一个机制来统筹调度,这个统筹调度机制就是这篇文章的核心——事件循环机制。为了更有逻辑的讲解事件循环机制,接下来我结合场景一步步说明。

为什么JS作为单线程语言还拥有异步操作能力

众所周知大多数编程语言都是多线程,如Java,C++。那我们思考一个问题,为什么JS是门单线程语言?这主要是因为JS的设计初衷就是创造一门支持表单脚本的轻量化的浏览器脚本语言,而JS拥有操作DOM的功能,如果JS是多线程语言,那么当它们同时对页面同一个元素进行操作的话势必要建立一套复杂的优先级体系来区分究竟谁更有资格操作DOM。因此在早期对 JavaScript 定位本身就是一门简单的浏览器脚本语言的情况下单线程无疑是最简单且有效的解决方案。

注:虽然在后面的 JavaScript 语言标准中引入了 Worker 工作线程但是其主要是做一些CPU密集型计算任务,对于DOM的操作依然是通过线程间通信交给主线程来做,因此 JavaScript 单线程语言的本质并没有改变。

那么JS作为一门单线程语言是如何执行异步操作的呢?比如 setTimeout 和 setInterval 的实现原理是什么?要回答这些问题就需要引入 JavaScript 事件执行机制的核心——任务队列。

任务队列

任务队列是一种数据结构,可以存放要执行的任务。下面我们结合代码来理解这个概念。

function single() {
     num1 = 1+1; //任务1  
     num2 = 1+2; //任务2
     num3 = 1+3; //任务3  
     console.log(num1,num2,num3);
}
single();

在上面的执行代码中,任务代码是按照顺序写进主线程里,按照顺序在线程中依次被执行;等所有任务执行完成之后,线程就自动退出。如图所示:

从JS的单线程特性说起,带你超快理解JavaScript中的事件循环机制(Event Loop)

那如果有突发事件需要紧急处理呢?如果有其他线程发来的消息需要接收呢?

我们就需要引进事件循环机制。任务队列符合队列的特点(但它不是队列,而是一种集合,因为事件循环处理模型从所选队列中获取第一个可运行任务,而不是使第一个任务出队):先进先出。我们添加一个任务队列,我们之前写的任务代码会按照顺序添加进任务队列中,与此同时IO线程把通过IPC接收到的其他进程传进来的消息组装成新任务添加进任务队列尾部,例如鼠标点击事件、网络请求等。

事件循环机制则被引入进渲染主线程中,它会循环地从任务队列头部中读取任务,依次执行任务。如图所示:

从JS的单线程特性说起,带你超快理解JavaScript中的事件循环机制(Event Loop)

你也许发现了,如果现在有个非常紧急的任务,但我们按照这个模型也只能把它加入到任务队列尾部等待执行。这样子会影响到任务的实时性。因此我们创造了微任务。它有效的弥补了实时性的缺点。

微任务与宏任务

有微任务,相对的就会有宏任务。

宏任务

宏任务的定义

宏任务即是任务队列中的任务。也可以定义为由宿主环境(如浏览器及浏览器)发起的任务。如前图所示,这些任务主要包括渲染事件、用户交互事件(如鼠标点击等)、JavaScript 脚本执行事件、资源加载完成事件。每个宏任务中都包含一个微任务队列。

宏任务的执行流程

主线程中其实是存在多个任务队列的。浏览器会将oldestTask(接下来会说明他的定义)和taskStartTime为空。

然后从多个任务队列中选出oldestTask,即一个最先写入任务队列的任务。(因为微任务队列不是任务队列,所以这一步选出的oldestTask必然是宏任务)。

然后将taskStartTime设置为不安全的共享当前时间。循环系统记录任务开始执行的时间。

将当前正在执行的任务设置为oldestTask

当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask

然后检查有没有微任务,执行微任务。

宏任务的不足之处

直接甩🌰,

const startTime = Date.now()
setTimeout(() => {
  console.log(Date.now() - startTime) 
}, 1000)
let i = 0
while (i < 100000000) {
 i+=1
}

从JS的单线程特性说起,带你超快理解JavaScript中的事件循环机制(Event Loop)

大家可以看到这里打印结果并不是我们预期中的 1000,因为IO线程会随时插进一些任务,所以导致JS的执行时间无法精准控制。对于一些实时性要求高的任务,宏任务显然是不足够的。

微任务

微任务的定义

JavaScript 引擎发起的任务称为微任务,其本质是一个需要异步执行的函数,典型例子就是Promise函数所发起的回调函数。

微任务的执行流程

如之前在宏任务的执行流程中所讲,微任务执行于主函数完成之后,事件循环系统会检查有无微任务,有则执行,执行完后再结束本轮宏任务。举个🌰:

console.log(1)
setTimeout(() => {
  console.log(2)
  const promise1 = new Promise((resolve, reject) => {
    resolve()
  })
  promise1.then((res) => {
    console.log(3)
  })
})
const promise2 = new Promise((resolve, reject) => {
  resolve()
})
promise2.then((res) => {
  console.log(4)
})
console.log(5)

从JS的单线程特性说起,带你超快理解JavaScript中的事件循环机制(Event Loop) 执行结果如图,这是因为 setTimeout 函数作为浏览器API(属于我们前面讲的宿主环境噢),触发的回调函数是典型的宏任务,系统看到它之后会将其加入宏任务队列,按照我刚刚讲述的宏任务执行流程,一个宏任务中主函数完结后会检查有无微任务,有则执行。所以系统看到 setTimeout 后会将其交予定时器线程来执行(也就是说它变成了下一轮宏任务),自己先继续完成接下来的JS语句。而Promise 函数它作为典型的微任务,在本轮宏任务完成后执行。

我们可以大概理解:宏任务的队列就相当于事件循环。那么本示例中含有两轮事件循环。第一轮,系统看到setTimeout直接将其弄去第二轮执行,看到Promise后将它丢进微任务队列,自己接着执行 console.log(5)。执行后,执行微任务队列里的任务,打印出4。第一轮事件循环就此结束,执行setTimeout函数中的内容。

宏任务与微任务的小总结

综上所述,一个JS脚本本身对于浏览器而言就是一个宏任务,也是第一个宏任务,而处于其中的代码可能有3种:非异步代码、产生微任务的异步代码(promise等)、产生宏任务的异步代码(settimeout、setinterval等)。

宏任务处于一个任务队列中,会先执行完上一个才会执行下一个宏任务,所以在JS脚本中,先执行非异步代码,再执行微任务代码,最后执行宏任务代码。然后开启下一个宏任务,继续按照这个顺序执行。 我们可以大概理解:一个宏任务执行的过程就相当于一个事件循环。

微任务是宏任务的一部分,每个宏任务中都包含一个微任务队列,在执行宏任务的过程中,如果有突发事件,那么就会将其加到微任务队列中,等待当前宏任务中的主函数执行完成后执行微任务,因此也就既解决了执行效率的问题又解决了实时性问题。

参考资料

《浏览器工作原理与实践》

WHATWG event-loop-processing-model