写在前面
无论是浏览器端还是服务端Node.js,都在使用EventLoop事件循环机制,都是基于Javascript语言的单线程和非阻塞IO的特点。在EventLoop事件队列中有宏任务和微任务队列,分析宏任务和微任务的运行机制,有助于我们理解代码在浏览器中的执行逻辑。
那么,我们得思考几个问题:
- 浏览器的EventLoop发挥着什么作用?
- Node.js服务端的EventLoop发挥着什么作用?
- 宏任务和微任务分别有哪些方法?
- 宏任务和微任务互相嵌套,执行顺序是什么样的?
- Node.js中的Process.nextick和其它微任务方法在一起的时候执行顺序是什么?
- Vue也有个nextick,它的逻辑又是什么样的呢?
浏览器的EventLoop
EventLoop是Javascript引擎异步编程需要着重关注的知识点,也是在学习JS底层原理所必须学习的关键。我们知道JS在单线程上执行所有的操作,虽然是单线程的,但是总是能够高效地解决问题,并且会给我们带来一种『多线程』的错觉。这其实是通过一些高效合理的数据结构来达到这种效果的。
调用栈(Call Stack)
调用堆栈:负责追踪所有要执行的代码。每当调用堆栈中的函数执行完毕时,就会从栈中弹出此函数,如果有代码需要输入就会执行PUSH操作。
事件队列(Event Queue)
事件队列:负责将新的函数发送到队列中进行处理。事件执行队列符合数据结构中的队列,先进先出的特性,当先进入的事件先执行,执行完毕先弹出。
每当调用事件队列(Event Queue)中的异步函数时,都会将其发送到浏览器API。根据调用栈收到的命令,API开始自己的单线程操作。
比如,在事件执行队列操作setTimeout事件时,会现将其发送到浏览器对应的API,该API会一直等到约定的时间将其送回调用栈进行处理。即,它将操作发送到事件队列中,这样就形成了一个循环系统,用于Javascript中进行异步操作。
Javascript语言本身是单线程的,而浏览器的API充当独立的线程,事件循环促进了这一过程,它会不断检查调用栈的代码是否为空。如果为空,就从事件执行队列中添加到调用栈中;如果不为空,则优先执行当前调用栈中的代码。
在EventLoop中,每次循环称为一次tick。主要顺序是:
- 执行栈选择最先进入队列的宏任务,执行其同步代码直到结束
- 检查是否有微任务,如果有则执行知道微任务队列为空
- 如果是在浏览器端,那么基本要渲染页面
- 开始下一轮的循环tick,执行宏任务中的一些异步代码,如:setTimeout
注意:最先进行调用栈的宏任务,一般情况下都是最后返回执行的结果。
事实上,EventLoop通过内部两个队列来实现Event Queue放进来的异步任务。以setTimeout为代表的任务称为宏任务,放在宏任务队列(Macrotask Queue)中;以Promise为代表的任务称为微任务,放在微任务队列(Microtask Queue)中。
主要的宏任务和微任务有:
- 宏任务(Macrotask Queue):
- script整体代码
- setTimeout、setInterval
- setimmediate
- I/O (网络请求完成、文件读写完毕事件)
- UI 渲染(解析DOM、计算布局、绘制)
- EventListner事件监听(鼠标点击、滚动页面、放大缩小等)
- 微任务(Microtask Queue):
setTimeout(func,0);
你以为上面的代码会一次打印1和2吗,并不是。因为在JS事件循环机制中,当执行setTimeout时会将事件进行挂起,执行一些其它的系统任务,当其他的执行完毕之后才会执行,因此执行时间间隔是不可控。
<a name="fic3G"></a>
### 微任务
微任务是一个需要异步执行的函数,执行时机是在主函数执行完毕后、当前宏任务结束前。JS执行一段脚本时,v8引擎会为其创建一个全局执行上下文,同时v8引擎会在其内部创建一个微任务队列,这个微任务队列就是用来存放微任务的。
那么微任务是如何产生的呢?
- 使用MutationObserver监控某个DOM节点,或者为这个节点添加、删除部分子节点,当DOM节点发生变化时,就会产生DOM变化记录的微任务。
- 使用Promise,当调用Promise.resolve()或者Promise.reject()时,也会产生微任务。
通过DOM节点变化产生的微任务或使用Promise产生的微任务会被JS引擎按照顺序保存到微任务队列中。
MutationObserver是用来监听DOM变化的一套方法,虽然监听DOM需求比较频繁,不过早期页面并没有提供对监听的支持,唯一能做的就是进行轮询检测。如果设置时间间隔过长,DOM变化响应不够及时;如果时间间隔过短,又会浪费很多无用的工作量去检查DOM。从DOM4开始,W3C推出了MutationObserver可以用于监视DOM变化,包括属性的变更、节点的增加、内容的改变等。在每次DOM节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加到当前的微任务队列中。
MutationObserver采用了"异步+微任务"策略,通过异步操作解决了同步操作的性能问题,通过微任务解决了实时性问题。
JS引擎在准备退出全局执行上下文并清空调用栈的时候,JS引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。在执行微任务过程中产生的新的微任务,并不会推迟到下一个循环中执行,而是在当前的循环中继续执行。
微任务和宏任务是绑定的,每个宏任务执行时,会创建自己的微任务队列。微任务的执行时长会影响当前宏任务的时长。在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论在什么情况下,微任务都早于宏任务执行。
浏览器EventLoop的原理是:
- JS引擎首先从宏任务队列中取出第一个任务
- 执行完毕后,再将微任务中的所有任务取出,按照顺序依次全部执行;如果在此过程中产生了新的微任务,也需要依次全部执行
- 然后再从宏任务队列中取出下一个,执行完毕后,再将此宏任务事件中的微任务从微任务队列中全部取出依次执行,循环往复,知道宏任务和微任务队列中的事件全部执行完毕
注意:一次EventLoop循环会处理一个宏任务和所有此处循环中产生的微任务。
<a name="O7HBj"></a>
## Node.js的EventLoop
Node.js官网的定义是:当 Node.js 启动后,它会初始化事件循环,处理已提供的输入脚本(或丢入 REPL,本文不涉及到),它可能会调用一些异步的 API、调度定时器,或者调用 process.nextTick(),然后开始处理事件循环。<br />![未命名文件 (15).png](https://cdn.nlark.com/yuque/0/2021/png/22745085/1638807447815-4d66bcf1-2f13-455e-a7e2-94a277de02b7.png#clientId=u7553a313-4655-4&crop=0&crop=0&crop=1&crop=1&from=drop&height=390&id=uf708fe7f&margin=%5Bobject%20Object%5D&name=%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20%2815%29.png&originHeight=537&originWidth=638&originalType=binary&ratio=1&rotation=0&showTitle=false&size=36070&status=done&style=none&taskId=ub916edf2-3145-4fb6-b93a-4e0591e2505&title=&width=463)<br />上图是Node.js的EventLoop流程图,我们依次进行分析得到:
- Timers阶段:执行的是setTimeout和setInterval
- I/O回调阶段:执行系统级别的回调函数,比如TCP执行失败的回调函数
- Idle、Prepare阶段:Node内部的闲置和预备阶段
- Poll阶段:检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 node 将在适当的时候在此阻塞。
- Check阶段:setImmediate() 回调函数在这里执行。
- Close回调阶段:一些关闭的回调函数,如:socket.on('close', ...)。
![未命名文件 (16).png](https://cdn.nlark.com/yuque/0/2021/png/22745085/1638808354793-8ce9e696-afd6-44c2-b1f6-9e184bbf00a6.png#clientId=u7553a313-4655-4&crop=0&crop=0&crop=1&crop=1&from=drop&height=437&id=u852e0bc1&margin=%5Bobject%20Object%5D&name=%E6%9C%AA%E5%91%BD%E5%90%8D%E6%96%87%E4%BB%B6%20%2816%29.png&originHeight=602&originWidth=972&originalType=binary&ratio=1&rotation=0&showTitle=false&size=80302&status=done&style=none&taskId=ub20ad878-50c3-4bde-a217-3da1af179ff&title=&width=706)<br />浏览器端任务队列每轮事件循环仅出队一个回调函数,接着去执行微任务队列。<br />而Node.js端只要轮到执行某个宏任务队列,就会执行完队列中的所有当前任务,但是每次轮询新添加到队尾的任务则会等待下一次轮询才会执行。
<a name="rZg1H"></a>
## Process.nextTick()
```javascript
process.nextTick(callback,可选参数args);
Process.nextTick会将callback添加到”nextTick queue”队列中,nextick queue会在当前Javascript stack执行完毕后,下一次EventLoop开始执行前按照FIFO出队。如果递归调用Process.nextTick可能会导致一个无限循环,需要去适当的时机终止递归。
Process.nextTick其实是微任务,同时也是异步API的一部分,但是从技术而言Process.nextTick并不是事件循环(EventLoop)的一部分。如果任何时刻在给定的阶段调用Process.nextick,则所有被传入Process.nextTick的回调,将会在事件循环继续往下执行前被执行,这可能导致事件循环永远无法到达轮询阶段。
为什么Process.nextTick这样的API会被允许存在于Nodejs中呢?
部分原因是因为设计理念,在nodejs中api总是异步的,即使那些不需要异步的地方。
function apiCall(args,callback){
if(typeof args !== "string"){
return process.nextTick(callback,new TypeError("atgument should be string"));
}
}
我们可以看到上面的代码,可以将一个错误传递给用户,但这只允许在用户代码被执行完毕后执行。使用process.nextTick可以保证apiCall()的回调总是在用户代码被执行后,且在事件循环继续工作前被执行。
那么Vue中nextTick又是做啥的呢?
vue异步执行DOM的更新,当数据发生变化时,vue会开启一个队列,用于缓冲在同一事件循环中发生的所有数据改变的情况。如果同一个watcher被多次触发,只会被推入队列中一次。这种在缓冲时去除重复数据,对于避免不必要的计算和DOM操作上非常重要。然后在下一个事件循环tick中。例如:当你设置vm.someData = “yichuan”,该组件不会立即执行重新渲染。当刷新队列是,组件会在事件循环队列清空时的下一个”tick”更新。
- process.nextTick的执行顺序是:每一次EventLoop执行前,如果有多个process.nextTick,会影响下一次时间循环的执行时间
- Vue:nextick方法中每次数据更新将会在下一次作用到视图更新
EventLoop对渲染的影响
requestIdlecallback和requestAnimationFrame这两个方法不属于JS的原生方法,而是浏览器宿主环境提供的方法。浏览器作为一个复杂的应用是多线程工作的,JS线程可以读取并且修改DOM,而渲染线程也需要读取DOM,这是一个典型的多线程竞争资源的问题。所以浏览器把这两个线程设计为互斥的,即同时只能有一个线程进行运行。
JS线程和渲染线程本来是互斥的,但是requestAnimationFrame却让这对水火不相容的线程建立起了联系,即把EventLoop和渲染建立起了联系。通过调用requestAnimationFrame()方法,我们可以在浏览器下次渲染之前执行回调函数,那么下次渲染具体在什么时间节点呢?渲染和EventLoop又有着什么联系呢?
简而言之,就是在每次EventLoop结束前,判断当前是否有渲染时机即重新渲染,而渲染时机是有屏幕限制的,浏览器的刷新帧率是60Hz,即1s内刷新了60次。此时浏览器的渲染时间就没必要小于16.6ms,因为渲染了屏幕也不会进行展示,当然浏览器也不能保证每16.6ms会渲染一次。此外,浏览器渲染还会收到处理器的性能以及js执行效率等因素的影响。
requestAnimationFrame保证在浏览器下次渲染前一定会被调用,实际上我们完全可以将其当成一个高级版的setInterval定时器。它们都是每隔一段时间执行一次回调函数,只不过requestAnimationFrame的时间间隔是浏览器不断进行调整的,而setInterval的时间间隔是用户进行指定的。因此,requestAnimationFrame更适合用于做每一帧动画的修改效果。
requestAnimationFrame不是EventLoop中的宏任务,或者说它并不在EventLoop的生命周期中,只是浏览器又开发的一个在渲染前发生的新hook。此时,我们对于微任务的认知也需要进行更新,在执行requestAnimationFrame的callback函数时,也有可能产生微任务会放在requestAnimationFrame处理完毕之后执行。因此,微任务并不像之前描述的在每一次EventLoop后执行处理,而是在JS函数调用栈清空后处理。
在EventLoop中并没有什么任务需要处理时,浏览器可能处于空闲状态,在这段空闲时间可以被requestIdlecallback利用,用于执行一些优先不高、不必立即执行的任务,如图所示:
同时,为了避免浏览器一直处于繁忙的状态,导致requestIdlecallback函数永远无法执行回调,浏览器提供了一个额外的setTimeout函数,为这个任务设置截止时间,浏览器就可以根据这个截止时间规划这个任务的执行。