js的事件循环机制的理解
首先,我们要理解JavaScript是一门单线程的语言。所谓单线程,简单来说一个时间只能做一件事,只有做完这件事,才能进行下一件。那为什么选择单线程,不选择多线程呢?这是由JS的用途决定的,JS的用途是与用户交互,以及操作DOM,假设JS有两个线程,一个要在某个DOM节点上添加内容,一个要删除这个节点,那浏览器该以哪个为准呢,事情就变得复杂了。因此,JS在诞生时就是单线程,以后也不会改变。
这个时候就出现了问题,如果一件任务耗时太长,就会阻塞后面的任务。这样肯定是不行的,所以,JS设计者将任务分成同步任务和异步任务。
同步任务就是在主线程(调用栈 Call Stack)中按照书写顺序依次执行的任务。异步任务就是不阻塞主线程,而是交由其他线程或系统处理(如浏览器 或 Node.js ),处理完毕后,将其回调函数推入任务队列。等到主线程空了,任务队列中的任务再根据FIFO算法被调度到主线程中执行。
事件循环的工作流程如下:
1.执行调用栈中的所有同步代码,直到栈空。【栈是后进先出结构,但是同步代码入栈后直接执行,执行完毕弹出,所以顺序还是不变的,但如果函数嵌套,则调用栈可能存在多层函数上下文,最后进入的上下文最先执行完,所以最先弹出】
2.检查任务队列,依次将任务调度到调用栈中执行,直到队列空。
3.重复循环。
只要调用栈空了,就去任务队列中读取任务,【用户点击按钮触发函数,函数就会被推入调用栈执行】这个过程不断重复循环,这就是JavaScript的运行机制, 这种机制就叫做事件循环机制
所以有一个小细节就是setTimeout(callback,s)的真正含义并不是在指定的毫秒数后调用函数,而是最快s毫秒后调用函数,因为它需要等待主线程空后再被调用。
但是这里面还有更细节的问题,在事件循环的早期设计中,所有异步任务都进入同一个任务队列。但随着前端复杂度提升,任务优先级问题显露出来:
紧急任务需要优先处理(如 Promise 状态更新)
非紧急任务可以延后(如 UI 渲染前的计算)
因此,现代事件循环又将异步任务细分为两类:
同步任务与宏任务:整个脚本(主线程代码),即script标签里的代码,本身就是一个宏任务,主线程执行脚本中的同步代码属于初始宏任务。整个脚本的执行是第一个宏任务,同步代码是它的组成部分。
于是,事件循环的工作流程就变成:
1.按照代码书写顺序执行初始宏任务:
如果遇到同步代码,直接推入调用栈执行。
如果遇到宏任务,将回调函数推入宏任务队列。
如果遇到微任务,将回调函数推入微任务队列。
2.清空微任务队列:
当前宏任务执行完毕后,依次将微任务队列中的所有微任务推入调用栈执行,直到微任务队列清空。
注意:若微任务中又生成新的微任务,新微任务也会在此阶段被立即执行。
3.渲染更新(如有必要):
浏览器判断是否需要渲染(通常根据屏幕刷新率,如 60Hz 对应约 16.6ms/次)。
4.开启下一轮事件循环:
从宏任务队列中取出下一个宏任务执行,重复上述流程。
Tips:我的一些思路历程
刚开始我以为js在遇到settimeout这种定时器时,会将整个定时器函数推到宏任务队列中,但其实不是的,
【如果是这样的话,那一个3秒的宏任务,会和一个耗费8秒的微任务几乎同时分别推入宏任务队列和微任务队列,那这个时候如果调用栈执行空了,先取出这个微任务执行,那可能8s的打印出来了,定时器还没打印出来,这个深究的话这个理解是错误的,不过可以帮我记住任务队列里放的都是处理过的回调】
js遇到定时器之后,将定时器以及回调交给浏览器,由浏览器的定时模块发起定时,处理完毕后(例如三秒后)将回调函数推入宏任务队列。
接着列举一些宏任务和微任务的理解,以及他们是怎么分类的
宏任务:
由宿主环境发起,比如浏览器或者Node。js遇到这些任务时会向对应的环境发起请求,由浏览器或者node进行处理,处理完毕后推入宏任务队列
浏览器环境: setTimeout、setInterval【定时器触发线程】、DOM事件【事件触发线程、GUI渲染线程】、AJAX回调【浏览器网络线程】等需要浏览器线程协作的任务
Node环境: I/O操作、setImmediate等系统级异步操作
微任务:
由JavaScript引擎自身发起和管理【相当于是一种js本身的异步机制】,例如js遇到promise的时候,会执行promise的同步部分,然后将promise的状态标记为pending,而直到当resolve/reject被调用时,js才会将对应的.then或者.catch回调函数推入微任务队列
语言级实现:Promise.then/catch/finally、queueMicrotask等属于ECMAScript规范定义的异步机制