JS的EventLoop事件循环

相信前端的小伙伴都或多或少的了解过EventLoop这个概念,但可能很多人并没有去深入学习过这个概念,我也一样。在这次失业找工作的过程中,面试官提出来一个问题是PromisesetTimeout的执行顺序问题,由于之前看到过相关问题所以知道是Promise先执行的,但是当面试官询问其原理时便一无所知了,所以在面试之后查阅了一些资料以及其他前辈的一些博客,对EventLoop有了一些基础的了解,纪录在此,用写给别人看的方式逼迫自己好好理解这个概念,争取做到知其然知其所以然。

翻了翻自己的笔记,找到了以前记录的EventLoop相关概念,翻出来发现完全看不懂了

任务队列中,在每一次事件循环中,macrotask只会提取一个执行,而microtask会一直提取,直到microsoft队列为空为止。

也就是说如果某个microtask任务被推入到执行中,那么当主线程任务执行完成后,会循环调用该队列任务中的下一个任务来执行,直到该任务队列到最后一个任务为止。而事件循环每次只会入栈一个macrotask,主线程执行完成该任务后又会检查microtasks队列并完成里面的所有任务后再执行macrotask的任务。

macrotasks: setTimeout, setInterval, setImmediate, I/O, UI rendering
microtasks: process.nextTick, Promise, MutationObserver

所以在此将EventLoop的基础概念从头到尾理一遍:

单线程的JS

大家都知道JavaScript是单线程的,作为一个浏览器的脚本语言,其主要用途是与用户互动 及 操作 DOM,它的使用场景决定了它只能是单线程的,否则会带来复杂的同步问题,假定JavaScript有两个线程,一个线程在某个DOM节点上添加内容,而另一个线程却删除了这个节点,此时浏览器应该以哪个线程为准呢?所以JavaScript是单线程的,但是为了充分利用多核CPU的计算能力,HTML5提出了Web Worker,允许创建多个线程,但是创建的子线程完全受主线程控制,且不允许子线程操作DOM,所以这个新标准 并没有改变JavaScript是单线程的本质。

关于Web Worker在此不展开描述,只是说明单线程问题。

单线程的代码执行是同步的而且会阻塞,如果只有同步执行肯定是不可以的,大家也都知道JavaScript是有异步函数的,比如setTimeout,而本文要说的Event Loop就是为了解决异步代码和同步代码的执行问题的。

相关术语概念

在计算机科学中是限定仅在表尾进行插入删除操作的线性表。是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底最后的数据栈顶,需要读数据的时候从栈顶开始弹出数据

是只能在某一端插入删除特殊线性表

队列

队列也是一种数据结构,它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和一样,队列是一种操作受限制的线性表。

队列的数据元素又称为队列元素。在队列中插入一个队列元素称为入队,从队列删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出

调用栈

调用栈本质上还是一个,存放的是待执行的函数

JS调用栈采用的是后进先出的规则,当函数执行的时候,会被添加到栈的顶部,当执行栈执行完成后,就会从栈顶移出,直到栈内被清空。

任务队列

简单来说就是一个回调函数的队列,在JavaScript中,任务被分为两种,一种宏任务(MacroTask)也叫Task,一种叫微任务(MicroTask)。

宏任务

宏任务包含了解析 HTML、生成 DOM、执行主线程 JS 代码和其他事件如 页面加载、输入、网络事件、定时器事件等。从浏览器的角度,宏任务代表的是一些离散的独立的工作。

如:script全部代码setTimeoutsetIntervalsetImmediateI/OUI RenderingrequestAnimationFrame

微任务

微任务则是为了完成一些更新应用程序状态的较小的任务,如处理 Promise 的回调和 DOM 的修改,以便让这些任务在浏览器重新渲染之前执行。微任务 应该以异步的方式尽快执行,所以它们的开销比宏任务要小,并且可以使我们在 UI 重新渲染之前执行,避免了不必要的 UI 渲染。

如:Process.nextTick(Node独有)PromiseObject.observe(废弃)MutationObserver

执行顺序

在文章开头引入的之前笔记写着:

任务队列中,在每一次事件循环中,macrotask只会提取一个执行,而microtask会一直提取,直到microsoft队列为空为止。

其实EventLoop至少需要一个宏任务队列和一个微任务队列。

微任务队列具有更高的优先级,即执行一个宏任务后,就会去执行整个微任务队列,如果此时有新的微任务加入也会被执行,直至微任务队列清空才会再去执行宏任务队列。

贴一张流程图(图源自网络):

IUh6mQ.md.png

贴一个相关文章给出的最多的代码示例吧

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('script start')

setTimeout(()=>{
console.log('setTimeout')
}, 0)

Promise.resolve().then(()=>{
console.log('promise 1')
}).then(()=>{
console.log('promsie 2')
})

console.log('script end')

经过前面的相关描述大家应该能直到执行的具体顺序了。

步骤:

  1. 执行开始,整个script脚本作为第一个宏任务进入调用栈开始执行。
  2. 输出script start
  3. 执行到setTimeout会将其加入宏任务队列进行等待。
  4. 执行到Promise会将其加入到微任务队列等待。
  5. 输出script end
  6. 此时第一个宏任务执行完毕了,不论宏任务队列是否清空,会直接开始执行微任务队列中的任务,从中拿出一个放入调用栈执行
  7. 执行Promsie会输出promise 1,返回undefined,第9行的回调会将其加入到微任务队列,此时由于微任务队列并没有清空,会继续执行输出promise 2
  8. 微任务队列清空,执行宏任务队列。
  9. 执行setTimeout,输出setTimeout,宏任务队列清空。
  10. 程序执行完成。

所以上述代码的执行顺序应该是:

1
2
3
4
5
script start
script end
promise 1
promise 2
setTimeout

扩展知识

ES7中提出了Promise的语法糖async/await,其写法更加简便,也更加容易理解。

需要注意的是,async这个函数总是返回一个promise,不论你是否显式的返回了一个Promise。这也就说明async函数是一个异步函数,且由于是对promise的封装,所以其调用也会加入到微任务队列。

await必须在async函数内部才能使用。

作者

胡兆磊

发布于

2021-11-10

更新于

2022-10-23

许可协议