用一篇文章去弄懂浏览器进程的事件循环EventLoop

503 阅读6分钟

前言

老生常谈的event loop,贯穿了整个浏览器生命的每个角落,在阅读了一些优秀的文章后,期望通过这篇文章让各位读者清晰理解到事件循环中的宏任务跟微任务,以及在日常中跟事件循环紧密联系的情况。

什么是事件循环(Event Loop)

Javascript是一门单线程语言,而线程又是隶属于进程的一个任务,而要完全把一个页面渲染出来,其中包括了很多进程跟线程,而Js引擎会根据一定的规则有条不紊地执行每一个线程,保证每个程序的顺利执行。

同步任务与异步任务

在理解事件循环前,先把这两个概念理清楚

同步:形成阻塞,等到指令完成再往下执行,如果该任务执行时间过长,后面的也只能一直等下去

异步:把事件挂起来到专门处理异步任务的队列中,执行栈继续后面脚本的执行,事件完成后再把信息返回给主线程

宏任务和微任务

浏览器的异步任务分宏任务跟微任务两种,其中宏任务里面包含了一个或多个微任务。下面来看两种任务在线程中的执行情况

宏任务包括:setTimeout、setInterval、requestAnimationFrame、Ajax、fetch、script 标签的代码

微任务包括:Promise.then、MutationObserver、Object.observe,async await后 注意 async await function,先执行function再执行await,后面的脚本会排到微任务队列末尾

任务队列

宏任务和微任务的队列,采用先进先出的形式,哪个任务先排队,就先执行哪个任务。

任务队列.png

事件循环定义

当函数执行栈为空时,从宏任务队列取一个任务来执行,当执行栈空时,再次从宏任务队列里获取任务,如此循环执行,就是所说的事件循环。

事件循环.png

循环过程

  1. 所有同步任务都在主线程上依次执行,形成一个执行栈,异步任务在异步处理模块执行,完成后则放入任务队列
  2. 当执行栈中任务执行完,检查微任务队列里的微任务是否为空,有就执行,如果执行微任务过程中又遇到微任务,就添加到微任务队列末尾继续执行,直到把微任务全部执行完
  3. 微任务执行完后,再到任务队列检查宏任务是否为空,有就取出最先进入队列的宏任务压入执行栈中执行其同步代码
  4. 然后回到第2步执行该宏任务中的微任务,循环执行,直至宏任务队列的任务全部执行完毕
  5. 执行渲染操作,更新页面
  6. 如果有web worker任务,则进行处理

用更加简洁的语言去描述就是: 一个宏任务-此宏任务里面的微任务-渲染-下一个宏任务....

那下面重点来了

为什么浏览器的机制要这么设计? 这么设计的好处是什么?

设计事件循环的好处

  • 如果没有异步任务的设计,那么主线程将一直单线往下执行,如此一来就会对所有的方法行程阻塞,试想一下,如果调用一个接口去获取数据,这时候浏览器一直在等待响应而不去执行后面的逻辑,无疑会大大拖慢渲染的速度。当前的loop机制跟任务队列实现了异步任务。
  • 微任务是直接加入到该宏任务队列的后尾,如果没有微任务,那么所有的任务只能加入到宏任务的末尾,如此一来,如果有很急的事件需要处理,那么它只能等到所有宏任务完成才会触发。而现在的浏览器机制,我们可以写一个微任务,它会在当前宏任务的末尾执行,而不用等到后续的宏任务执行完成。俗称插队机制。

关键词: 阻塞,优先级

举例搞懂

来个简单例子看看:

setTimeout( () => {
	console.log('setTimeout 1')
})
const promise = new Promise((resolve, reject) => { 
	console.log(1); 
	resolve(); 
	console.log(2);    
}); 
promise.then(() => { 
	console.log(3);    
});
  • 遇到setTimeout,插入到宏任务队列中,先往下继续走
  • 遇到promise对象,先执行console.log(1)
  • 遇到promise.then,属于微任务,排到微任务队列的末尾,但优先级高于上述的setTimeout宏任务
  • 执行console.log(2)
  • 扫了一下,微任务还有一个console.log(3)
  • 执行宏任务最后一个任务console.log('setTimeout 1')

因此最后打印出来的结果如下:

1
2
3
setTimeout 1

在日常中我们遇到的情况会比这个例子复杂很多,但只要条理清晰,弄清楚宏任务跟微任务的执行顺序,再多的代码都能解读出来,但也不建议把太多方法硬塞到一个script中增加阅读难度。举一个进阶版的例子看看

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async3');
};

async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0);

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});

console.log('script end2');
  • 打印script start
  • 遇到setTimeout,把它置于宏任务队列末尾,在0秒后执行,但不是马上,是当前执行栈执行完毕才执行
  • 执行async1
  • 打印async1 start
  • 遇到await 先执行async2(),执行完毕,把await放到微任务队列后,故后面的async3也无法马上执行
  • 线程内的promise1正常打印,后面的promise.then属塞到微任务的后面
  • 打印script end2
  • 正常顺序的微任务已执行完成,现在去找队列后面新加入的任务
  • 第一个加入后尾的是await后面的微任务:打印async3
  • 第二个加入的微任务:promise.then, 打印promise2
  • 微任务队列执行完毕,出来发现最后还有一个宏任务需要执行,打印setTimeout

下面是最终的打印结果,

script start
async1 start
async2
promise1
script end2
async3
promise2
setTimeout3
setTimeout2

总结

JavaScript 最早是用于写网页交互逻辑的,为了避免多线程同时修改 dom 的同步问题,设计成了单线程,又为了解决单线程的阻塞问题,引入了同步跟异步任务,把异步任务放到了异步处理模块执行,跟同步任务隔开。而异步任务又分宏任务跟微任务,其执行顺序逻辑就是 Loop 循环和 Task 队列,通过微任务实现了任务的优先级调度。

这就是浏览器的 Event Loop 机制:每次执行一个宏任务,然后执行所有微任务,重复如此