一、什么是事件循环机制?
ECMScript标准里并没有任何事件循环机制的概率。 在HTML的标准定义(whatwg)里,有一段描述:
To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.
从这个描述里,我们可以了解到:在web环境里,为了能够协调好:事件、用户交互、脚本的执行、页面的渲染、网络请求等事情,必须使用event loops机制来处理。 而event loop在每一个环境里还不同。
由此,我们知道,事件循环并不是由js的执行引擎提供的,而是宿主环境提供的。web环境的宿主环境就是由浏览器来提供的。
二、事件循环机制是如何协调好js的事件的?
web环境和nodejs环境的event loop机制会略有不同。 以下介绍的是web环境下的event loop,nodejs是另一个宿主环境,v8只是js的执行引擎,关于nodejs的event loop机制参考:[[事件循环机制]]
2.1 用户操作、IO等事件是怎么传递到js的?
首先,我们来想像一下这么一个场景:
用户在打开一个页面,在页面里填写了表单,然后点击提交按钮,将数据发送到服务器上。
在用户进行输入、点击"提交"这些动作时,我们的浏览器是怎么捕捉到这些操作。
其实,在浏览器的多进程架构里,用户的输入、点击操作是由主进程来处理的,然后通知渲染进程的。
类似的,网络IO也是通过网络进程来处理,等网络数据加载完成后,再通知到渲染进程的。
在渲染进程里,也分很多线程,其中:
- 主线程负责处理HTML、CSS和JavaScript。它会解析HTML生成DOM,处理CSS生成CSSOM,合并DOM和CSSOM生成渲染树。对于JavaScript代码,主线程会负责解析和执行。此外,主线程还会进行布局和绘制任务,将生成的渲染树转化成屏幕上的像素。
- I/O线程 : 负责和网络进程通信,将网络进程中返回的I/O数据存放到任务队列里,等待主线程空闲的时候进行处理。
- 事件触发线程: 处理主进程发送过来的事件,比如鼠标点击或者键盘输入。该事件会被放入到一个任务队列中,交给主线程处理。
2.2 为什么会出现事件循环机制
?
上面提到了浏览器的渲染进程里,实际上维护着一个任务队列,这个队列里存放着各种其他进程发送过来的事件。
事件队列的任务什么时候去处理呢?
于是就有了事件循环机制* *
渲染主线程的事件循环机制会不断地从队列头部读取任务,然后执行任务,再读取、再执行...;
2.3 队列里的任务执行顺序?
在js代码的执行过程里,会产生无数的事件,这些事件是如何排队执行的?
这里就牵扯到了宏任务与微任务了。
js里有两种队列类型:
- JavaScript外部的队列:外部队列主要是浏览器协调的各类事件的队列,标准文件中称之为 Task Queue。
- JavaScript内部的队列: 这部分主要是 js内部执行的任务队列,标准中称之为 Microtask Queue。
2.4 Task Queue
上面说的,一些由其他进程发送过来的时间,都是存放在这里,常见的事件源:
- 收到网络进程的 HTML 页面数据,于是将"解析 DOM"任务添加到任务队列;
- 用户改变了浏览器窗口大小,于是将"重新布局"任务添加到任务队列;
- 用户点击了按钮,于是将"按钮触发了点击, 需要生成click event"加入到任务队列;
- 异步ajax的网络请求有响应了...
- 定时任务触发了,于是将"执行其回调任务"加入到任务队列;
- History API ...
可以看到外部事件源种类很多。s
scripts 执行也是一个事件
- 当外部脚本
<script src="...">
加载完成时,任务就是执行它。 - 当用户移动鼠标时,任务就是派生出
mousemove
事件和执行处理程序。 - 当安排的(scheduled)
setTimeout
时间到达时,
2.5 Microtask Queue(内部队列,又叫微任务队列)
微任务队列主要是解决:如何执行高优先级任务的问题。
举个例子,比如用户在浏览器中点击了一个按钮,这个点击行为会生成一个click事件,浏览器将这个事件加入到任务队列(也称为宏任务队列)。
假设我们为这个按钮绑定了一个事件监听器,事件监听器中的代码会在click事件触发时执行。此时,我们在事件监听器中执行了一个Promise异步操作,比如模拟网络请求,当请求成功后,我们执行Promise的resolve()方法。
Promise的resolve()方法会将Promise的状态变为成功(fulfilled),并且将.then()方法中传入的回调函数加入到了微任务队列中去等待执行。
现在,我们有点击事件这个宏任务在等待执行,我们也有.then()中的回调函数这个微任务在等待执行,那么浏览器应该先执行哪一个呢?
如果我们采用同步的方式,也就是说,点击事件触发后,立刻执行.then()中的回调函数,这样做会阻塞当前的宏任务,也就是说,我们需要等待回调函数执行完毕(包括其中可能存在的其它的异步操作)才能继续执行下一个宏任务,这样显然会降低执行效率。
另一种做法是,我们将.then()中的回调函数加入到宏任务队列的末尾,等待下一个宏任务执行。但是,这样做又会导致我们的回调函数执行延迟,因为在执行回调函数之前,还需要当前宏任务队列中的其它任务全部执行完毕,这样做显然会降低实时性。
因此,有了一个微任务队列作为中间的解决方案。也就是说,点击事件触发后,我们先执行完当前宏任务(点击事件的处理),然后在两个宏任务之间,我们去执行微任务队列中的任务(.then()中的回调函数)。这样做既不会阻塞当前的宏任务,也能尽快地执行我们的回调函数,从而达到我们既想要的执行效率,又能保证实时性。
所以微任务队列里存放的其实就是 JavaScript 执行过程中产生的优先级比较高的任务。
- Promise
- MutationObserver
三、浏览器一次完整的tick流程
我们把每次完整的事件循环,叫做一个ticket。
每一个ticket里:
1、首先会去macroTask queue里取一个可执行任务,进行执行;
怎么挑选任务,并没有定量的优先级规定,由user agent来进行决定。具体的实现细节上,可能因浏览器和环境的不同而有所差异。
一般情况下,它们在被放入任务队列时都是按照先进先出(FIFO)的顺序来执行的。也就是说,先触发的外部事件会优先被放入任务队列,因此也会被优先处理和执行。
然而,这并不代表浏览器对所有事件都是均等处理的。例如,用户交互事件的响应速度对于用户体验非常重要,因此浏览器可能会优先处理这一类事件。同样,网络请求回调也需要及时处理,以避免过长的等待时间。又或者任务队列中的任务过多或某个任务的执行时间过长,浏览器可能会对任务进行重新排序,改变一些任务的优先级。
2、当执行完一个macroTask后,或者如果macroTask queue里没有任务,就会去执行微任务队列里的任务。在微任务队列里,会挨个执行所有任务;
如果微任务执行过程中产生了微任务,会继续放到当前微队列里。
3、等微任务队列都执行完以后,会去检查是否有requestAnimationFrame的回调;如果有,那么执行requestAnimationFrame里的回调。
4、最后,会进行浏览器的render更新操作。
以上,完成了一次event loop之后,会重复执行event loop。
3.1 关于requestIdleCallback
我们说:浏览器的页面渲染频率是60hz(1s60次)。如果一次loop里的任务执行超过了(1/60 =16.67ms)这个时间,那么就让用户感知页面渲染卡顿。
如果小于这个时间,那么剩余的时间,可以利用来执行其他的任务。
这就是requestIdleCallback的机制:利用每次loop的剩余时间,如果离下次渲染还有时间,就会去执行这里的任务。
所以一次tick的流程应该是:
宏任务 => 微任务 => requestIdleCallack => requestAnimationFrame => render()
当然,如果requestIdleCallback还支持设置超时时间,如果event loop一直没空闲时间,那么一旦requestIdleCallback等待超过了设置的超时时间,也会执行。
react16的新引擎fiber,就是利用这个机制,将dom渲染从一个递归任务改成了多个小的任务,放到任务队列里,来做调度更新,从而减少渲染的消耗时间。#[FiberNode]