在这两天里看到一篇文章,发现好像很多人都把事件循环给搞混了,到底是宏任务先执行还是微任务先执行。在写这篇文章之前,我也随机挑选了几位幸运观众来问这个问题,好像大多都是说微任务先执行。
那么从这篇文章里,我们就来探讨一下到底是哪个先执行。
什么是进程
进程是计算机系统中正在运行的程序的实例。它是操作系统对一个正在运行的程序的抽象表示,负责管理程序的执行和资源分配。
通常它包括堆栈,例如临时数据,如函数参数、返回地址和局部变量和数据段,其中数据段包括全局变量。
进程还可能包括堆,这是在进程运行时动态分配的内存。在 JavaScript 中,堆和栈的内存分配是通过不同方式进行的:
堆内存分配:
- JavaScript 中的对象、数组和函数等复杂数据类型都存储在堆内存中;
- 使用 new 关键字或对象字面量语法创建对象时,会在堆内存中动态分配相应的内存空间;
- 堆内存的释放由垃圾回收机制自动处理,当一个对象不再被引用时,垃圾回收机制会自动回收其占用的堆内存,释放资源;
栈内存分配:
- JavaScript 中的基本数据类型,如数字、布尔值和字符串以及函数的局部变量保存在栈内存中;
- 栈内存的分配是静态的,编译器在编译阶段就确定了变量的内存空间大小;
- 当函数被调用时,会在栈内存中创建一个称为栈帧
stack frame
的数据结构,用于存储函数的参数、局部变量、返回地址等信息;
- 当函数执行完毕或从函数中返回时,对应的栈帧会被销毁,栈内存中的数据也随之释放;
在操作系统中,每个进程都有自己的地址空间、状态和控制信息。进程可以独立运行,与其他进程离开来,互不干扰。它们可以同时进行,并通过进程间通信机制进行交互。
进程在执行时会改变状态。进程状态,部分取决于进程的当前活动。每个进程可能处于以下状态:
- 新的: 进程正在创建;
- 运行: 指令正在运行;
- 等待: 进程等待发生某个时间,如
I/O
完成或收到信号;
- 就绪: 进程等待分配处理器;
- 终止: 进程已经完成执行;
下图完整地显示了一个状态图:
什么是线程
线程是进程中的一个执行路径,是进程的组成部分。在同一个进程中的多个线程共享进程的资源,如内存空间和文件句柄等。不同线程之间可以并发执行,各自堵路地完成特定的任务。
进程和线程之间的关系如下:
- 一个进程可以创建多个线程,这些线程共享同一个地址空间和资源,能够并发地执行任务;
- 线程是在进程内部创建和销毁的,它们与进程共享进程的上下文,包括打开的文件、全局变量和堆内存等;
- 每个进程至少包含一个主线程,主线程用于执行进程的主要业务逻辑。其他线程可以作为辅助线程来完成特定的任务;
主线程是一个程序中的特殊线程,它是程序的入口点和主要执行线程。
在许多编程语言和操作系统中,主线程是程序启动后自动创建的线程,负责执行程序的主要业务逻辑。主线程会按照顺序执行代码,从程序的入口点开始,直到程序结束或主线程显式终止。
你可以理解成一个篮球场,整个篮球场就是一个进程,就是一块能供线程使用的内存。
而线程就是篮球场上的每一个队员,每个人都有不同的职责。
Chrome: 多进程架构浏览器
许多网站包含活动内容,如 JavaScript
、Flush
和 HTML5
等,以便提供丰富的、动态的 Web
浏览体验。遗憾的是,这些 Web 应用程序也可能包含软件缺陷,从而导致响应迟滞,有的甚至网络浏览器崩溃。如果一个 Web
浏览器只对一个网站进行浏览,那么这不是一个大问题。
但是,现代 Web 浏览器提供标签是浏览,它运行 Web 浏览器的一个实例,同时打开多个网站,而每个标签代表一个网站。要在不同网站之间切换,用户只需点击响应的标签。这种安排如下图所示:
这种方法的一个问题是: 如果任何标签的 Web
应用程序崩溃,那么整个进程,包括所有其他标签所显示的网站也会崩溃。
Google
的 Chrome Web
浏览器通过多进程架构的设计解决这以问题。Chrome
具有多种不同类型的进程,这里我们主要讲讲其中三个:
- 浏览器进程: 主要负责界面显示、用户交互、子进程管理,同时提供存储等功能;
- 网络进程: 网络进程负责处理浏览器内发起的所有网络请求,例如加载网页、资源文件,如图片、
CSS
、 JavaScript
、XMLHttpRequest
和 Fetch API 等请求;
- 渲染进程: 主要负责渲染网页的逻辑。主要处理 HTML、Javascript、图像等等。一般情况下,对应于新标签的每个网站都会创建一个新的渲染进程。因此,可能会有多个渲染进程同时活跃;
如下图所示:
例如我只打开了两个标签也,浏览器就会开辟两个两个不同的进程,两者之间相互独立,一个崩掉了不会另外一个。
这个目前是这样子的,但是后续可能会改,根据 chrome 文档说明,后期可能会修改成每个站点开启一个进程,例如,你访问 b 站,而再 b 站里面的所有页面都不会开启新的渲染进程了,详情请看 chrome 官方文档
渲染主线程是如何工作的
渲染主线程是浏览器中最繁忙的线程,需要它处理的任务包括但不限于:
- 解析
HTML
;
- 解析
CSS
;
- 计算样式;
- 布局;
- 处理图层;
- 每秒把⻚面画
60
次;
- 执行全局
JavaScript
代码;
- 执行事件处理函数;
- 执行计时器的回调函数;
等等事情,当浏览器的网络线程收到 HTML
文档后,会产生一个渲染任务,并将其传递给渲染主线程的消息队列,在事件循环的作用下,渲染主线程取出消息队列中的渲染任务,并开启渲染流程。整个过程分为多个阶段,分别是: 解析 HTML
、样式计算、布局、分层、绘制、分块、光栅化、画,每个阶段都有明确的输入输出,上一个阶段的输出会成为下一个阶段的输入。
首先解析的是 HTML
文档,这里我们忽略 CSS
的文件,如果主线程解析到 script
位置,会停止解析 HTML
,转而等待 JavaScript
文件下载好,主线程将 JavaScript
代码解析执行完成后,才能继续解析 HTML
。这是因为 JavaScript
代码的执行过程中可能会修改当前的 DOM
树,所以 DOM
树的生成必须暂停。这就是 JavaScript
会阻塞 HTML
解析的根本原因。
前面的这句话中有两个关键字很关键字,画好重点,它们分别是 下载 和 解析
要处理这么多的事情,浏览器给渲染进程采用了多线程,它主要包含了以下线程:
- 主线程: 主线程负责解析
HTML
、CSS
和 JavaScript
,构建 DOM
树、CSSOM
树和渲染树,并进行页面布局和绘制。它还处理用户交互,执行 JavaScript
代码以及其他页面渲染相关的任务;
- 合成线程: 合成线程负责将渲染树转换为图层,并执行图层的合成操作;
- 网络线程: 网络线程负责处理网络请求和数据传输。当浏览器需要加载网页、图片或其他资源时,网络线程负责发送请求并接收响应数据;
- 定时器线程: 定时器线程负责管理定时器事件,包括
setTimeout
和 setInterval
等。它用于在指定的时间间隔内触发预定的任务;
- 事件处理线程: 当用户进行交互操作,如点击按钮、滚动页面、输入文本等,需要触发相应的事件处理函数;
由于主线程和合成线程是并行执行的,这就可能导致这两个线程之间存在数据交互的问题。例如,当主线程和合成线程都需要访问相同的共享资源时,就需要进行同步,以避免竞态条件等问题。
这里就设计到消息队列的作用: 主线程和合成线程之间通过消息队列进行通信。主线程将渲染任务和图层数据等信息封装成消息,并将消息放入消息队列中。合成线程从消息队列中获取消息,并执行相应的图层合成操作。
浏览器中出现消息队列是为了处理异步任务和事件。在浏览器当中,有许多人任务是在后台执行或者将来某个事件触发时才执行的,例如:
- 异步操作: 比如通过
AJAX
请求从服务器获取数据、读取本地文件等,这些操作需要等待网络请求或者文件读取完成后再处理响应的数据;
- 定时器: 通过
setTimeout
或 setInterval
设置的定时器任务,需要在指定的时间间隔后执行;
- 事件处理: 当用户进行交互操作,如点击按钮、滚动页面、输入文本等,需要触发相应的事件处理函数;
如上图所示,当渲染主线程正在执行一个 JavaScript
函数,执行到一半的时候用户点击了按钮或者碰到了一个定时器,也就是 setTimeout
。因为在我们的渲染进程里面是有定时器线程的,定时器线程监听到有这个定时器操作。那么该线程会将 setTimeout
里面的事件处理函数 (setTimeout
的第一个回调函数) 作为一个任务拿去排队。
因为消息队列采用的是队列的数据结构,当渲染主线程将所有任务情况之后,然后从消息队列中拿去最旧的那个任务,假设消息队列之前没有任务的情况下,就拿出 setTimeout
这个事件处理函数。如果在该函数当中又遇到了类似的事件处理函数或者定时器,按照前面的步骤。依此循环,直到所有任务执行完成。
整个过程,就被称之为事件循环。
什么是异步
JavaScript
是一门单线程的编程语言.意味着在一个特定的时间点,只能有一个代码块在执行。当执行一个同步任务时,如果任务需要很长时间才能完成,如网络请求、文件读取等,整个程序会被阻塞,导致用户界面无响应,甚至造成卡顿的问题。这种情况在 Web
应用中尤其常见,因为 JavaScript
经常与网络请求、DOM
操作等耗时任务打交道。
常见的异步操作包括:
- 网络请求: 发送
HTTP
请求并等待服务器响应时,通常使用异步方式,以允许程序继续执行其他操作;
- 定时器: 设置定时器,在一段时间后执行某个任务,也是异步操作的一种;
- 事件处理: 为 DOM 元素注册事件监听器是一种常见的异步任务。当特定事件触发时,相应的事件处理函数将被异步调用,例如
addEventListener
。
渲染主线程负责处理网页的构建、布局、绘制和用户交互等任务,而异步编程使得我们可以在主线程执行同步代码的同时,处理耗时的异步操作,例如网络请求、文件读写等,以提高程序的性能和用户体验。在 JavaScript
中,通过事件循环机制,异步编程实现了一种非阻塞的执行方式,使得浏览器能够高效地处理各种任务,同时保持用户界面的响应性。
例如你要执行一个 setTimeout
:
setTimeout(() => {
console.log(111);
}, 3000);
console.log(222);
在这段代码当中,如果让渲染主线程去等待这个定时器任务执行完再去执行下一个任务,就会导致主线程长期处于阻塞的状态,从而导致浏览器页面长期见不到效果,可能要砸电脑了。
整个流程你可以理解为这样,但也可能不完全正确。
等到整个计时结束,再执行 console.log(222)
的代码,这种模式就叫作同步。整个时候消息队列还有很多任务在等待,可能还存在一些渲染页面的任务,有可能直接导致整个页面卡死。
所以为了这个问题,浏览器采用了异步的方式来解决这个问题,因为渲染主线程承担着及其重要的工作,无论如何都不能阻塞。
当计时开始之后,我就不管你了,就例如你是服务,餐厅里面来了客人,客人点完了菜,你把菜单交给后厨,你就可以先不管了,继续服务下一个客人,也就是从消息队列中拿下一个任务。当计时结束之后,会把该回调函数放入到消息队列末尾.等到剩下的任务完成之后,你就可以给客人端菜了。
任务优先级
任务没有优先级,在消息队列中先进先出,但消息队列是有优先级的。
事件循环在过去的说法中,任务分为两个队列,一个是宏任务,一个是微任务。但是现在已经没有了宏任务的说法了。
因为我在年初的时候就写过相关事件循环的文章,且有在 mdn 上搜索过宏任务的相关概念,但现在在 mdn 已经完全搜索不到了。
根据 W3C
的最新解释:
- 每个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列。在一次事件循环当中,浏览器可以根据实际情况从不同的队列中取出任务执行;
- 浏览器必须准备好一个微任务队列,微队列中的任务优先所有其他任务执行;
相关 W3C 连接
在 Chrome
的实现中,至少包含了下面的队列:
- 延时队列: 用于存放计时器到达后的回调任务,优先级
中
;
- 交互队列: 用于存放用户操作后产生的事件任务,优先级
高
;
- 微队列: 用户存放需要最快执行的任务,优先级
最高
;
虽然浏览器最新规范是这样,但是你用之前的宏任务和微任务去答题也完全没有问题的,但是输出的顺序是完全没有变的,况且这篇文章主要内容也不是讲这个,那么在之后的代码中我们就继续以宏任务和微任务的来讲。
宏任务和微任务 重点来啦!!!
宏任务是一组异步任务,这些任务通常由浏览器的事件触发器发起,并在主线程中按照顺序执行。常见的宏任务包括:
setTimeout
和 setInterval
;
I/O
操作,例如读取文件、网络请求;
DOM
事件,例如点击事件、输入事件;
requestAnimationFrame
;
script
标签;
微任务是一个细微的异步任务,它的执行时机在宏任务之后、渲染之前。微任务通常在一个宏任务执行完毕后立即执行,而不需要等待其他宏任务。这使得微任务的执行优先级比宏任务高。常见的微任务包括:
Promise
的 resolve
和 reject
回调;
async/await
中的异步函数;
MutationObserver
;
很重要的一点来了,为什么说 script
标签是宏任务呢?
如果忘记了,你再看看我们前面中说到的 下载
和 解析
两个关键字。
script
标签包含的 JavaScript
代码在浏览器中执行时,被认为是宏任务。这是因为执行 script
标签内的代码需要进行一系列的操作,包括解析、编译和执行。主要有以下几个理由:
- 解析和编译: 当浏览器遇到
script
标签时,它会停止当前的文档解析过程,并开始解析 script
内的 JavaScript
代码。解析器将逐行读取代码,并将其转换为可执行的内部表示形式。这个解析和编译的过程是一个比较耗时的操作,需要占用大量的 CPU
资源;
- 阻塞页面渲染: 由于脚本的执行通常会修改当前页面的结构和样式,浏览器必须等待脚本执行完毕后再进行页面的渲染。也就是说,当浏览器执行
script
标签时,它会阻塞页面的渲染,直到脚本执行完毕才会继续渲染;
- 可能引起网络请求:在
script
标签中,可以使用外部的 JavaScript
文件引用,例如 <script src="example.js"></script>
。当浏览器遇到这样的情况时,它会发起一个网络请求去下载该文件,并等待文件下载完成后再执行。网络请求通常是一个比较耗时的操作,因此将其作为宏任务可以确保脚本的执行按照正确的顺序进行;
总结起来,script
标签被认为是宏任务是因为它需要解析、编译和执行 JavaScript
代码,并且会阻塞页面的渲染。此外,如果使用了外部 JavaScript
文件,还可能引起网络请求,进一步增加了执行时间。这些特性使得 script
标签的执行与其他微任务(如 Promise)不同,被归类为宏任务。
如果你依然觉得理由不够充分的话,请看以下代码:
<script>
console.log("start1");
setTimeout(() => console.log("timer1"), 0);
new Promise((resolve, reject) => {
console.log("p1");
resolve();
}).then(() => {
console.log("then1");
});
console.log("end1");
</script>
<script>
console.log("start2");
setTimeout(() => console.log("timer2"), 0);
new Promise((resolve, reject) => {
console.log("p2");
resolve();
}).then(() => {
console.log("then2");
});
console.log("end2");
</script>
该代码的输出结果如下所示:
如果 script
标签不是宏任务,普通任务的话,是不是应该先执行 start2
和 end2
再执行 then1
。
所以根据此结论,整个浏览器循环应该是 先执行 宏任务
-> 同步代码
-> 微任务
,直到当前宏任务中的微任务清理完毕,继续执行下一个宏任务,以此类推。
最后我再抛出一个问题,没有你这个 script
这个宏任务的出现,你哪来的微任务?
一个事件循环过程模型如下,当调用栈为空时,执行以下步骤:
- 选择任务队列中最旧的任务(队列是一个先进先出的队列,最旧的那个就是最先进的,这里是 任务 A);
- 如果任务 A为空(意味着任务队列为空),跳转到第6步;
- 将当前运行的任务设置为任务 A;
- 运行任务 A,意味着运行回调函数;
- 运行结束,将当前的任务设置为空,删除任务 A;
- 执行微任务队列:
- 选择微任务队列中最早的任务 X;
- 如果任务 X,代表这微任务为空,跳转到步骤6;
- 将当前运行的任务设置为任务 X,并运行该任务;
- 运行结束,将当前正在运行的任务设置为空,删除任务 X;
- 选择微任务队列中下一个最旧的任务,可以理解为第n+1个入队的,跳转到步骤2;
- 完成微任务队列;
- 跳转到第1步;
这个事件循环过程模型如下图所示:
值得注意的是,当一个任务在宏任务队列中正在运行时,可能会注册新事件,因此可能会创建新任务,下面是两个新创建的任务:
Promise.then(...)
是一个回调任务:当 promise
被 fulfilled/rejected
:任务将被推入当前轮事件循环中的微任务队列;当promise
是 pending
:任务将在下一轮事件循环中被推入微任务队列(可能是下一轮);
案例
接下来我们通过一些案例来加深对事件循环的理解。
案例一
setTimeout(() => {
console.log("time1");
new Promise((resolve) => {
resolve();
}).then(() => {
new Promise((resolve) => {
resolve();
}).then(() => {
console.log("then4");
});
console.log("then2");
});
});
new Promise((resolve) => {
console.log("p1");
resolve();
}).then(() => {
console.log("then1");
});
最后的输出结果为 p1 then1 time1 then2 then4
,下面就来分析一下这个结果的由来:
- 代码首先遇到
settimeout
,是一个宏任务,里面的代码不会被执行;
- 接着代码往下执行,遇到
new Promise(...)
中的回调函数是一个同步任务,直接执行;
- 直接输出
"p1"
,调用 resolve()
,Promise
的状态变为 fuifilled
,当 promise
状态变为 fulfilled/rejected
时,任务将被推入当前轮事件循环中的微任务队列,所以后面的 then(...)
会被加入到微任务队列里面;
- 主线程中的同步代码执行完,从微任务中取出最旧的那个任务,也就是
then(...)
,输出 then1
,此时微任务队列为空;
- 继续执行宏任务,也就是这个
settimeout
,代码从上往下执行,首先输出 time1
;
- 在下面的代码中又遇到了
new Promise(...)
,并且调用了 resolve()
,then(...)
被加入到微任务队列中,此时的同步任务已经执行完毕,直接执行这个 then(...)
;
- 又是遇到
new Promise(....)
,又是调用的 resolve()
,所以 then()
方法会被添加到微任务队列中,代码往下执行,输出 "then2"
,此时微任务then(...)
中的代码全部执行完毕;
- 此时同步任务执行完毕,继续执行微任务中的
then(...)
,输出 "then4"
;
- 所有代码运行完毕,程序结束;
案例二
<script>
console.log(1);
setTimeout(() => {
console.log(5);
});
new Promise((resolve) => {
resolve();
}).then(() => {
console.log(3);
});
console.log(2);
</script>
<script>
console.log(4);
</script>
这段代码的最后的输出结果是: 1 2 3 4 5
,具体代码执行过程有以下步骤:
首先提醒一点,script
标签本身是一个宏任务
,当页面出现多个 script
标签的时候,浏览器会把script
标签作为宏任务来解析。当前实例中两个 script
标签,它们会一次加入到宏任务队列中。
console.log(...)
是同步代码,1首先会被输出,代码往下执行;
- 遇到
settimeout()
,会被加入到宏任务
队列中;
then(...)
会被加入到微任务队列中,代码继续往下执行;
console.log(...)
为同步认为输出 2
;
- 此时同步任务执行完毕,转而执行微任务
then(...)
,输出 3
;
- 当前宏任务执行完毕,此时同步任务和微任务都为空,取出最旧的宏任务,也就是第二个
script
标签;
- 输出
4
,此时同步代码和微任务队列都为空,继续执行下一个宏任务,也就是 settimeout
;
- 输出
5
;
案例三
async function foo() {
console.log("start");
await bar();
console.log("end");
}
async function bar() {
console.log("bar");
}
console.log(1);
setTimeout(() => {
console.log("time");
});
foo();
new Promise((resolve) => {
console.log("p1");
resolve();
}).then(() => {
console.log("p2");
});
console.log(2);
这段代码的最后的输出结果是: 1 start bar p1 2 end p2 time
,下面就来分析一下这段代码的执行过程:
- 前面两个是函数定义,不执行,遇到
console.log()
,输出 1;
- 代码继续往下执行,遇到
settimeout()
,代码加入到宏任务队列之中,代码往下执行;
- 调用
foo
,输出 start
;
await
等待 bar()
调用的返回结果;
- 执行
bar()
函数,输出 bar
;
await
相当于 Promise.then(...)
,代码被加入到微任务队列中,所以 end
还不执行;
- 代码往下执行,遇到
new Promise(...)
,p1
直接输出,then()
又继续被加入到微任务队列中;
- 代码继续往下执行,遇到
console.log(2)
,输出 2
;
- 此时主线程代码快为空,执行微任务队列中最旧的那个任务,继续执行
await
后续代码,输出 end
;
- 执行
then()
,输出 p2
;
- 最后执行
settimeout
,输出 time
;
案例四
Promise.resolve()
.then(() => {
console.log(0);
return Promise.resolve(4);
})
.then((res) => {
console.log(res);
});
Promise.resolve()
.then(() => {
console.log(1);
})
.then(() => {
console.log(2);
})
.then(() => {
console.log(3);
})
.then(() => {
console.log(5);
})
.then(() => {
console.log(6);
});
这个案例中,因为每一个 then()
都是一个微任务,所以首先执行的是0
,代码继续往下执行,输出同级的 then()
,也就是输出 1
。
如果 Promise
内返回的对象具有可调用的 then()
方法,则会在微任务队列中再插入一个任务,这就慢了一拍,如果这个 then()
方法是来源于 Promise
的,则因为是异步又慢了一拍,所以一共是慢了拍,所以 Promise.resolve(4)
的结果等到 2
和 3
输出完成,console.log(res)
的结果才会被输出;
所以该案例的最终结果输出的是 0 1 2 3 4 5 6
。
参考资料
总结
整个浏览器循环应该是 先执行 宏任务
-> 同步代码
-> 微任务
,直到当前宏任务中的微任务清理完毕,继续执行下一个宏任务,以此类推。
最后分享两个我的两个开源项目,它们分别是:
这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🥰🥰🥰
作者:Moment
来源:juejin.cn/post/7259927532249710653