前言
大家好,我是编程小白小管,今天我们来一起谈谈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递增,a从1变为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++,将b从2变为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)
异步代码指的是不会立即执行,而是要等某些事件、操作或者条件满足后,再执行的代码。异步操作可以让程序继续执行后续的任务,不会被阻塞。常见的异步代码就有
-
回调函数 (
setTimeout、setInterval等), -
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); })
在 Promise 的 resolve() 被调用后,.then() 回调会被推入微任务队列。
此时,.then()这个微任务被放在微任务队列中,不用执行,所以里面有什么先按下不提,等到执行它的时候我们再来看。
当前输出:
1
2
4. setTimeout(() => { console.log(5); setTimeout(() => { console.log(6); }, 0); }, 0);
这行代码设置了一个 setTimeout,将 console.log(5) 的回调推入 宏任务队列。如图,将这里的第一个setTimeout记为setTimeout(5)(因为它输出5)
。
当前输出:
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)推入宏任务队列。
此时,微任务队列已为空,事件循环会开始执行 宏任务队列。
当前输出:
1
2
3
7
7. 宏任务执行:第一个 setTimeout
从宏任务队列中取出第一个 setTimeout 的回调(打印 5)并执行:
回调中的代码:
console.log(5)会打印5。setTimeout(() => { console.log(6); }, 0)设置了另一个setTimeout,将console.log(6)的回调推入 宏任务队列,该回调会在 0 毫秒后执行
当前输出:
1
2
3
7
5
8. 宏任务执行:第二个setTimeout(4)
从宏任务队列中取出第二个 setTimeout 的回调(打印 4)并执行:
-
setTimeout(() => { console.log(4); }, 0); } -
打印
4。 -
当前输出:
1
2
3
7
5
4
9. 宏任务执行:第三个 setTimeout(6)
最后,从宏任务队列中取出第一个 setTimeout 中的 console.log(6) 任务并执行:
- 打印
6。
当前输出:
1
2
3
7
5
4
6
最终输出顺序总结:
- 同步代码(如
console.log(1))会立刻执行并输出。 - 微任务(如
Promise.then())会在当前宏任务执行完后立即执行。 - 宏任务(如
setTimeout)会在微任务执行完后、下一个事件循环开始时执行。
恭喜你少年,理解完这个代码你差不多已经会了什么叫js中的事件循环机制。ok!让我们来小小总结一下eventLoop步骤。
eventLoop步骤
- 执行同步代码(这属于宏任务)
- 执行完同步后,检查是否有异步代码需要执行
- 执行所有的微任务
- 如果有需要,就渲染页面
- 执行宏任务,也是开启了下一次事件循环。(相当于火车头(宏任务)-车厢(微任务)-火车尾(火车头)(宏任务)-车厢.....
再来一次!
俗话说,温故而知新,既然你已经学会了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
-
- 在微任务队列头部的是
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 的执行顺序,成长为更加成熟的程序员。