简析JavaScript事件循环机制

145 阅读7分钟

JavaScript事件循环机制

一 .概念

1. 进程与线程

进程是资源分配最小单位,线程是程序执行的最小单位。

计算机在执行程序时,会为程序创建相应的进程,进行资源分配时,是以进程为单位进行相应的分配。每个进程都有相应的线程,在执行程序时,实际上是执行相应的一系列线程。

2. 一个进程包含多个线程

  • java c c++ 多线程编程 可以通过代码创建线程

    • 多线程同时并发执行
  • javascript语言 单线程 不能自己创建线程

    • javascript引擎单线程负责js代码

二.为什么javascript语言不能像java语言允许我们创建线程?

作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,JavaScript就是单线程。

引入单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。这同时又导致了一个问题:如果前一个任务耗时很长,后一个任务就不得不一直等着。

为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

三.javascript中的事件循环机制

JS是一门单线程语言,所有的任务都需要排队,前一个任务执行完成,才会执行下一个任务。如果JS执行的时间过长,就会造成页面的渲染不连贯,导致页面渲染加载阻塞。为解决此问题,JS引入了同步任务和异步任务。

同步任务: 主线程上的任务,按顺序依次执⾏,当前⼀个任务执⾏完毕后,才能执⾏下⼀个任务。

异步任务: 主线程外的任务,是进⼊任务队列的任务,执行完毕之后会产生一个回调函数,并且通知主线程。当主线程上的任务执行完后,就会调取最早通知自己的回调函数,使其进入主线程中执行。

微任务和宏任务

如上除了同步任务和异步任务的区分之外,异步任务还有更精确的区分:

  • 微任务(micro-task) job
  • 宏任务(macro-task) task
宏任务(macrotask)微任务(microtask)
谁发起的Node、浏览器JS引擎
具体事件script 网络请求(Ajax) setTimeout/setInterval dom 事件 postMessage MessageChannel setImmediate I/O(Node.js)Promise.then async/awit MutaionObserver Object.observe(已废弃,Proxy 对象替代) process.nextTick(Node.js)
谁先运行后运行先运行
会触发新一轮Tick吗不会

1.一个简单的例子

 console.log(1)
 ​
 setTimeout(() => {
     console.log(2)
 }, 1000)
 ​
 setTimeout(() => {
     console.log(3)
 }, 0)
 ​
 Promise.resolve().then(()=>{
     console.log(5);
 })
 ​
 console.log(4)
 ​
 //输出结果:1  4  5  3  2

同步任务和异步任务都是由js引擎来调度管理的,在这其中维护了一组任务队列(Event Queue);当执行到setTimeout时会将回调放入到宏任务队列,当执行到Promise then方法时会将会回调放入到微任务队列,当同步任务执行完成之后,就会去任务队列中的读取异步任务拿出来放到主线程中依次执行,首先会将微任务队列清空,然后再读取宏任务队列。

2.Event Loop 执行机制

  1. 进入到script标签,就进入到了第一次事件循环.
  2. 遇到同步代码,立即执行
  3. 遇到宏任务,放入到宏任务队列里.
  4. 遇到微任务,放入到微任务队列里.
  5. 执行完所有同步代码即执行栈清空
  6. 取出微任务队列代码到栈区执行
  7. 微任务代码执行完毕,本次队列清空
  8. 寻找下一个宏任务,重复5

以此反复直到清空所以宏任务,这种不断重复的执行机制,就叫做事件循环,流程图如下:

Event Loop.png 到这里你应该应该清楚上述代码的执行顺序的原因,但是这只是基于代码层面的,实际开发中往往更加复杂。

异步任务就只有微任务和宏任务吗?

思考下一下代码:

 //黑色
 document.body.style.background = 'black';
 ​
 Promise.resolve().then(() => {
   //红色
  document.body.style.background = 'red';
 }).then(() => {
   //绿色
   document.body.style.background = 'green';
 });
 ​
 ​
 requestAnimationFrame(() => {
   //橙色
   document.body.style.background = 'orange';
 });
 ​
 setTimeout(() => {
   //蓝色
   document.body.style.background = 'blue';
 }, 200);

以上代码依次将body背景色改色,在变成橙色的一瞬间,最终变成了蓝色,你可以将其copy到控制台执行试试看效果。

结合上述讲解最终变成蓝色是没问题的,但是为什么会先变成橙色再变成蓝色呢,在这之前的黑色、红色和绿色呢?

3.从浏览器渲染顺序看异步执行机制

以上代码的运行结果在这里需要结合浏览器的渲染顺序来理解它。

我们来分析一下:

首先将body背景色变成黑色,然后遇到Promise then方法,依次将body背景色改成红色、绿色,我们之前提到Promise then方法属于微任务,该任务会在宏任务执行之前被全部清空,然后是执行requestAnimationFrame方法将body背景色改成橙色,最后是setTimeout宏任务将背景色改成蓝色。

这是代码的执行顺序,为什么之前的设置的背景色没有生效呢,只有requestAnimationFrame和setTimeout设置的生效了呢,很显然之前设置的被覆盖掉了。

结合同步任务和异步任务的讲解,我们知道同步代码先执行,首先设置背景色为黑色,然后清空微任务队列依次设置背景为红色、绿色,然后执行了requestAnimationFrame设置背景色为橙色,最后setTimeout将背景色变成蓝色。requestAnimationFrame是在setTimeout之前执行的,最后才算执行setTimeout,很显然requestAnimationFrame的执行时机比setTimeout更靠前,但是为什么会有先变成橙色再变成蓝色闪现的效果呢?

原因是在requestAnimationFrame和setTimeout执行顺序之间还穿插了GUI渲染操作,也就是我们经常说的浏览器绘制,当requestAnimationFrame执行完之后浏览器进行GUI渲染重新绘制页面,然后再执行setTimeout方法将背景色改成蓝色。

4.requestAnimationFrame

上述提到异步任务中分微任务和宏任务,那么requestAnimationFrame是什么东西呢,它是属于微任务还是宏任务呢?为什么它在setTimeout之前执行呢?我们可以在 MDN 看到关于requestAnimationFrame的描述:

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

语法:window.requestAnimationFrame(callback);

意思是说requestAnimationFrame是在浏览器绘制页面之前最后修改DOM元素的时机,文档中并未提到微任务和宏任务,这说明它并不属于这两者之间,它是独立于任务队列的,是由浏览器渲染进程来调度的,因为它独立于同步任务和异步任务,不存在同步异步阻塞的情况,所以一般实现动画效果使用它来实现比setTimeout更合适。

结合以上代码示例和讲解我们可以总结出javascript事件循环的执行顺序:

event- loop.png 如上图所示,我将它分为两步,首先执行第一个宏任务,也就是script代码块,将script代码块中的同步任务放入主线程中执行,同步任务执行完成之后取出微任务中队列中的所有任务依次执行,然后执行requestAnimationFrame中的回调,其次是GUI渲染,最后执行setTimeout。第一步在脚本加载完成之后执行,其次不断循环第二步,这就是javascript事件循环的具体流程。

四.总结

  • javascript是单线程语言,同步任务同步执行,异步任务异步执行
  • 异步任务分为微任务和宏任务
  • requestAnimationFrame是独立于任务队列的,它是浏览重新绘制页面之前操作DOM的最后时机