-
Notifications
You must be signed in to change notification settings - Fork 3
Description
参考文章:
- JS事件循环机制(event loop)之宏任务/微任务
- 微任务、宏任务与Event-Loop
- JavaScript基础四:事件循环EventLoop
- JavaScript中的Event Loop(事件循环)机制
事件循环机制
概念
Event Loop 实际就是 JavaScript 异步执行机制的一种实现方式;
程序按照主线程-微任务-宏任务的顺序不断重复执行, 并始终维护各执行队列直至全部队列清空的操作就是 Event Loop;
流程
JavaScript 是单线程语言, JS 任务需要遵循一定的顺序执行。为了避免某个任务执行时间过长而阻塞后面任务的执行, JS 将任务分为了同步和异步任务,而同步任务和异步任务执行的场所不同,因此执行的过程也有所差异:
- 同步任务进入主线程执行, 异步任务进入 Event Table 执行, 并在此阶段注册其内部的回调函数;
- 注册的回调函数会被放入 Event Queue 中等待;
- 主线程中同步任务执行完毕后(js 引擎通过 monitoring process 进程持续监测主线程执行栈是否为空), 此时执行栈为空, 会去 Event Queue 检查是否存在等待的回调函数, 若存在则读取 Event Queue 的函数到主线程中执行;
异步任务又被细分为宏任务和微任务, JS 在处理宏任务和微任务时又遵循特殊的执行顺序:
当 JS 遇到宏任务时, 将其放入 Macro Event Queue 中, 而微任务会被放入 Micro Event Queue 中(注意宏任务队列和微任务队列不是一个队列); 在读取(向外拿)回调函数时, 先清空微任务队列中的回调函数, 然后再从宏任务队列中调用并执行一个回调函数; (换句话说, 每一次宏任务执行前, 要清空上一次的微任务队列, 宏任务在微任务之后执行);
宏任务中可能产生新的微任务,而这些微任务的回调会被注册到微任务队列中,因此我们取出一个宏任务并执行完毕后,要再一次确认微任务队列是否被清空,若没有则要清空微任务队列,然后再从宏任务队列中调取下一个回调函数......
概括而言,JS 的执行顺序, 应遵循的思路为:
同步放入主线程 -> 执行同步代码(清空主线程代码) -> 遇到异步代码, 放入 Event Table -> Event Table 中判断宏任务 or 微任务 -> 注册回调放入各自队列 -> 若主线程为空,清空微任务(将微任务提到主线程执行) -> (若执行后又推入了新的微任务,又回到了主线程为空,微任务队列存在回调的情况,重复上述流程) -> ...... (若主线程/微任务队列都为空) -> 执行下一次宏任务(取出宏任务队列中的一个回调到主线程,然后执行。) -> … -> (若执行后推入了新的微任务,又回到了主线程为空,微任务队列存在回调的情况,重复上述流程)
宏任务 & 微任务
在通过例子深入了解 JS 执行机制前, 我们需要记住几个常用的宏任务和微任务:
宏任务:
- 整体 script 代码(script 代码是异步代码, 其内部可能包含同步代码, 但整体上是异步宏任务, 许多文章对于 script 代码解释有出入, 以自己理解为准)
- setTimeout
- setInterval
- setImmediate
微任务:
- 原生 Promise 的 then() , catch() 方法
- await 暂停处的后续语句
- process.nextTick
- MutationObserver
- Object.observe (已废弃)
关于 Promise:
首先遇到 new Promise() 会同步执行代码,立即执行参数传入的 executor 函数,executor 函数内部从上到下同步执行代码,遇到 resolve() 或 reject() 时,将值传递给 then() 参数中的回调,并立即将该回调注册到微任务队列。(⚠️ 注意:resolve() / reject() 的执行也是同步的,它不会等异步完成后执行,除非它在异步回调内,参照示例代码理解)
对于 then() 方法的链式调用而言:尽管调用在形式上是连续的,但是注册回调并不是连续的,下一个 then() 回调注册需要等待上一个 then() 方法执行完毕后才进行注册。
// 示例代码
let a = new Promise((resolve, reject) => {
// 1. 打印 1
console.log(1);
let a = 0;
// 2. 回调注册至宏任务队列
setTimeout(() => {
a = 1;
// 10. 打印 5
console.log(5);
}, 1000);
// ⚠️3. 同步执行 resolve(),将 a 传递给 then() 回调,并注册 then() 回调至微任务队列
resolve(a);
// 4. 打印 2
console.log(2);
// 至此,同步代码执行完毕(主线程为空),下一步清空微任务队列
}).then(
// 在第3步被注册入微任务队列,val = 0,主线程为空后,该微任务出队列,压入主线程执行
val => {
// 5. 打印 0
console.log(val);
// 6. 执行 new Promise 的 executor
return new Promise((resolve, reject) => {
// ⚠️7. 同步执行 resolve(),将 3 传递给 then() 回调,并注册 then() 回调至微任务队列
resolve(3);
// 8. 打印 4
console.log(4);
// 至此,主线程又为空了,会再一次检查微任务队列是否为空,由于刚刚又推入了一个微任务,因此执行下一个 then 回调👇
})
}).then(val => {
// 9. 打印 3
console.log(val);
// 主线程/微任务队列为空,调出宏任务队列注册的回调到主线程执行
})
// 1
// 2
// 0
// 4
// 3
// 5
总的来说:普通函数代码块内都是同步执行的,异步形式主要是依靠回调注册至不同队列来实现的。
关于 async & await:
async 内部按照同步代码的方式运行直到碰到 await,执行到 await 后面的表达式停止。此时,程序会记录前段的变量和当前中断的位置,并暂时将该函数弹出执行上下文栈,将执行权交给其他同步代码执行。
await 暂停处之后的语句都会被推入微任务队列,当 await 后的表达式执行完成后,遵循事件循环机制,将微任务队列中的后续语句推入主线程执行 (async 函数重新被压入上下文栈,从中断位置恢复执行)。
实例讲解
例一
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
/**请写出打印结果
* script start
* script end
* promise1
* promise2
* setTimeout
*/
/**过程解析
* 添加执行环境 - 任务入栈:
* 最开始 JS 整体代码作为异步输入
* console.log('script start') 同步代码, 放入主线程队列
* setTimeout 异步宏任务, 经 Event Table 注册回调后, 其回调放入宏任务队列
* Promise.resolve() 异步微任务, 经 Event Table 注册回调后, 回调放入微任务队列 (Promise 由于链式调用, 微任务队列 promise1 先入, promise2 后入)
* console.log('script end') 同步代码, 放入主线程队列
*
* 执行 - 任务出栈:
* 先执行主线程, 输出 script start, script end
* 主线程为空, 清除微任务队列, 微任务回调出队列, 进入主线程执行, 输出 promise1, promise2
* 微任务为空, 清除宏任务队列, 宏任务回调出队列, 进入主线程执行, 输出 setTimeout
* 主线程, 任务队列均为空, 执行完毕;
*/
例二
setTimeout(()=>{
console.log('setTimeout1');
}, 0);
let p = new Promise((resolve, reject)=>{
console.log('Promise1');
resolve();
})
p.then(()=>{
console.log('Promise2');
})
/**请写出打印结果
* Promise1
* Promise2
* setTimeout1
*/
/**过程解析
* 入队
* 最开始 JS 整体代码作为异步输入
* setTimeout 异步宏任务, 注册回调并添加至宏任务
* new Promise 是同步任务, 其内部 executor 函数在主线程自动执行, 因此将 executor 函数添加至主线程;
* Promise.then() 异步微任务, 注册回调并添加至微任务
*
* 出队
* 清空主线程, 输出 Promise1;
* 清空微任务队列, 输出 Promise2;
* 取宏任务队列回调, 输出 setTimeout1;
*/
例三
Promise.resolve().then(()=>{
console.log('Promise1')
setTimeout(()=>{
console.log('setTimeout2')
},0)
});
setTimeout(()=>{
console.log('setTimeout1')
Promise.resolve().then(()=>{
console.log('Promise2')
})
},0)
/**输出结果
* Promise1
* setTimeout1
* Promise2
* setTimeout2
*/
/**过程解析
* 入队
* 最开始 JS 整体代码作为异步输入
* Promise.resolve() 异步微任务, 注册回调并添加至微任务
* setTimeout 异步宏任务, 注册回调并添加至宏任务
*
* 出队
* 主线程没有任务, 清空微任务, 输出 Promise1, 此时碰到 setTimeout 宏任务;
*
* 入队
* setTimeout 注册回调并添加至宏任务队尾
*
* 出队
* 微任务为空, 此时获取宏任务队列最开始的回调并执行, 输出 setTimeout1;
*
* 入队
* 遇到 Promise.resolve 异步微任务, 注册其回调并添加至微任务;
*
* 出队
* 下一次宏任务队列回调执行前, 必须保证微任务队列是清空的, 因此此时清空微任务回调, 输出 Promise2
* (这就是宏任务不用清空这两个字的原因)
* 清空后, 获取宏任务回调并执行, 输出 setTimeout2
*/
经过上述三个例子, 你可能对 JS 执行机制有了大致的了解:
- 在判断执行顺序时, 我们需要先构建初步的执行上下文(入队), 同步添加至主线程, 异步则进一步判断宏任务还是微任务, 注册回调并添加至各自的等待队列;
- 执行上下文构建完成后, 开始执行(出队), 先执行主线程的同步代码, 清空主线程后再清空微任务队列, 微任务队列清空后, 再从宏任务队列获取回调(多次强调, 此处是获取而不是清空)并执行;
- 在执行宏任务回调时, 又可能会添加新的主线程和微队列任务(第二轮构建入队), 我们要在下一次执行宏任务回调前, 将其清空(第二轮执行出队), 依次不断循环
- ......
考核
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
/**输出结果
* 1
* 7
* 6
* 8
* 2
* 4
* 3
* 5
* 9
* 11
* 10
* 12
*/
/**过程解析
* 首轮构建执行上下文(入队)
* js 代码块作为异步代码入队
* console.log(1) 同步代码, 进入主线程
* setTimeout 异步宏任务, 注册其 function(){...} (不关心其嵌套) 至宏任务队列
* process.nextTick 异步微任务, 注册回调并添加至微任务队列
* new Promise 内部 executor 函数同步执行, 添加至主线程
* .then() 异步微任务, 添加至微任务队列
* setTimeout 异步宏任务, 注册其 function(){...} (不关心其嵌套) 至宏任务队列
*
* 首轮执行(出队)
* 清空主线程: 输出 1, 7
* 清空微任务队列: 输出 6, 8
* 获取宏任务第一个回调并执行: 输出 2, 此时遇到 process.nextTick, 开启第二轮入队(构建执行环境)
*
* 第二轮入队
* 注册 process.nextTick 回调并添加至微任务队列,
* 添加 new Promise 内同步代码至主线程,
* 注册 .then() 回调至微任务; 此时主线程和微任务队列都不为空, 开启第二轮出队
*
* 第二轮出队
* 清空主线程: 输出 4
* 清空微任务队列: 输出 3, 5
* 获取宏任务下一个回调: 输出 9, 此时遇到 process.nextTick, 开启第三轮入队
*
* 第三轮入队
* 注册 process.nextTick 回调并添加至微任务队列,
* 添加 new Promise 内同步代码至主线程,
* 注册 .then() 回调至微任务; 此时主线程和微任务队列都不为空, 开启第三轮出队
*
* 第三轮出队
* 清空主线程: 输出 11;
* 清空微任务队列: 输出 10, 12
*
* 主线程, 宏任务队列, 微任务队列都为空, 执行完毕
* 输出结果为: 1, 7, 6, 8, 2, 4, 3, 5, 9, 11, 10, 12
*/
回调函数内可能嵌套了多层, 但遵循上述步骤仍可以正确判断, 我们每次只需关注最外层嵌套函数即可, 在原有上下文基础上构建第二级的执行上下文, 清空主线程, 清空微队列, 再获取下一个宏任务回调执行并判断, 遇到嵌套后再循环…