前言
大家好,我是编程小白小管,今天我们来一起谈谈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 的执行顺序,成长为更加成熟的程序员。