vlambda博客
学习文章列表

进击的JavaScript之再临「EventLoop」城下

先来复习几个知识点

异步任务的类型(Web APIs)

宏任务(macro-task):这个队列由事件触发线程维护

setTimeout队列=>

setInterval队列=>

setImmediate队列=>

MessageChannel=>

resquestAnimationFrame=>

I/O队列=>

UI交互事件=>

...

微任务(micro-task):这个队列由JS引擎线程维护 (因为它是在主线程下无缝执行的)

process.nextTick队列=>

Promise.then队列=>

Await=>

MutationObserver=>

...

函数调用栈(call stack)

JavaScript代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行。无论是宏任务还是微任务,最终都是被拿到函数调用栈中执行(后进先出原则)。

事件循环(Event Loop)

“javascript引擎遇到一个异步事件后并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。当一个异步事件返回结果后,js会将这个事件加入与当前执行栈不同的另一个队列,我们称之为事件队列。被放入事件队列不会立刻执行其回调,而是等待当前执行栈中的所有任务都执行完毕, 主线程处于闲置状态时,主线程会去查找事件队列是否有任务。如果有,那么主线程会从中取出排在第一位的事件,并把这个事件对应的回调放入执行栈中,然后执行其中的同步代码…,如此反复,这样就形成了一个无限的循环。这就是这个过程被称为“事件循环(Event Loop)”

(图网上找的,懒得画了🤷‍♂️)

从一些不得不面对的🌰来理解事件循环机制

// 🌰.1
console.log("每");

setTimeout(function () {
console.log("身");

Promise.resolve().then(() => {
console.log("不");
});

new Promise(function (resolve) {
console.log("体");
resolve();
}).then(function () {
console.log("生");
}).then(function () {
console.log("锈");
});

}, 10);//进入WebApis进程(事件触发线程),10s后放入任务队列,在第二轮宏任务中执行

setTimeout(function () {
console.log("次");
}, 0);//进入WebApis进程(事件触发线程),0s后放入任务队列,在第二轮宏任务中执行

Promise.resolve().then(() => {
console.log("动");
});

new Promise(function (resolve) {
console.log("周");
resolve("_gagaga_");
}).then(function (res) {
console.log("两");
return res;
});

//结果:每 周 动 两 Promise {<fulfilled>: '_gagaga_'} 次 身 体 不 生 锈

逐行解释:看完保证豁然开朗

首先,函数执行栈执行整体代码块script 遇到console.log("每"),立即执行,输出 ;遇到第一个setTimeout,这是一个异步任务的宏任务,由WebApis进程(事件触发线程),10ms后它的第一个参数会被放进任务队列,将在第二轮宏任务中被执行;遇到第二个setTimeout,与上一个类似,区别是0ms后它就会被放进任务队列;此时setTimeout微任务队(左进右出,先进先出)列情况:10ms的settimeout,0ms的settimeout;遇到第一个PromisePromise.resolve()内无代码可执行,遇到.then,这是一个微任务,将.then里的代码加入到Promise微任务队列;遇到第二个Promisenew Promise的第一个参数会立即执行,输出 ;遇到.then,这是本轮循环遇到的第二个微任务,将.then里的代码加入到Promise微任务队列(左进右出,先进先出);此时Promise微任务队列里的情况:console.log("两") , console.log("动")

至此,本(第一)轮无宏任务需要执行,开始执行本轮产生的微任务。首先从微任务队列头部取出console.log("动")执行,输出 ,并从微任务队列中清除;再取出console.log("两")执行,输出 ,并从微任务队列中清除。此时微任务队列已无待执行任务,本轮循环结束,开始下一轮事件循环。

新一轮循环依然从宏任务队列开始执行:发现setTimeout宏任务队列有任务可以执行,从队头取出0ms的settimeout的任务执行,输出 ;再取出10ms的settimeout的任务执行,输出 ;且执行过程中遇到第一个PromisePromise.resolve()内无代码可执行,遇到.then,这是一个微任务,将.then里的代码加入到Promise微任务队列;遇到第二个Promisenew Promise的第一个参数会立即执行,输出 ;遇到.then,这是本轮循环遇到的第二个微任务,将.then里的代码加入到Promise微任务队列,又遇到.then,这是本轮循环遇到的第三个微任务,将.then里的代码也加入到Promise微任务队列(左进右出,先进先出);此时Promise微任务队列里的情况:console.log("锈") console.log("生") , console.log("不")

至此,本(第二)轮无宏任务需要执行,开始执行本轮产生的微任务。首先从微任务队列头部取出console.log("不")执行,输出 ,并从微任务队列中清除;再取出console.log("生")执行,输出 ,并从微任务队列中清除,最后再取出console.log("锈")执行,输出 ,并从微任务队列中清除。此时微任务队列已无待执行任务,本轮循环结束,开始下一轮事件循环。

新一轮循环依然从宏任务队列开始执行:发现宏任务队列中已无任务可以执行,循环结束;

这个例子的注意点:第一轮循环,遇到两个setTimeout,到了第二轮谁先执行呢?当然是延迟小的先执行。主线程之外,事件触发线程管理着一个任务队列(或消息队列),倒计时结束后,setTimeout的第一个参数会被放入宏任务队列,所以倒计时先结束的就会先放进去,当第二轮循环执行宏任务的时候也就会先执行。

// 🌰.2

console.log('start');
setTimeout(function() { //setTimeout1
console.log('setTimeout1');
process.nextTick(function() {
console.log('nextTick1'); //nextTick1
})
new Promise(function(resolve) {// Promise1
console.log('Promise1');
resolve();
}).then(function() {
console.log('Promise1.then')
})
})
new Promise(function(resolve) { //Promise2
console.log('Promise2');
resolve();
}).then(function() {
console.log('Promise2.then')
})

setTimeout(function() { //setTimeout2
console.log('setTimeout2');
process.nextTick(function() { //nextTick2
console.log('nextTick2');
})
new Promise(function(resolve) { //Promise3
console.log('Promise3');
resolve();
}).then(function() {
console.log('Promise3.then')
})
})

// start Promise2 Promise2.then setTimeout1 Promise1 nextTick1 Promise1.then setTimeout2 Promise3 nextTick2 Promise3.then
// 🌰.3
console.log('golb1');

setTimeout(function() {
console.log('timeout1');
process.nextTick(function() {
console.log('timeout1_nextTick');
})
new Promise(function(resolve) {
console.log('timeout1_promise');
resolve();
}).then(function() {
console.log('timeout1_then')
})
})

setImmediate(function() {
console.log('immediate1');
process.nextTick(function() {
console.log('immediate1_nextTick');
})
new Promise(function(resolve) {
console.log('immediate1_promise');
resolve();
}).then(function() {
console.log('immediate1_then')
})
})

process.nextTick(function() {
console.log('glob1_nextTick');
})
new Promise(function(resolve) {
console.log('glob1_promise');
resolve();
}).then(function() {
console.log('glob1_then')
})

setTimeout(function() {
console.log('timeout2');
process.nextTick(function() {
console.log('timeout2_nextTick');
})
new Promise(function(resolve) {
console.log('timeout2_promise');
resolve();
}).then(function() {
console.log('timeout2_then')
})
})

process.nextTick(function() {
console.log('glob2_nextTick');
})
new Promise(function(resolve) {
console.log('glob2_promise');
resolve();
}).then(function() {
console.log('glob2_then')
})

setImmediate(function() {
console.log('immediate2');
process.nextTick(function() {
console.log('immediate2_nextTick');
})
new Promise(function(resolve) {
console.log('immediate2_promise');
resolve();
}).then(function() {
console.log('immediate2_then')
})
})

//执行结果:golb1, glob1_promise, glob2_promise, glob1_nextTick, glob2_nextTick, glob1_then, glob2_then, timeout1, timeout1_promise, timeout1_nextTick, timeout1_then, timeout2, timeout2_promise, timeout2_nextTick, timeout2_then, immediate1, immediate1_promise,
immediate1_nextTick, immediate1_then, immediate2, immediate2_promise, immediate2_nextTick, immediate2_then

这个参考来的栗子比较复杂,这里需要注意的点是:process.nextTick和promise.then都是微任务,但是process.nextTick的优先级比promise.then要高,因此会先执行。

// 🌰.4 有await

console.log('script start');//宏任务1
async function async1() {
await async2();
console.log('async1 end');//微任务1
}

async function async2() {
console.log('async2 end');//宏任务2
}
async1()

setTimeout(function() {
console.log('setTimeout');//宏任务2
}, 0)

new Promise(resolve => {
console.log('Promise');//宏任务3
resolve()
}).then(function() {
console.log('promise1');//微任务2
}).then(function() {
console.log('promise2');//微任务3
})
console.log('script end');//宏任务4
//先执行完await后面的函数async2后(async2后面的console.log('async1 end')可以理解为进入到了微任务队列里),然后跳出当前async1函数,执行async1外面的同步代码,同步代码执行完,再回到async内部,最后在执行console.log( 'async1 end' );

这个栗子的注意点:对于async/await函数,可以将紧跟着await后的代码当作立即执行函数(同步任务),await的下一行当作promise.then,即产生一个微任务。

分析过程(啰嗦点)

执行script代码块

遇到宏任务:将任务添加到对应的宏任务队列中

遇到setTimeout,将setTimeout回调内容添加到setTimeout队列中;遇到setImmediate,将setImmediate回调内容添加到SetImmediate队列中;

遇到微任务:将任务添加到对应的微任务队列中

遇到process.nextTick:将回调内容添加到nextTick队列中;遇到Promise:Promise里是立即执行函数,所以里面的代码会立即执行;Promise.then回调添加到Promise队列中;

当前宏任务执行完毕

开始执行当前宏任务所产生的微任务

先执行nextTick队列,过程中遇到其他宏任务/微任务重复上面的逻辑再执行Promise队列,过程中遇到其他宏任务/微任务重复上面的逻辑

所有微任务执行完毕,本轮循环结束,开始下一轮事件循环,再次从宏任务队列开始执行

先执行setTimeout队列,过程中遇到其他宏任务/微任务重复上面的逻辑(执行过程中,若遇到微任务,加入到对应微任务队列,然后先将微任务队列中任务执行完毕,再返回执行其他setTimeout任务)再执行setImmediate队列,过程中遇到其他宏任务/微任务重复上面的逻辑(执行过程中,若遇到微任务,加入到对应微任务队列,然后先将微任务队列中任务执行完毕,再返回执行其他setImmediate任务)

重复(先执行宏任务,再执行宏任务产生的微任务,UI Render)流程,直到所有宏任务执行完毕,函数执行栈清空,事件循环结束。

简而言之(参考大佬的总结,并加入了UI渲染的触发时机)

  • 执行一个宏任务(函数执行栈中没有就从事件队列中获取

  • 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中(先进先出原则)

  • 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)

  • 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染

  • 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)

再多说一嘴,加深记忆

  • setTimeout 需要等待当前队列中所有的消息都处理完毕之后才能执行,即使已经超出了由第二参数所指定的时间

  • 当在执行setTimeout任务中遇到setTimeout时,它仍然会将对应的任务分发到setTimeout队列中去,但是该任务就得等到下一轮事件循环执行了;

  • setTiemout队列产生的微任务执行完毕之后,循环则回过头来开始执行setImmediate队列。仍然是先将setImmediate队列中的任务执行完毕,再执行所产生的微任务;

  • 当setImmediate队列执行产生的微任务全部执行之后,第二轮循环也就结束了;

  • 在node环境下,process.nextTick的优先级高于Promise,也就是可以简单理解为:在宏任务结束后会先执行微任务队列中的nextTickQueue部分,然后才会执行微任务中的Promise部分。




参考

并发模型与事件循环 MDN[1]

深入核心,详解事件循环机制[2]

EventLoop 事件循环机制详解[3]

References

[1] 并发模型与事件循环 MDN: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/EventLoop
[2] 深入核心,详解事件循环机制: https://segmentfault.com/a/1190000012646373
[3] EventLoop 事件循环机制详解: https://www.icode9.com/content-4-806163.html