捋一捋 JavaScript 事件循环机制

捋一捋 JavaScript 事件循环机制

前置知识

  • JavaScript 是一门单线程的语言。
  • 事件循环 (Event Loop) 是 JavaScript 的执行机制。

为什么 JavaScript 是单线程的语言?

我们知道线程是操作系统能够进行运算调度的最小单位。是进程中的实际运作单位。一个进程中可以并发多个线程,每条线程并行执行不同的任务。这就意味着多线程可以同时执行多个任务,单线程同一时刻只能执行一个任务。

那为什么 JavaScript 不采用多线程这种更高效方式呢?其实这跟 JavaScript 的应用场景有关,众所周知 JavaScript 是浏览器脚本语言,主要用来处理用户的交互逻辑和 DOM,如果 JavaScript 是多线程的,同一时刻,一个线程删除 DOM 的内容,另一个线程新增 DOM 的内容,那现在到底是新增还是删除呢?因此 JavaScript 只能是单线程的,同一时刻只能做一件事。

单线程带来的弊端

单线程同一时刻只能做一件事,意味着所有的 JavaScript 事件执行的时候都需要排队,后边的事件只能等前边的事件执行完后才能执行。如果当前正在执行的需要耗费很多时间,后边的事件就只能等着,这就是 JavaScript 的阻塞问题。例如访问一个网站时,文字加载很快,图片加载很慢,但由于单线程,我们必须等图片加载完成后才能加载后边的文字,无疑这样的体验很差。

同步任务异步任务

为了解决 JavaScript 的阻塞问题,提出了将同步任务与异步任务的概念,异步任务主要是处理耗时长的操作。这样就不会阻塞 JavaScript 的主线程。

捋一捋 JavaScript 事件循环机制_第1张图片

同步任务

在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。

代码示例分析

如下代码所示,我们分析一下执行流程:

  1. 判断console.log(0)是同步任务,主线程执行输出 0;
  2. 判断console.log(1)是同步任务,主线程执行输出 1;
  3. 最后输出 3 后所有代码执行完毕。
  4. 将当前执行上下文弹栈,运行结束。
<script>
// synchronization
    console.log(0);
    console.log(1);
    console.log(2);
    console.log(3);
</script>

异步任务

不进入主线程直接执行,而进任务表格的任务,当其满足触发条件后,再将回调函数推进任务队列,当所有同步任务执行完毕,才会从任务队列取出异步任务放入主线程执行。

Event Table
Event Table 可以理解成一张 事件 -> 回调函数 对应表,它就是用来存储 JavaScript 中的异步事件 (request, setTimeout, IO等) 及其对应的回调函数的列表

Event Queue
Event Queue 简单理解就是 回调函数 队列,所以它也叫 Callback Queue当 Event Table 中的事件被触发,事件对应的 回调函数 就会被 push 进这个 Event Queue,然后等待被执行

代码示例分析

如下代码所示,我们分析一下执行流程:

  1. 判断console.log(0)是同步任务,主线程执行输出 0;
  2. 判断setTimeout是异步任务,将其回调函数放入任务表格(Event tabel),从此刻开始200ms后将回调函数推进任务队列(Event queue);
  3. 判断console.log(3)是同步任务,主线程执行输出 3;
  4. 同步任务执行完毕,任务队列不为空,取出队头回调函数,压入执行栈执行。
  5. 判断console.log(1)是同步任务,主线程执行输出 1;
  6. 判断console.log(2)是同步任务,主线程执行输出 2,当前函数执行完毕;

注意这里的setTimeout会在200ms后将回调函数放入任务队列等待主线程执行,而不是200ms后主线程执行,这就是有时候延时时间设置的200ms,但是真正执行时延时不只200ms的原因。

从上面的流程和输出可以看到我们将耗时的任务作为异步任务让他先在别的地方执行(后面会解释具体在哪里),这样那些不耗时的同步任务就能先执行。例如当我们访问一个网站时,那么我们就可以异步加载图片,先加载显示文字,显示图片的地方用别的元素占位,这样就极大的提高了用户体验。

// asynchronization
console.log(0);
setTimeout(() => {
	console.log(1);
	console.log(2);
},200)
console.log(3);

宏任务与微任务

捋一捋 JavaScript 事件循环机制_第2张图片

异步任务又分为宏任务和微任务,微任务的执行优先级高于宏任务。也就是说当前同步任务执行完毕,就会去判断微任务队列(microTask)是否为空,若不为空会先执行微任务队列中所有微任务,执行完毕后,取宏任务队列(macroTask)队头宏任务进入执行栈执行。

宏任务(macro-task)

宏任务是指宿主发起的任务(浏览器/node)。

  • 同步 script (整体代码)
  • 定时器
  • 事件绑定
  • ajax
  • 回调函数
  • Node中fs可以进行异步的I/O操作
  • UI rendering

微任务(micro-task)

微任务是指 js 引擎发起的任务。

  • process.nextTick:node中实现的api,把当前任务放到主栈最后执行,当主栈执行完,先执行 nextTick,再到等待队列中找
  • Promise(async/await) :Promise并不是完全的同步,在promise中是同步任务,执行 resolve 或者 reject 回调的时候,此时是异步操作,会先将then/catch 等放到微任务队列。当同步代码完成后,才会再去调用 resolve/reject 回调执行。
  • MutationObserver:创建并返回一个新的 MutationObserver 它会在指定的DOM发生变化时被调用。

代码示例分析

如下代码所示,我们分析一下执行流程:

  1. 判断console.log(0)是同步任务,主线程执行输出 0;
  2. 判断setTimeout是异步任务,并且是宏任务,将其回调函数放入任务表格(Event tabel),从此刻开始200ms后将回调函数推进宏任务队列(macroTask);
  3. 判断Promise.resolve(2).then是异步任务,并且是微任务,将其回调函数放入任务表格(Event tabel),满足触发条件后将回调函数推进微任务队列(microTask);
  4. 判断console.log(3)是同步任务,主线程执行输出 3;
  5. 当前同步任务执行完毕,弹栈,检查微任务队列不为空,取出队头回调函数,压入执行栈执行。 判断console.log(1)是同步任务,主线程执行输出 2,当前微任务队列为空。
  6. 检查宏任务队列不为空,取出队头回调函数,压入执行栈执行。判断console.log(1)是同步任务,主线程执行输出 1,当前函数执行完毕,弹栈;
console.log(0);
setTimeout(() => {
	console.log(1);
})
Promise.resolve(2).then((res) => console.log(res));
console.log(3);

事件循环

首先要明白script代码块是作为一个宏任务的,所以我们在执行 JavaScript 代码时是从执行宏任务开始的。当前宏任务执行完毕后,会去检查微任务队列是否为空,若不为空,会执行微任务队列中所有微任务后,再从宏任务队列取出队头宏任务执行,执行完毕后,在检查微任务队列是否为空,若为空,取宏任务队列队头宏任务执行······,如此循环往复。
捋一捋 JavaScript 事件循环机制_第3张图片

这张图摘自事件循环(EventLoop)
捋一捋 JavaScript 事件循环机制_第4张图片

分析一道复杂题

第一轮循环
const promise1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    console.log('promise里的定时器')
    resolve('success')
  }, 1000)
})
const promise2 = promise1.then(() => {
  console.log('promise.then方法')
  Promise.resolve('resolve').then((res) => console.log(res))
  throw new Error('error!!!')
})
console.log('promise1', promise1)
console.log('promise2', promise2)
setTimeout(() => {
  console.log('promise1', promise1)
  console.log('promise2', promise2)
}, 2000)
  • 执行promise执行器函数,遇到setTimeout,1s后将回调函数推进宏任务队列。
  • 由于promise2依赖于 promise1,且 promise1 是 pendding 状态,因此 promise2 的状态也是 pendding。
  • 打印promise1 Promise {}和promise2 Promise {}
  • 又遇到setTimeout,宏任务,2s后将回调函数推进宏任务队列。
  • 检查微任务队列为空,第一轮循环结束。
第二轮循环
  • 取宏任务队列队头宏任务执行,打印promise.then方法执行
  • 执行 resolve 方法,promise1 状态变为 fulfilled,将 promise1.then 的回调函数推进微任务队列。
  • 检查微任务队列,不为空,取出队头微任务执行。打印promise.then方法
  • 遇到 Promise.resolve().then,将回调函数添加到微任务队列。
  • 抛出错误,此时会将 promise2 的状态置为 rejected。
  • 检查微任务队列不为空,取出队头微任务执行,打印resolve
  • 检查微任务队列为空,第二轮循环结束。
第三轮循环
  • 取出宏任务队列队头宏任务执行。顺序打印promise1 Promise {: 'success'}和promise2 Promise {: Error: error!!!
  • 检查微任务队列为空,第三轮轮循环结束。

宏任务队列和微任务队列都为空,程序运行结束。

打印结果

捋一捋 JavaScript 事件循环机制_第5张图片

疑难问题

这些问题是我在学习过程中事件循环机制时遇到的,希望能帮助到你。

区分宏任务、同步任务

从概念上宏任务是相对于微任务来说的,同步任务是相对于异步任务。

由于script代码块是作为一个宏任务,如下所有代码就是一个宏任务,在宏任务执行过程中会判断是每一步操作是同步任务还是异步任务。也就是执行console.log(0);时判断时同步任务,执行setTimeout时判断时异步任务······。

<script>
// asynchronization
  console.log(0);
  setTimeout(() => {
    console.log(1);
  },200)
  console.log(3);
</script>

若当前事件循环结束后进入下一轮事件循环,此时就是执行setTimeout的回调函数。它也是一个宏任务,在执行过程中又会判断console.log(1);是同步代码···。

() => {
	console.log(1);
}

异步是如何实现

这里以浏览器环境为例。javascript 是单线程的,但是浏览器却是多线程的,如图所示 javascript 引擎线程称为主线程,它负责解析JavaScript代码;其他可以称为辅助线程。当在解析过程中遇到setTimeout,那么就把它交给定时器线程执行,主线程继续解析下一行代码。当定时器线程执行完毕后将回调函数推进宏任务队列,然后等待主线程空闲了执行。

捋一捋 JavaScript 事件循环机制_第6张图片

为什么异步任务要分为宏任务和微任务

其实设置微任务就是给一些优先级搞的任务插队的机会。设想如果没有微任务只有宏任务,那么在执行异步任务的时候,假设遇到三个异步任务,分别执行完后会依次将回调函数添加到任务队列中等待执行,但是第三个异步任务执行完后我需要立即执行它的回调函数,这显然无法实现,只能乖乖等前两个回调函数执行完,才能执行。但是如果有微任务,他在每次执行完一个宏任务后就执行,只要将第三个异步任务设置为微任务,这个需求不就轻轻松松完成了。

总结

因为 JavaScript 是单线程的脚本语言,所以为了解决阻塞问题,提出了将任务分为异步任务同步任务,同步任务交由主线程执行,异步任务交由辅助线程执行后将回调函数推进相应的队列中等待主线程空闲时执行。并且异步任务又分为宏任务微任务,微任务的执行优先级更高,事件循环简单来说就是宏任务微任务之间的交替执行。

非常感谢这些文章对我的帮助

  • 理解 JS 中的 EventLoop 事件循环(详细图文教程)
  • JavaScript 为什么要区分微任务和宏任务
  • 事件循环(EventLoop)
  • 深入浅出讲解 JS 的微任务与宏任务
  • 单线程的JavaScript是如何实现异步的

我是孤城浪人,一名正在前端路上摸爬滚打的菜鸟,欢迎你的关注。

你可能感兴趣的:(#,JavaScript,进阶,javascript,前端)