事件循环
默认情况下,浏览器的每一个页面对应着一个渲染进程,渲染进程主要包括主线程、合成线程、I/O线程等多个线程。主线程的工作相较于更为繁忙,需要处理DOM、计算样式、处理布局、处理事件的响应、执行JS代码等。
那么可能会有以下两个问题:
- 如何去调度这些任务(包含线程内的和线程外的)?
- 在主线程工作的时候新任务是如何参与调度的?
第一个问题答案是:所有参与调度的 任务会加入任务队列中。根据队列“先进先出“的特性,最早加入队列的任务会被优先处理。
//从任务队列中取出任务
const task = taskQueue.takeTask()
//执行任务
processTask(task)
其他线程通过IPC将任务发送给渲染进程的I/O进程,I/O进程再将任务发送给主线程的任务队列,比如:
- 点击鼠标后,浏览器进程通过IPC将”点击事件“发送给I/O线程,I/O线程将其发送给任务队列。
- 资源加载完成之后,网络进程通过IPC将”加载完成事件“发送给I/O线程,I/O线程将其发送给任务队列。
第二个问题的答案是:新任务通过事件循环参与调度。主线程会在循环语句中执行任务。随着循环的一直进行,加入的新任务会位于队列的末尾,之前加入的任务会被取出并执行。
//循环进行标志
let keepRunning = true;
//主线程
function MainThread() {
//循环执行任务
while(true) {
//从任务队列中取出任务
const task = taskQueue.takeTask();
//执行任务
processTask(task);
if(!keepRunning) {
break;
}
}
}
除了任务队列外,浏览器还根据WHATWG标准实现了延迟队列,用于存放需要被延迟执行的任务(如setTimeout等)
function MainThread() {
while(true) {
const task = taskQueue.takeTask();
processTask(task);
//执行延迟队列中的任务
processDelayTask();
if(!keepRunning) {
break;
}
}
}
当执行完processTask之后,浏览器将会去检查是否有延迟的任务过期,如果存在,那么取出该过期任务通过processDelayTask进行执行。由于processDelayTask放在processTask之后执行,因此当processTask任务执行的时间过长时,就可能会导致延迟任务不能按期执行。如下面的例子:
function helloWorld() {console.log("hello World")}
function foo() {
setTimeout(helloWorld, 0);
for (let i = 0; i < 10000; i++) {
console.log(i);
}
}
foo();
即使helloWorld的延迟时间设置的为0,但是也要等到foo函数所在任务执行完成之后才能执行。同时,setTimeout并不属于ECMAScript标准,其规范由WHATWG中的HTML标准实现。各宿主环境在实现时预设了最小延迟时间,比如在Chromium中,最小的延迟时间为4ms。所以helloWorld最终的延迟时间肯定是超过了设定的延迟时间的。
如上加入任务队列的新任务需要等待队列中其他任务都执行完后才能执行,这种情况对于”突发情况下需要优先级执行的任务“是不利的。那么该如何解决呢?这时就引入了微任务。任务队列中的任务被称为宏任务,为了解决时效性问题,在宏任务执行的过程中可以产生委任务,该微任务保存在该宏任务的执行上下文的微任务队列中,在宏任务执行结束前,线程会遍历其微任务队列,将其宏任务产生的微任务批量执行完。
那么微任务是如何解决时效性问题并且兼顾性能的呢?例如一个监控DOM变化的微任务API——MutationObserver。当同一个宏任务中发生多次DOM变化时,会产生多个MutationObserver微任务,其执行时机为“该宏任务执行结束前”,相比较于“作为新的宏任务进入队列等待执行”,这种机制更能保证时效性。同时,由于微任务队列内的微任务被批量执行,相比较于每次DOM变化都同步执行MutationObserver回调性能更佳。