js事件循环机制到底是怎么回事?一篇文章带你全面了解!

850 阅读12分钟

前言

大家好,我是编程小白小管,今天我们来一起谈谈js中的事件循环机制。众所周知,js是一种单线程执行的一种语言(小管也是单线程生物),v8引擎在运行js代码时这个进程中,这个进程只有一个线程会被开启。

有同学就要问了:进程和线程是什么意思呢1.进程:cpu运行指令和保存上下文所需要的时间 2.线程:是执行一段指令所需要的时间。比如:浏览器每开一个tab页,就是新开一个进程。而线程分很多种,比如渲染线程,js引擎线程,http 线程等。pps:渲染线程 和 js引擎线程(js可以操作html) 是互斥的

而这个进程只有一个线程会被开启。这句话是什么意思呢,就是它从始至终只能干一件事。

举个简单的例子,小管做饭的时候,必须得先洗菜,再切菜最后炒菜,小管不是超人也没有对象(悲),不能一边洗菜一边切菜,所以只能一步一步来。问题来了,如果小管想要熬一锅汤,需要花一个小时,但小管还要再汤出锅前放上一把切好的葱花,小管是个单线程生物,熬汤的时候就干等,汤好了才切葱花。小管觉得不对劲啊,这一小时白等了,为什么不能熬上汤之后直接切好葱花等最后放呢,这样还能打两把瓦。

很明显,js开发者也是这么想的,于是乎,js事件循环机制event-loop应运而生。

event-loop 的概念

JavaScript 的事件循环(Event Loop)是其异步编程模型的核心机制,它使得 JavaScript 能够在单线程环境下高效地处理异步操作。JavaScript 运行时环境(如浏览器或 Node.js)通过事件循环来管理执行队列并控制同步和异步任务的执行顺序。又是一大串难懂的语言,那让我们用一句话简单总结——v8引擎按照先执行同步,再执行异步的策略,反复执行。 当然只有干巴巴的文字可不行,下面让我们从一个简单的代码讲起。

故事的开始

故事的一开头,让我们来看一个你这种高手一看就会的代码。

let a = 1
console.log(a);

setTimeout(function () {
    let b = 2
    console.log(b);
    a++
    setTimeout(function () {
        b++
    }, 2000)
    console.log(b);

}, 1000)

console.log(a)

让我们来解释一下当前代码是如何运行的,从第一行起

1. 初始化变量和同步任务

首先,V8 执行 JavaScript 时会立即执行:

  • let a = 1;:定义并初始化变量 a 为 1。
  • console.log(a);:打印 a,此时输出 1

此时输出为:

1

2. 设置第一个 setTimeout

  • setTimeout(function() { ... }, 1000);:此时设置了一个延迟 1000 毫秒(1秒)的定时器回调函数。在 1000 毫秒后执行。也就是说这个回调函数并不会立即执行。

3. 执行第二个 console.log(a)

  • console.log(a);:输出当前变量 a,此时 a 没有被修改,仍然是 1。所以会打印 1

此时输出顺序变成:

1
1

4. 主线程继续执行

在这两条同步 console.log 语句执行完后,主线程会继续执行。

5. 进入事件循环等待异步任务

到此为止,所有不需要等待的代码执行完毕。接下来,V8 该执行之前“需要等待”任务了。此时,主线程进入了等待阶段,等待 setTimeout 回调函数的到来。

  • 1000 毫秒后,第一个 setTimeout 的回调函数将被执行。

6. 第一个 setTimeout 回调执行

当 1000 毫秒过去后,第一个 setTimeout 执行:

let b = 2;
console.log(b);    // 输出 b
a++;               // 修改 a 的值
setTimeout(function() { b++; }, 2000);
console.log(b);    // 输出 b
  • let b = 2;:定义并初始化 b 为 2。
  • console.log(b);:输出 b 的值,输出 2
  • a++a 递增,a1 变为 2
  • setTimeout(function() { b++; }, 2000);:在 2000 毫秒后,增加 b 的值,但这不会立即发生(太难了),而是会等简单的执行完。此时的 b 仍然是 2
  • console.log(b);:打印 b,此时 b 还没有被修改,所以打印 2

所以,这一部分输出顺序是:

1
1
2
2

7. 第二个 setTimeout 回调

  • 第二个 setTimeout 的回调是在 2000 毫秒后执行,它会修改 b 的值:b++,将 b2 变为 3

但是,这时主线程已经完成了所有输出,在这个阶段,你不会看到 b++ 立即影响输出。

8. 总结输出

最终,V8 中的输出顺序如下:

1       // 变量 `a` 的初始值
1       // 第二次打印 `a`,值没有改变
2       // 第一个 `setTimeout` 回调中 `b` 的初始值
2       // 第二次打印 `b`,`b` 没有被修改
3       // 第二个 `setTimeout` 2秒后修改 `b` 为 3

其实以上就是v8脑海中想的东西。只不过,我们要多加点“专业术语”,当然我们还要思考一件事,如果,定时器函数中又叠加了一个定时器函数呢?这又怎么办。那就得继续探究啦,在研究前,我们来了解一些预备知识。

预备知识

预备知识1:同步代码和异步代码

要讨论什么是事件循环机制,我们逃不过同步代码和异步代码,下面就是对这俩的简要解释。

1. 同步代码 (Synchronous Code)

同步代码是指按照代码的书写顺序 逐行执行的代码。JavaScript 引擎会执行完当前的任务之后,才会继续执行下一个任务。也就是说,每一条同步代码会阻塞后续代码的执行,直到当前代码执行完毕。

2. 异步代码 (Asynchronous Code)

异步代码指的是不会立即执行,而是要等某些事件、操作或者条件满足后,再执行的代码。异步操作可以让程序继续执行后续的任务,不会被阻塞。常见的异步代码就有

  • 回调函数 (setTimeoutsetInterval 等),

  • Promise:更好的异步控制,避免回调地狱。

  • async/await:简化基于 Promise 的异步操作,使代码看起来像同步代码。

预备知识2:event-loop中的异步代码

js事件循环机制中,异步代码又分为两种

  • 微任务:promise.then()//指的是promis身上.then方法,process.nextTick(),MutationObserver()

  • 宏任务:script,setTimeout,setInterval,setImmediate I/O(输入输出),UI-rendering(页面渲染)

顾名思义,宏任务是指花费时间更长(更宏大)的任务,微任务则反之。所以当执行异步代码时,会分别创建微任务队列和宏任务对列,当执行到微任务或宏任务时,就将任务推入到相应队列中。先执行完微任务再执行宏任务(v8你个懒鬼)。

预备知识3:await

await这个东西在浏览器更新后有了一种特殊的属性(await你是超级英雄嘛这么特殊

  • 浏览器对await的执行提前了(放在 await 后面的代码被当成同步执行)
  • 会将后续(下面所有)代码挤入微任务队列

代码2.0

好了,了解完这些预备知识,该进入正题了,让我们继续来看一个代码2.0

console.log(1);
new Promise(function (resolve, reject) {
   console.log(2);
   resolve()
})
   .then(() => {
       console.log(3);
       setTimeout(() => {
           console.log(4);
       }, 0)
   })
setTimeout(() => {
   console.log(5);
   setTimeout(() => {
       console.log(6);
   }, 0)
}, 0)
console.log(7);

让我们一行一行分析这个代码

1. console.log(1);

这是一个同步任务,立即执行,输出 1

当前输出:

1

2. new Promise(function (resolve, reject) { console.log(2); resolve(); })

在创建 Promise 时,同步代码 先执行:

  • console.log(2) 被执行,输出 2
  • 然后调用 resolve(),使 Promise 状态变为 fulfilled

当前输出:

1
2

3. .then(() => { console.log(3); setTimeout(() => { console.log(4); }, 0); })

Promiseresolve() 被调用后,.then() 回调会被推入微任务队列

capture_20241205005041498.bmp

此时,.then()这个微任务被放在微任务队列中,不用执行,所以里面有什么先按下不提,等到执行它的时候我们再来看。

当前输出:

1
2

4. setTimeout(() => { console.log(5); setTimeout(() => { console.log(6); }, 0); }, 0);

这行代码设置了一个 setTimeout,将 console.log(5) 的回调推入 宏任务队列。如图,将这里的第一个setTimeout记为setTimeout(5)(因为它输出5)

capture_20241205082321962.bmp

当前输出:

1
2

5. console.log(7);

这是一个同步任务,立即执行,输出 7

当前输出:

1
2
7

6. 微任务执行

同步任务都执行完毕后,V8 会处理 微任务队列。此时,微任务队列中有一个任务,即 .then() 中的回调:

  • .then(() => { console.log(3); setTimeout(() => { console.log(4); }, 0); })

  • .then() 的回调执行,打印 3,然后设置了一个 setTimeout(打印 4)推入宏任务队列。

capture_20241205082516923.bmp

此时,微任务队列已为空,事件循环会开始执行 宏任务队列

当前输出:

1
2
3
7

7. 宏任务执行:第一个 setTimeout

从宏任务队列中取出第一个 setTimeout 的回调(打印 5)并执行: 回调中的代码:

  • console.log(5) 会打印 5
  • setTimeout(() => { console.log(6); }, 0) 设置了另一个 setTimeout,将 console.log(6) 的回调推入 宏任务队列,该回调会在 0 毫秒后执行

capture_20241205085846591.bmp

当前输出:

1
2
3
7
5

8. 宏任务执行:第二个setTimeout(4)

从宏任务队列中取出第二个 setTimeout 的回调(打印 4)并执行:

  • setTimeout(() => { console.log(4); }, 0); }

  • 打印 4

  • capture_20241205090113631.bmp

当前输出:

1
2
3
7
5
4

9. 宏任务执行:第三个 setTimeout(6)

最后,从宏任务队列中取出第一个 setTimeout 中的 console.log(6) 任务并执行:

  • 打印 6

capture_20241205090356141.bmp

当前输出:

1
2
3
7
5
4
6

最终输出顺序总结:

  • 同步代码(如 console.log(1))会立刻执行并输出。
  • 微任务(如 Promise.then())会在当前宏任务执行完后立即执行。
  • 宏任务(如 setTimeout)会在微任务执行完后、下一个事件循环开始时执行。

恭喜你少年,理解完这个代码你差不多已经会了什么叫js中的事件循环机制。ok!让我们来小小总结一下eventLoop步骤。

eventLoop步骤

    1. 执行同步代码(这属于宏任务)
    1. 执行完同步后,检查是否有异步代码需要执行
    1. 执行所有的微任务
    1. 如果有需要,就渲染页面
    1. 执行宏任务,也是开启了下一次事件循环。(相当于火车头(宏任务)-车厢(微任务)-火车尾(火车头)(宏任务)-车厢.....

再来一次!

俗话说,温故而知新,既然你已经学会了js的事件循环机制,那我要给你上点强度了。

console.log('script start');
async function async1() {
  await async2()  
  console.log('async1 end');
}
async function async2() {
  console.log('async2 end');
}
async1()
setTimeout(() => {
  console.log('setTimeout');
}, 0)
new Promise((resolve, reject) => {
  console.log('promise');
  resolve()
})
.then(() => {
  console.log('then1');
})
.then(() => {
  console.log('then2');
});
console.log('script end');

亲爱的朋友,你有三秒钟回答上述代码的结果!你的回答是?好了不开玩笑了,我们一起来看看最后这个代码如何运行。

1. console.log('script start');

这行是同步代码,立即执行,输出 script start

当前输出:

script start
2. async1()
  • 调用 async1() 函数,进入函数内部。

  • async1() 内部调用了 await async2()await 会等待 async2() 执行完毕后再继续执行 async1 后面的代码,但是 async2() 是一个同步函数(预备知识3:跟在await后的所有代码立即执行)。也就是说:在它会立即执行并打印 async2 end

    • async2() 执行时,打印 async2 end

当前输出:

script start
async2 end
3. console.log('async1 end');
  • await async2() 执行完毕后,async1 中的 console.log('async1 end') 被推入 微任务队列

当前输出:

script start
async2 end

微任务队列:


graph TD
    async1  

4. setTimeout(() => { console.log('setTimeout'); }, 0);
  • 这行代码设置了一个 setTimeout,将 console.log('setTimeout') 推入 宏任务队列。尽管延迟是 0 毫秒,但 setTimeout 的回调总是会被放到宏任务队列中,等待所有同步任务和微任务执行完毕后才会执行。

宏任务队列:


graph TD
     setTimeout

当前输出:

script start
async2 end
5. new Promise((resolve, reject) => { console.log('promise'); resolve(); })
  • 这行代码创建了一个 Promise,在 Promise 构造函数内同步执行 console.log('promise'),立即输出 promise
  • resolve() 被调用,表示 Promise 被成功解决。

当前输出:

script start
async2 end
promise
6. .then(() => { console.log('then1'); })
  • 由于前面的 resolve() 被调用,.then() (记为.then1)中的回调函数被推入 微任务队列

微任务队列:


graph 
.then1
    async1  

当前输出:

script start
async2 end
promise
7. .then(() => { console.log('then2'); })
  • 紧接着,第二个 .then() (我们记为.then2)回调被推入微任务队列,

微任务队列:


graph
.then2
.then1
    async1  

当前输出:

script start
async2 end
promise

8. console.log('script end');
  • 这行代码是同步的,立即执行,打印 script end

当前输出:

script start
async2 end
promise
script end

9. 执行微任务队列中的任务

微任务队列中有三个待执行的任务。

微任务队列:


graph
.then2
.then1
    async1  

    1. 在微任务队列头部的是 async1 中的 console.log('async1 end'),此时先执行这个任务。于是我们输出async1 end 。第一个微任务async1出队列,此时微任务队列中剩余两个。

微任务队列


 graph
 .then2
.then1
    

  • 2.此时 .then1 成为微任务队列中的头部,此时执行 .then(() => { console.log('then1'); }),于是输出then1。执行完毕后,第二个微任务.then出队列。此时微任务队列中只剩下.then2

微任务队列


graph
.then2
    

  • 3.此时 .then2 成为微任务队列中的头部,此时执行 .then(() => { console.log('then2'); }),于是输出then2。至此,微任务队列中的微任务全部执行完毕

当前输出:

script start
async2 end
promise
script end
async1 end
then1
then2

10. 执行宏任务队列中的任务

  • 现在,宏任务队列中有一个 setTimeout 的回调。这个回调将被执行,打印 setTimeout

宏任务队列:


graph TD
     setTimeout

当前输出:

script start
async2 end
promise
script end
async1 end
then1
then2
setTimeout

OVER!

写在最后

恭喜你,看到这儿,你已经完全学会了js事件循环机制了,小管也终于明白,在等汤煮好的时候,可以先去切点葱花,这样小管就能去打两把瓦了!简而言之,事件循环机制是 JavaScript 单线程模型高效运行的关键,使其能够在不增加复杂性的情况下,处理大量异步任务,同时保持主线程的流畅性和响应性。当我们理解了js中的事件循环机制,我们就会在编写异步代码时更好地理解 JavaScript 的执行顺序,成长为更加成熟的程序员。