# JavaScript 中的 Event Loop 事件循环、微任务与宏任务

TIP

本节开始学习 JavaScript 中的 Event Loop 事件循环、微任务与宏任务

# 一、单线程的 JavaScript

TIP

我们都知道 JS 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那 JS 为什么要设置成单线程的呢 ?为什么不设置成多线程呢 ?

  • JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JS 的主要用途是与用户交互,以及操作 DOM。这就决定了他只能是单线程的,否则会带来很多复杂的同步问题。
  • 假设 JS 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准 ?

所以,为了避免复杂性,从一从一诞生,JavaScript 就是单线程,浏览器中的 JS 执行和 DOM 渲染共用一个线程。

# 二、同步任务与异步任务

TIP

JS 是单线程的,那就意味着所有的任务需要排队,前一个任务结束,才能执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

如果因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 设备(输入输出设备)很慢(比如受网络的影响,外部请求加载一张图片等会很慢),就不得不等着结果出来,再往下执行。这样就造成了 CPU 资源的浪费,因为 CPU 是闲着的,但后面还有很多任务要做又不能做,这样代码的执行效率就变得很低了,因为某个任务过长,就会造成主线程阻塞。

JS 的语言设计者也意识到了,这时主线程完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,JS 中把任务分成两种:同步任务(synchronous)和异步任务(asynchronous)

  • 同步任务:是指在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务
  • 异步任务:是指不进入主线程,而进入“任务队列”(task queue)的任务,只有等主线程任务执行完毕,"任务队列"开始通知主线程,请求执行任务,该任务才会进入主线程执行

JS 中那些任务是属于同步任务,那些属性异步任务呢 ?

同步任务 异步任务
大部分代码都是同步任务 事件、setTimeout、setInterval、requestAnimationFrame、Ajax、fetch、MutationObserver、Promise 的 then 和 catch 方法、async 函数

你可能有这样的疑问:

JS 是单线程的,那他的异步任务是谁来负责的,如何被加入到任务队列,这就需要了解浏览器的进程与线程。

# 三、浏览器进程与线程

TIP

首先我们要知道 JS 是单线程的,所谓的单线程是指用来执行 JS 代码的线程只有一个。

但浏览器是多线程的,所以 JS 执行时遇到异步任务,如是 http 请求,这些请求是由浏览器的相关线程来完成的,等请求有结果时,再把需要 JS 线程来执行的任务(通常以回调函数的形式)加入到任务队列等着 JS 主线程空闲时来执行。

浏览器是多进程和多线程的,那进程和线程是什么关系呢 ?

分类 说明
进程 是操作系统资源分配的最小单元。一个进程拥有的资源有⾃⼰的堆、栈、虚存空间(页表)、文件描述符等信息(可以把他理解为一个独立运行的程序)
线程 是操作系统能够进行运算调度的最小单元。它被包含在进程中,是进程中实际运行的单位。一个进程中可以并发多个线程,每个线程执行不同的任务

生活类比 - 解释进程与线程

如果把进程看作一个部门,一个部门都需要完成指定的任务,那就会为完成这些任务配套相关的资源。

那线程就相当于这个部门的人,他们共享这个部门的资源,然后每个人又有自己不同的事情要做,多个人(多个线程)之间相互配合,然后一起把这个任务完成。

温馨提示:

  • 线程共享进程资源,包括内存空间和操作系统的权限
  • 进程中的任意一个线程执行出错,都会导致整个进程的崩溃
  • 进程和进程之间也是可以互相通信,就好比部门和部门之间也是可以互相通信的一样

image-20230217003241845

仅仅打开一个网页,就需要具有以下 4 个进程

进程 功能
浏览器进程 主要负责界面显示、用户交互、子进程管理、同时提供存储等功能
渲染进程 核心任务是将 HTML、CSS 和 JS 转换为用户可以与之交互的网页,提成版引擎 Blink 和 JS 的 V8 引擎都是在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。
GPU 进程 负责整个浏览器界面的渲染,早期主要是为了实现 3D CSS 效果
网络进程 主要负责页面的网络资源加载
渲染进程中的线程 功能
JS 引擎线程 JS 引擎线程也称为 JS 内核,负责处理 Javascript 脚本程序,解析 Javascript 脚本,运行代码;
JS 引擎线程一直等待着任务队列中任务的到来,然后加以处理,一个 Tab 页中无论什么时候都只有一个 JS 引擎线程在运行 JS 程序
HTTP 请求线程 XMLHttpRequest 连接后通过浏览器新开一个线程请求;检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将回调函数放入事件队列中,等待 JS 引擎空闲后执行
定时器触发线程 主要负责负 setTimeout,setInterval 定时器计时的,计时完毕后,将事件添加到处理队列的队尾,等待 JS 引擎空闲去处理
事件触发线程 用来控制事件循环,当 JS 引擎执行到点击事件,异步事件等等,都会将对应的任务添加到事件线程中,当事件符合触发条件时,会将事件添加到处理队列的队尾,等待 JS 引擎空闲后去执行(主要负责将准备好的事件交给 JS 引擎去执行)
GUI 线程 负责渲染浏览器页面,解析 HTML、CSS、构建 DOM 树,构建 CSSOM 树,构造渲染树和绘制页面。当界面需要重绘或某种操作引发回流时,该线程就会执行
不过要特别注意: GUI 线程和 JS 引擎线程是互斥的,当 JS 引擎执行时 GUI 线程会被挂起,GUI 更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行

# 四、同步与异步执行顺序

TIP

首先 JS 会将所有同步任务执行完再去执行异步任务,如果在执行同步任务的过程中遇到了异步任务,会先把他放到 “任务队列” 中等着,等同步的代码全部执行完,再到任务队列取出异步任务,进入主线程并执行。

异步任务的执行顺序是先加入队列的先拿出来执行

image-20221103170123369

console.log(1);
console.log(2);
setTimeout(function () {
    console.log("定时器1000");
}, 1000);
console.log(3);

setTimeout(function () {
    console.log("定时器0");
}, 0);
console.log(4);
......

// 最后执行结果  1,2,3,4 定时器0  定时器1000

# 五、宏任务与微任务

TIP

JS 中的任务分为同步与异步,其中异步任务又分为两种:

  • 宏任务(Macro-take)
  • 微任务(Micro-take)

image-20230215195040486

宏任务 微任务
script 标签(JS 整体代码)、setTimeout、setInterval、Ajax、DOM 事件 等 Promise 的 then 和 catch 方法、MutaionObserver、async/await 等

任务队列的执行过程:

  • 1、刚开始,调用栈空。微任务队列空,宏任务队列里有且只有一个 Script 脚本(整体 JS 代码)。这时首先执行的就是这个宏任务。(所以一开始程序执行时是没有微任务的)
  • 2、整体代码作为宏任务进入调用栈,先执行同步代码,在执行的过程中遇到宏任务或微任务,就将他们加入分别加入到宏任务队列或微任务队列。
  • 3、上一步的同步代码执行完后出栈,接着从微任务队列中取出微任务(先添加到微任务队列的先执行)并执行,在执行微任务过程中产生新的微任务,会添加到微任务队列,等微任务中的任务全部完成后,并不会马上执行宏任务,而是会进行 DOM 渲染
  • 4、开始 DOM 渲染,把内容呈现在页面中,DOM 渲染结束。
  • 5、接着从宏任务队列中取出宏任务(先加入到宏任务队列的先执行),并压入栈中执行。在执行宏任务时,也可能会产生新的宏任务和微任务。其执行过程重复上面操作。

以上不断重复的过程就叫做 Event Loop(事件循环)

注意事项:

微任务是在下一轮 DOM 渲染之前执行,宏任务是在这之后执行。也就是说微任务与宏任务之间隔着一个 DOM 渲染。

所谓 DOM 渲染是指把内容绘制到页面上。

image-20230217002049162

接下来我们通过相关的代码来分析整个代码执行的顺序

# 1、代码分析 一

<div>正文内容</div>
<script>
  console.log("同步开始---");
  const div = document.createElement("div");
  div.innerHTML = "新加的内容";
  document.body.appendChild(div);
  const list = document.querySelectorAll("div");
  console.log("div的个数----", list.length);

  setTimeout(() => {
    console.log("timeout中代码");
    alert("阻塞 timeout");
  });
  console.log("同步进行中----");
  Promise.resolve().then(() => {
    console.log("Promise的then方法中代码");
    alert("阻塞 Promise");
  });
  console.log("同步结束----");
</script>

image-20230216230216488

# 2、代码分析 二

console.log("同步1");

setTimeout(function fn1() {
  console.log("定时器为宏任务");
}, 0);

new Promise((resolve, reject) => {
  console.log("同步2");
  resolve("a");
})
  .then(function fn2() {
    console.log("then方法为微任务1");
  })
  .then(function fn3() {
    console.log("then方法为微任务2");
  });

console.log("同步3");

image-20230215193101755

分析上面代码的执行步骤:

1、宏任务:执行整个代码(<script>标签中的代码)

  • (1):先执行同步任务console.log("同步1"),输出 "同步1"
  • (2):遇到 setTimeout,加入到宏任务队列
  • (3):遇到 Promise 的构造函数,属于同步任务,输出 "同步2"
  • (4):遇到 Promise 的 then 方法,加入微任务队列(1 个 then,加入微任务队列)
  • (5):接着执行后面的同步代码 console.log("同步3");输出 "同步 3"

2、微任务:执行微任务对列(promise 的 then 方法中的回调)

  • (1): 从微任务队列中取出第一个任务(第一个 then 的回调)执行,输出:"then方法为微任务1" 这个 then 方法执行后又产生了一个微任务,加入到了微任务队列。
  • (2):从微任务队列中取出刚加的微任务,并执行,输出 "then方法为微任务2"

3、执行渲染操作,更新界面

4、宏任务:取出宏任务队列中的任务(setTimeout 的回调函数 fn1)并执行,最后输出 "定时器为宏任务"

# 3、代码分析 三

setTimeout(() => {
  console.log("ok");
});
new Promise((resolve, reject) => {
  console.log(1);
  resolve();
})
  .then(() => {
    console.log(2);
  })
  .then(() => {
    console.log(3);
  })
  .then(() => {
    console.log(4);
  })
  .then(() => {
    console.log(5);
  });

new Promise((resolve, reject) => {
  console.log(10);
  resolve();
})
  .then(() => {
    console.log(20);
  })
  .then(() => {
    console.log(30);
  })
  .then(() => {
    console.log(40);
  })
  .then(() => {
    console.log(50);
  });

// 执行结果:1 10 2 20 3 30 4 40 5 50  ok

分析上面代码的执行步骤:

宏任务:执行整个代码(<script>标签中的代码)

  • (1):遇到异步的宏任务,添加到宏任务队列。然后接着向下执行代码
  • (2):遇到同步任务,new Promise(...),则打印 1 ,然后 Promise 的状态改变,向微任务队列中添加第 1 个微任务() => { console.log(2); }
  • (3):接着遇到同步任务,new Promise(...),则打印 10,然后然后 Promise 的状态改变,向微任务队列中添加第 2 个微任务() => { console.log(20);}
  • (4):同步任务执行完,开始从微任务队列中取出第 1 个微任务执行,打印 2,然后返回成功的 Promise 对象,向微任务队列中添加第 3 个微任务() => { console.log(3);},出栈。
  • (5):接着从微任务队列中取出第 2 个微任务执行,打印 20,然后然后返回成功的 Promise 对象,向微任务队列中添加第 4 个微任务() => { console.log(30);},出栈。
  • (6):接下来重复上面的步骤 3 和 4,不断取出对应的微任务执行,在执行的过程中又产生新的微任务。等所有微任务全部执行完,最后去宏任务队列取出宏任务,并执行,所以最后输出 “ok"

# 4、代码分析 四

这是一道经典的面试题,熟称让人失眠的一道面试题

const p2 = Promise.resolve()
  .then(() => {
    console.log(0);
    // 慢两拍
    return Promise.resolve(4);
  })
  .then((data) => {
    console.log(data);
  });

Promise.resolve()
  .then(() => {
    console.log(1);
  })
  .then(() => {
    console.log(2);
  })
  .then(() => {
    console.log(3);
  })
  .then(() => {
    console.log(5);
  })
  .then(() => {
    console.log(6);
  });
上次更新时间: 6/8/2023, 9:23:17 PM

大厂最新技术学习分享群

大厂最新技术学习分享群

微信扫一扫进群,获取资料

X