理解JS的事件循环机制(Event Loop)

文章目录

  • 一、前言
  • 二、首先理解
  • 三、灵魂三问
    • 1. JS为什么是单线程的?
    • 2. 为什么需要异步? (为什么要有事件循环机制?)
    • 3. 单线程又是如何实现异步的呢?
  • 四、什么是事件循环?
  • 五、事件循环(Event Loop )执行顺序
  • 六、事件循环实例讲解
  • 七、关于setTimeout
  • 八、思考问题
    • 1. JS 中的计时器能做到精确计时吗?为什么?
    • 2. 为什么script(整体代码)是宏任务却优先执行?


一、前言

JS是单线程语言,但是又可以做到异步处理高并发请求,这时就用到了JavaScript的事件循环机制。
理解事件循环,可以帮助我们准确分析和运用各种异步形式,减少代码的不确定性,在一些执行效率优化上也能有明确的思路。

二、首先理解

  • JS是单线程语言
  • 分为 同步任务异步任务
  • 同步任务:立即执行的任务;在主线程上排队执行,形成一个执行栈;只有前一个任务执行完毕,才能继续执行下一个任务。
  • 异步任务:不进入主线程,而进入“任务队列”的任务;只有等主线程任务全部执行完毕。“任务队列”的任务才会进入主线程执行。
  • 任务队列分为 微任务队列宏任务队列
  • 微任务:较短时间内可以完成的任务
  • 宏任务:需要相对较长时间才能完成的任务
微任务(microtask) 宏任务(macrotask)
谁发起的 JS引擎 宿主(Node、浏览器)
具体事件 Promise.then()/.catch()、async await、MutaionObserver、nextTick(Node.js 环境) script(整体代码)、setTimeout/setInterval 、UI渲染任务、事件处理器、I/O操作(如文件读取)、Ajax网络请求等异步任务
谁先执行 先执行 后执行
会触发新一轮事件循环吗 不会
  • :是一种后进先出的数据结构,数据元素在插入(即进栈)和删除(即出栈)时均从栈顶进行操作。
    类似于堆在一起的餐盘,最先放的盘子在最底下,最后放的盘子在最上面,需要把最上面的盘子一个个拿走,才能拿到最下面的盘子。
  • 队列:是一种先进先出的数据结构,数据元素在队尾插入而从队首删除的。
    类似于我们去排队买东西,先去的同学可以先买到。

理解JS的事件循环机制(Event Loop)_第1张图片

三、灵魂三问

1. JS为什么是单线程的?

JS引擎之所以是单线程,是由于JavaScript最初是作为浏览器脚本语言开发的,并且JavaScript需要操作DOM等浏览器的API,如果多个线程同时进行DOM更新等操作则可能会出现各种问题(如竞态条件、数据难以同步、复杂的锁逻辑等),因此将JS引擎设计成单线程的形式就可以避免这些问题。

如果JS是多线程的场景描述:
现在有2个线程,process1 process2,由于是多线程的JS,所以他们对同一个dom,同时进行操作;
process1 删除了该dom,而process2 编辑了该dom,同时下达2个矛盾的命令,浏览器究竟该如何执行呢?这时可能就会出现问题了。
这样想,JS为什么被设计成单线程应该就容易理解了吧

2. 为什么需要异步? (为什么要有事件循环机制?)

如果JS中不存在异步,只能自上而下执行;如果上一行解析时间很长,那么下面的代码就会被阻塞。对于用户而言,阻塞就意味着"卡死",这样就导致了很差的用户体验。所以JS中存在异步执行。

3. 单线程又是如何实现异步的呢?

异步的核心就是事件循环机制(Event Loop)。
JS遇到异步任务会挂起,交给其他线程去处理。一旦满足触发条件,对应的回调函数会被放入任务队列中。
当 JavaScript 引擎空闲下来,也就是当前的执行栈已经清空时,JavaScript 引擎才会去查询任务队列中是否有需要执行的异步任务,这就是保证异步代码不会阻塞其他任务执行的关键。

四、什么是事件循环?

事件循环是JavaScript实现异步的一种方法,也是JavaScript的执行机制。
事件循环又叫消息循环,是浏览器渲染主线程的工作方式。

因为 js 是单线程运行的,在代码执行时,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行。

  1. 先执行同步任务,如果遇到异步任务,js 引擎并不会一直等待其返回结果,而是会将这个任务挂起,交给其他线程去处理。自己继续执行执行栈中的其他同步任务。
  2. 当异步任务执行完毕后,再将异步任务对应的回调函数加入到一个任务队列中等待执行。
  3. 任务队列可以分为宏任务队列微任务队列,当执行栈中的事件执行完毕后,js 引擎首先会判断微任务队列中是否有任务可以执行,如果有,就将微任务队首的事件压入栈中执行。队列遵循先进先出原则。
  4. 当微任务队列中的任务都执行完成后,再去执行宏任务队列中的任务。
  5. 如果宏任务队列中有微任务,继续执行微任务。如此反复循环,直至任务队列为空。这就是JavaScript的事件循环机制

总结JS代码执行顺序:同步任务 => 微任务 => 宏任务。

需要注意的点:

  1. 所有的代码都要通过函数执行栈(主线程)中调用执行。
  2. 等到执行栈中的task执行完之后再回去执行任务队列之中的task。
  3. 任务队列中存放的是回调函数
  4. 执行微任务过程中产生的新的微任务并不会推迟到下一个循环中执行,而是在当前的循环中继续执行。
  5. 当执行一个宏任务时,如果宏任务中产生了新的微任务,这些微任务不会立刻执行,而是会被放入到当前微任务队列中,在当前宏任务执行完毕后被依次执行。

JS的事件循环也可以用一张图来表示,图文结合更容易理解。
理解JS的事件循环机制(Event Loop)_第2张图片

五、事件循环(Event Loop )执行顺序

  1. 首先执行同步代码,这属于宏任务。
  2. 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行。
  3. 执行所有微任务。
  4. 当执行完所有微任务后,如有必要会渲染页面。
  5. 然后开始下一轮 Event Loop,执行宏任务中的异步代码。

也就是说,一次 Event Loop 循环会处理一个 宏任务 和 所有这次循环中产生的微任务。

六、事件循环实例讲解

上面描述了事件循环机制的由来,概念。下面结合实例将概念运用起来。

实例1:

 setTimeout(function(){
     console.log('定时器setTimeout')
 },0);
 
 new Promise(function(resolve){
     console.log('同步任务1');
     for(var i = 0; i < 10000; i++){
         i == 99 && resolve();
     }
 }).then(function(){
     console.log('Promise.then')
 });
 
 this.$nextTick(() => {
    console.log('nextTick')
 })

 console.log('同步任务2');
 
// 执行结果: 同步任务1 => 同步任务2 => nextTick => Promise.then => 定时器setTimeout

分析执行过程:

  1. 首先执行 script 下的宏任务,遇到 setTimeout,0秒之后定时器运行完毕,把它放到 宏任务队列 中。
  2. 遇到 new Promise 直接执行,打印 “同步任务1”。
  3. 遇到 then 方法,属于微任务,把它放到 微任务队列 中。
  4. 遇到 nextTick,属于微任务,把它放到 微任务队列 中。在同步代码执行完成后,不管其他异步任务,先尽快执行 nextTick。
  5. 打印 “同步任务2”。
  6. 本轮宏任务执行完毕,查看本轮的微任务,发现有一个 then 方法里的函数,打印执行 “Promise.then”。
  7. 到此,第一轮的 Event Loop 全部完成。(第一轮的宏任务和宏任务下的微任务都被执行过了)。
  8. 下一轮的循环里,先执行一个宏任务,发现 宏任务队列 有一个 setTimeout 里的函数,执行打印 “定时器setTimeout”。

顺便提一下,在浏览器中 setTimeout 的延时设置为 0 的话,会默认为 4ms,NodeJS 为 1ms。
具体值可能不固定,但不是为 0。

实例2:

console.log('同步代码1');

async function async1 () {
   console.log('async1 start')
   await async2()
   console.log('async1 end')
}
async function async2 () {
   console.log('async2')
}
async1()
    
setTimeout(() => {
    console.log('setTimeout1')
    new Promise((resolve) => {
        console.log('Promise 1')
        resolve()
    }).then(() => {
        console.log('promise.then1')
    })
}, 10)

new Promise((resolve) => {
    console.log('同步代码2, Promise 2')
    setTimeout(() => {
        console.log('setTimeout2')
    }, 10)
    resolve()
}).then(() => {
    console.log('promise.then2');
    setTimeout(() => {
        console.log('setTimeout3')
    }, 10);
    new Promise((resolve) => {
        console.log('Promise 3')
        resolve()
    }).then(() => {
        console.log('promise.then3')
    });
})

console.log('同步代码3');

/* 宏任务队列中第1个宏任务script的打印:*/
// 同步代码1
// async1 start
// async2
// 同步代码2, Promise 2
// 同步代码3
// async1 end
// promise.then2
// Promise 3
// promise.then3

/* 宏任务队列中第2个宏任务setTimeout1的打印:*/
// setTimeout1
// Promise 1
// promise.then1

/* 宏任务队列中第3个宏任务setTimeout2的打印:*/
// setTimeout2

/* 宏任务队列中第3个宏任务setTimeout3的打印:*/
// setTimeout3

需要注意的点:

  1. promise中的回调函数立刻执行,then中的 回调函数 会推入微任务队列中,等待执行栈所有任务执行完才执行。
  2. 通过 async 定义的函数在 执行栈 中执行,await 将异步程序变成同步,所以await 后面执行的程序需要等到await定义的函数执行完毕才执行,需要放入微任务队列中等待的。

七、关于setTimeout

下面这段代码我们一般会说,3秒后,执行setTimeout里的那个函数。

 setTimeout(function(){
    console.log('执行了')
 },3000)    

其实这样说并不严谨,准确的说应该是:

通过 定时触发器线程 来计时并触发定时,3秒后,setTimeout里的函数会被推入任务队列,等JS引擎线程(主线程)空闲后才会推入主线程执行。

所以只有同时满足下面两点才会3秒后执行该函数。

  • 3秒后
  • 主线程空闲时

如果主线程有很多内容需要执行,执行时间超过3秒,比如10秒,那么这个函数只能10秒后才会被推入主线程执行了。

经典面试题案例

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
      console.log('异步任务', i);
    }, 1000);
}

console.log('同步任务', i);
//执行顺序 5 => 5,5,5,5,5; 即第一个5直接输出,1秒后,输出5个5
//但是我们想要的执行顺序是5 => 0,1,2,3,4

执行结果解析:

1)首先 i = 0,满足条件,执行栈执行循环体里的代码,遇到setTimeout是异步操作,它会将回调函数推入到 任务队列 中
2)当 i = 1,2,3,4 时,均满足条件,情况和i = 0相同,因此现在任务队列中有5个相同的延时执行的函数。
3)当 i = 5 时,不满足条件,for循环结束。 console.log(‘同步任务’, i)入栈,此时i值被累加到了5。因此输出5。
4)此时1s已经过去,5个回调函数按照顺序推入任务队列。
5)主线程去执行任务队列中的函数,5个function依次入栈执行之后再出栈,此时的i已经变成了5。因此几乎同时输出5个5。
6)因此等待1s的时间其实只有输出第一个5之后需要等待1s,1s时间之后将回调函数交给任务队列。等主线程任务执行完毕后,再去执行任务队列中的5个回调函数。这期间是不需要等待1s的。
因此输出的状态就是:5 => 5,5,5,5,5,即第1个 5 直接输出,1s之后,输出 5个5;

但我们实际想要的结果是 5 => 0,1,2,3,4,所以需要再改下代码。有两种解决方案。
上面的代码使用var定义变量,当定时器触发时,for循环已经完成,变量i 的值已经变成了5,为了解决这个问题,可以 使用闭包 或者 块级作用域 来捕获每次循环的值。

方案1:

我们使用了一个立即执行函数,它创建了一个新的作用域并将当前循环的索引值作为 参数 传递给函数。这样,每个异步任务都会捕获到正确的索引值,并打印出预期的结果。

for (let i = 0; i < 5; i++) {
  (function(index) {
    setTimeout(function() {
      console.log('异步任务', index);
    }, 1000);
  })(i);
}
console.log('同步任务', i);  //输出结果为:  5 => 1秒钟后再输出 0,1,2,3,4

方案2:

for (let i = 0; i < 5; i++) {
    setTimeout(function() {
      console.log('异步任务', i);
    }, 1000);
}

console.log('同步任务', i);
//执行顺序5 => 0,1,2,3,4 即第一个5直接输出,1秒后,输出0,1,2,3,4

将 var 改为 let 后,我们就可以得到期望的输出结果 0、1、2、3、4。

这是因为 let 声明的变量具有块级作用域,每次迭代都会创建一个新的 i 变量,并且在每个 setTimeout 回调函数内部,都能访问到对应的 i 变量。因此,改用 let 关键字声明 i,可以有效避免由闭包引起的变量共享问题。

八、思考问题

1. JS 中的计时器能做到精确计时吗?为什么?

并不能做到完全精确的计时。

  1. JS是单线程的,当调用 setTimeout 或 setInterval 时,通过定时器触发线程来计时并触发定时(计时完毕后,添加到任务队列中排队,等待JS引擎空闲后执行)。但是,如果此时主线程正忙于处理其他任务,那么新的计时器任务就需要等待主线程空闲后才被执行。这就有可能导致计时器任务延迟执行,甚至丢失任务。
  2. JS 本身不支持精确的时间控制,即使我们在设置计时器时传递了一个准确的时间间隔,浏览器也无法保证定时器完全精确,也就是说它们存在一定的“误差”。
  3. W3C在HTML标准规定,要求setTimeout中低于4ms的时间间隔算为4ms。

2. 为什么script(整体代码)是宏任务却优先执行?

在 JavaScript 的事件循环中,整体 script 代码也是一个宏任务。与其他宏任务不同的是,当浏览器加载 HTML 文档时,遇到 script 标签就会停止对文档的解析并立即执行脚本中的代码,因此 script 被认为是“特殊”的宏任务。
script 可能包含大量的代码,例如定义全局变量、函数、类等,或者涉及到 DOM 操作、网络请求等耗时操作。如果这些代码都被视为微任务,则会造成其他应用程序进程(如渲染进程)过长的卡顿和阻塞,影响用户体验。因此,浏览器通常会将外部 script 作为一个宏任务执行,以确保其他任务(如页面渲染)优先执行。

总结:整体 script 代码虽然是宏任务,但是包含的内容重要性高且预处理时间较长,所以是宏任务却优先执行。

可参考:
10分钟理解JS引擎的执行机制
JS事件循环机制
深入浅出Javascript事件循环机制(上)
js事件循环机制及面试题详解

你可能感兴趣的:(JavaScript,javascript,ajax,前端)