前言:了解js的事件循环机制,了解其底层运行的原理,让我们以不变应万变。还有一点也很重要,那就是应对各大互联网公司的面试,懂其原理,题目任其发挥,哈哈哈,再也不用担心啦。
JS 并发模型与事件循环
Stack 栈(执行栈)
栈是一种数据结构,他是后进先出的,js函数调用形成了一个由若干帧组成的栈,看如下代码
function foo(b) {
let a = 10;
return a + b + 11;
}
function bar(x) {
let y = 3;
return foo(x * y);
}
console.log(bar(7)); // 返回 42
当调用 bar
时,第一个帧被创建并压入栈中,帧中包含了 bar
的参数和局部变量。 当 bar
调用 foo
时,第二个帧被创建并被压入栈中,放在第一个帧之上,帧中包含 foo
的参数和局部变量。当 foo
执行完毕然后返回时,第二个帧就被弹出栈(剩下 bar
函数的调用帧 )。当 bar
也执行完毕然后返回时,第一个帧也被弹出,栈就被清空了。整个过程符合后进先出概念
Queue 队列
队列也是中数据结构,他是先进先出的,一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用以处理这个消息的回调函数。
在 事件循环 期间的某个时刻,运行时会从最先进入队列的消息开始处理队列中的消息。被处理的消息会被移出队列,并作为输入参数来调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。意思就是读取到队列中的消息后,会将消息的执行栈中,待执行栈所有的调用帧都结束后会进行下一轮循环
Heap 堆
对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。用来存储js变量
以上只是把大致的概念解释了下了,我看下如果下图
Queue队列出现的原因
JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
如果排队是因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 设备(输入输出设备)很慢(比如 Ajax 操作从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript 语言的设计者意识到,这时主线程完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。
于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入”任务队列”(task queue)的任务,只有”任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
宏任务与微任务
实际上任务队列是有两种类型的,一个是宏任务(macrotask)一个是微任务(microtask),宏任务被称为task,
微任务被称为jobs,javascript本身是没有发起异步请求的的能力的,异步请求的发起是调异步请求线程发起的,
早期javascript中是没有微任务存在的,但是后面引入很多概念比如Promise,这样就使的js引擎自己就能发起异步任务了,所以就出现了微任务的概念,那哪些是宏任务,哪些微任务呢?
- 宏任务的发起方是宿主(浏览器、node)环境,比如定时器,当代码执行到定时器计时工作是有浏览器定时器线程进行时间计数
- 微任务的发起方是js引擎本身,
任务事件 |
浏览器是否支持 |
Node是否支持 |
宏任务(macrotask) | ||
requestAnimationFrame |
✅ |
❌ |
setTimeout |
✅ |
✅ |
setInterval |
✅ |
✅ |
setImmedidate |
❌ |
✅ |
I/o |
✅ |
✅ |
http请求 |
✅ |
✅ |
dom事件 |
✅ |
❌ |
UI rendering |
✅ |
❌ |
postMessage |
✅ |
❌ |
MessageChannel |
✅ |
❌ |
微任务(microtask) | ||
Promise |
✅ |
✅ |
MutainObserver |
✅ |
❌ |
process.nextTick |
❌ |
✅ |
上面列出了那些是宏任务与微任务,实际上宏任务与与微任务是两个队列
宏任务队列:从上面的视频可以看出宏任务的逻辑是函数的调用帧在执行栈执行的时候,如果遇到了宏任务事件,就会让将任务执行与回调一起交给相应的线程接管,就比如 setTimeout(function cb() {}, 5000)
,这段代码执行逻辑就是会把setTimeout
推入执行栈执行,然后5秒计数以及时间到了以后将回调push到宏任务队列中,可见这个队列是由相应的触发线程维护
微任务队列: 函数的调用帧在执行栈执行的时候,如果遇到了微任务就直接将微任务的push到微任务队列中,就比如
new Promise((resolve, reject) => resolve()).then()
,当执行到.then
会把它的回调直接推送到微任务队列中,可见这个队列是由js线程维护的
那么宏任务与微任务执行的先后顺序是如何的?
1、执行栈中如果有执行到微任务那么就把微任务push到微任务队列
2、当前执行栈中的调用帧都执行完毕后,立刻将微任务队列中的任务执行
3、微任务队列中的任务都清空了以后,去宏任务队列取出第一个任务,把它压入执行栈,如果当前执行栈有微任务,重复第一步
最后在看一张图
上述可能不太好理解,下图是我做的一张图片。
举个例子
1、示例一
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
new Promise((resolve) => {
resolve();
}).then(() => {
console.log('3')
});
console.log('4')
依次打印:1----》4 ----》3 ----》2
过程详解:
1.执行main主进程,打印1,将setTimeout放入宏任务队列,将promise.then放入微任务队列,打印4,退出main主进程
2.在宏任务和微任务队列里找,发现微任务队列里有,执行微任务队列,打印3,微任务队列执行完
3.微任务队列没有任何东西,再执行宏任务队列,打印2,
4.宏任务和微任务队列均没有东西,结束
2、示例二
setTimeout(() => {
console.log('time1');
Promise..resolve().then(() => {
console.log('promise1')
});
}, 0);
setTimeout(() => {
console.log('time2');
Promise..resolve().then(() => {
console.log('promise2')
});
}, 0);
依次打印:time1 =》promise1=》time2=》promise2
过程详解:
1.先走main主进程,看宏任务和微任务队列里没有任何东西,拿到俩个setTimeout宏任务,将其放入宏任务队列
2.执行第一个setTimeout,打印time1, 遇到promise.then,将其放入到微任务队列
3.找宏任务和微任务队列,先执行微任务队列,打印出promise1,执行完微任务队列
4.再找微任务和宏任务队列,微任务队列没有任何东西,执行宏任务,执行第二个setTimeout
5.打印time2,遇到promise.then,将其放入微任务队列
6.再找微任务和宏任务队列,发现微任务队列里有,执行微任务队列,打印promise2
7.再找微任务和宏任务队列,发现俩者均没有,结束
执行过程,如图所示(看图会理解的更加清晰)
以上是个人理解,如有问题,望各位大佬指出!!!