深入理解事件循环这一回事
只有深入理解,才能真正理解。
要搞清事件循环,需先明白这两个重要概念–进程、线程,然后我们由外向内逐一引入事件循环这回事。
进程与线程
进程
每个系统都有一套内存空间,在执行不同程序时,就会给程序分配一定的内存空间,每个程序都是在自己所占有的内空间上运行的,不会占用到别的程序的内存。比如你手机上打开一个软件,该应用即被运行,那么手机系统即会为该应用的运行分配一个内存空间,供该应用在该独立空间上自由发挥其功能。
无论是应用还是其它程序,只要运行,就会占用一定的内存空间。
可以简单理解为:程序占用的这块内存空间即为进程。
每个应用至少有一个进程,进程之间相互独立。有了进程后,就可以运行程序代码了。
线程
进程相当于做一项工程,完成这个工程,需要至少1个人力,如果该工程复杂,则需要多个人力,每个人分工执行不同的任务,合作完成该大工程。线程就是这个进程中的“人力”,每个进程至少1个线程,当程序需要同时执行多块代码时则需启动更多线程来执行代码。
所以通俗理解,一项工程“进程”需交由一个或多个人力“线程”完成,而系统为该工程分配的内存空间即为人力线程们开展工程执行任务的场所。
好了,理解了线程与进程,我们由外向内逐一介绍引入事件循环这回事。
浏览器是一个多进程多线程的应用程序
浏览器内部工作极其复杂,为避免互相影响,减少奔溃几率,启动浏览器后,浏览器会自动启动多个进程,各自占据于不同的内存空间,主要进程有浏览器进程、网络进程、渲染进程等。而渲染进程即是与前端开发密切相关的进程,常见浏览器每打开一个标签页即开启一个渲染进程。而渲染进程启动后,会开启一个渲染主线程,HTML、CSS、JS等代码的执行使标签页页面渲染呈现,则都是在该渲染主线程上完成。
渲染主线程
浏览器–多进程多线程的应用程序,而渲染主线程,则是浏览器的一个进程中的一个线程,我们常说的页面渲染,实际上主要过程就是浏览器为某个标签页启动渲染进程(分配了一定内存空间),该进程开启一个渲染主线程,该线程里执行前端网页开发的html、css、js等代码以及计算样式、布局等页面渲染相关事宜。
任务繁杂下渲染主线程的最佳运行方案–事件循环
渲染主线程中有很多待处理的任务,这些任务就是“事件”,事件都是交由渲染主线程来处理的,而一个渲染主线程,怎么处理这些事件保证最高效率呢?那么事件循环就是这逻辑下关键的处理方式了。
我们先通过以下文字了解下事件循环的大致过程(文章后面会以一道例题更具象讲解事件循环过程):
我们先举个例子,就像有很多家务要做,洗衣服晾衣服扫地拖地做饭洗碗,那么有些事情是不需要全都靠自己来做的,比如洗衣服,可以交给洗衣机处理,洗衣机洗衣服时自己继续去扫地拖地,用事件循环的语言来讲,就是,洗衣服晾衣服是异步任务,交给另一个线程洗衣机处理去,我主线程继续做自己马上能做的事(扫地拖地这些现在就能马上做的同步任务),直到洗衣机洗完衣服哔哔哔的(洗衣服处理完毕,那么晾衣服的任务就排入等待由我来操作的队列了),我等这些同步任务都完成后,再回来看洗衣机洗好了没(有没有在队列里了),已经有了,那我就去做队列中的任务晾衣服了。
那么事件循环实际上就是渲染主线程将简单的(同步任务)先完成,无法立马完成的(异步任务)则交给其它相应的线程处理,自己继续执行后续可马上完成的任务(同步任务);而其他相应的线程与此同时对接到的异步任务进行执行处理(比如需要计时的计时线程去计时,需要网络请求的网络线程去请求),搞定完计时任务或者网络请求完毕后,该异步任务附带的回调函数就“有资格”执行啦。有资格是什么意思呢,不是说马上插入还在执行同步任务的主线程哦,主线程很忙的,其它线程处理完异步任务的相关操作后,需友好礼貌地将异步任务的回调任务加入消息队列中排队;等主线程忙完所有同步任务后,就会来消息队列中检查有没有需要待做地任务,队列中有任务,则将其放入主线程中执行任务。
那么怎么保证像点击事件这种随时可能发生的任务能确保被渲染主线程及时检查到呢,这就要求渲染主线程得 永不停歇地 执行主线程任务,主线程任务一旦为空就一直检查消息队列中是否有任务,以第一时间将队列中的任务放入主线程中执行,如何做到永不停歇地执行任务、检查队列是否有任务?渲染主线程的底层代码实际上就是写了一个死循环,在一开始渲染主线程就会进入一个无限循环,这样就可以一直反复进行执行任务、检查队列任务的操作了,因此将该过程称之为事件循环,也叫消息循环。
关于异步的进一步解释:
渲染主线程是浏览器中最繁忙的线程,该线程中,有很多任务要处理(比如读取执行一行一行的代码),还有很多任务是无法立即执行的(比如定时器、用户点击操作、网络请求后的任务),如果渲染主线程等待这些无法立即执行的任务直至完成后再执行下一步代码任务,则会导致后续代码因等待前置无法立即执行的任务而“堵车”,使线程处于“阻塞”状态,从而导致浏览器“卡死”,因此浏览器选择使用异步的方式,防止主线程产生阻塞,具体做法是,当某些无法立即执行的任务发生时(如setTimeout),主线程将该任务交给其他线程处理(如将setTimeout交给计时器线程进行计时处理),自身立即结束该任务的执行,转而执行后续代码,当其他线程完成时(如setTimeout在计时器线程完成设定的倒计时),则将其中传递的回调函数包装成任务加入到消息队列的末尾排队(把setTimeout中的fn函数放入消息队列末尾排队),等待主线程调度执行。
事件循环中执行任务的优先级
队列的任务排序规则是,先进先出,然鹅,事件循环中所谓的消息队列,并不只是一个队列,过去把消息队列简单分为宏队列和微队列(里面的任务即为宏任务微任务),但随着浏览器的复杂度急剧提升,W3C不再使用宏队列微队列的说法,规定可以有多个队列,而这些队列中必须有一个微队列,微队列的任务优先于其他队列的任务,其它队列a,队列b,可以存有执行队列a的任务1后再执行队列b的任务1,也可以执行队列a的任务1后再执行队列a的任务2,这由浏览器自行决定。在目前chrome浏览器的实现中,则根据W3C标准设置了微队列(W3C规定必须要有的,优先级最高的,常见放在微队列的任务如Promise.then())、延时队列(放计时器的回调任务)、交互队列(放用户操作后的事件回调任务)。所以明白为什么Promise.then()执行的优先级高于setTimeout了吗~顺便注意,promise构造函数里的代码是同步执行的,在new时就直接放入主线程中执行了,then里面的才需要放入微队列
所以事件循环执行任务的过程就是:开启一个for循环,每次循环先把主线程中的任务都执行完,再检查消息队列中是否有任务,有则以微队列任务优先的顺序把队列中的任务放入主线程中;接着便进入下一个循环,继续执行主线程中的任务→检查队列→把优先级最高的任务放入主线程→执行主线程任务…
看到这里,相信你不仅对进程、线程有了清晰的认识,也能说明白事件循环具体是怎么一回事,并且常见的八股文题目考察你执行顺序的是不是也能从事件循环原理出发答对其执行顺序结果了呢?
那我们通过以下例子再进一步具象化事件循环这一回事:
执行结果顺序是什么呢?
答案是: 2 10 3 5 4 1
那么结合以上所描述的事件循环流程,以上题为例来演示一下这个代码执行过程吧,如图:
- 首先渲染主线程中,将代码从上往下依次执行:
- 在读取到setTimeout时,识别为异步任务,将其交由其它线程A处理(即过程①),而渲染主线程自己结束当前的setTimeout任务,继续往下读取代码【与此同时,其他线程也在工作,处理异步任务,如其他线程A会对setTimeout任务进行计时处理,当计时任务完成后,则将setTimeout的回调函数fn1包装成任务放入延时队列中,等待被渲染主线程读取执行(即过程⑤)】;
- 主线程继续往下读取代码,当读到new Promise时,由于Promise构造函数里的fn2会在Promise新建new时同步执行,所以渲染主线程中执行new Promise(fn2)就相当于也执行了fn2(),因此fn2是同步任务,会被放入渲染主线程中执行(即过程②),所以如fn2()的执行结果,依次输出2,10,3;
- 接着渲染主线程再继续往下读取代码,读取到Promise.then(),而Promise.then()不是能立马执行的任务,即异步任务,因此需放入队列中,那么放入哪个队列呢,Promise.then()就是典型要放入微队列里的任务,因此将Promise.then()中的回调函数fn3包装成任务放入微队列中(即过程③),渲染主线程结束当前任务继续往下读取代码;
- 然后读取到console.log(5)为同步任务,放入渲染主线程中直接执行(过程④),所以打印输出5。
- 这时候渲染主线程中fn2与打印5的这些同步任务都执行完毕了,没其他同步任务了,则会开始检查队列中是否有待执行的任务;
- 首先优先检查微队列,微队列中有fn3任务,因此将fn3任务放入渲染主线程中执行,输出4,
- 接着继续检查队列任务,此时微队列已无任务(fn3已被取出放入主线程中处理了),接着再按一定顺序(浏览器自行规定)检查其他队列,检查到延时队列还有个任务fn1,因此将fn1任务取出放入渲染主线程中执行,输出1;
- 执行完毕后,渲染主线程任务都完成了,检查队列中是否有待执行的任务,发现队列里都没有任务待执行了,因此进入休眠状态(但会一直在无限循环里保持检测队列中是否有出现新的待执行任务,如点击事件的回调函数任务,一旦出现有任务,就会停止休眠状态,将队列中新增的任务放入渲染主线程中执行)。
因此上述代码执行结果为2,10,3,5,4,1。