jtwang7 / JavaScript-Note

JavaScript学习笔记
10 stars 2 forks source link

JS 基础篇 - 宏任务与微任务 & EventLoop事件循环 #49

Open jtwang7 opened 3 years ago

jtwang7 commented 3 years ago

参考文章:

事件循环机制

概念

Event Loop 实际就是 JavaScript 异步执行机制的一种实现方式; 程序按照主线程-微任务-宏任务的顺序不断重复执行, 并始终维护各执行队列直至全部队列清空的操作就是 Event Loop;

流程

JavaScript 是单线程语言, JS 任务需要遵循一定的顺序执行。为了避免某个任务执行时间过长而阻塞后面任务的执行, JS 将任务分为了同步和异步任务,而同步任务和异步任务执行的场所不同,因此执行的过程也有所差异:

异步任务又被细分为宏任务和微任务, JS 在处理宏任务和微任务时又遵循特殊的执行顺序: 当 JS 遇到宏任务时, 将其放入 Macro Event Queue 中, 而微任务会被放入 Micro Event Queue 中(注意宏任务队列和微任务队列不是一个队列); 在读取(向外拿)回调函数时, 先清空微任务队列中的回调函数, 然后再从宏任务队列中调用并执行一个回调函数; (换句话说, 每一次宏任务执行前, 要清空上一次的微任务队列, 宏任务在微任务之后执行); 宏任务中可能产生新的微任务,而这些微任务的回调会被注册到微任务队列中,因此我们取出一个宏任务并执行完毕后,要再一次确认微任务队列是否被清空,若没有则要清空微任务队列,然后再从宏任务队列中调取下一个回调函数......

概括而言,JS 的执行顺序, 应遵循的思路为: 同步放入主线程 -> 执行同步代码(清空主线程代码) -> 遇到异步代码, 放入 Event Table -> Event Table 中判断宏任务 or 微任务 -> 注册回调放入各自队列 -> 若主线程为空,清空微任务(将微任务提到主线程执行) -> (若执行后又推入了新的微任务,又回到了主线程为空,微任务队列存在回调的情况,重复上述流程) -> ...... (若主线程/微任务队列都为空) -> 执行下一次宏任务(取出宏任务队列中的一个回调到主线程,然后执行。) -> … -> (若执行后推入了新的微任务,又回到了主线程为空,微任务队列存在回调的情况,重复上述流程)

宏任务 & 微任务

在通过例子深入了解 JS 执行机制前, 我们需要记住几个常用的宏任务和微任务: 宏任务:

微任务:

关于 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
 */

回调函数内可能嵌套了多层, 但遵循上述步骤仍可以正确判断, 我们每次只需关注最外层嵌套函数即可, 在原有上下文基础上构建第二级的执行上下文, 清空主线程, 清空微队列, 再获取下一个宏任务回调执行并判断, 遇到嵌套后再循环…

jtwang7 commented 2 years ago

2022.02.11 更新

一道事件循环面试考题:

async1 = async () => {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(() => {
  console.log('setTimeout');
})
async1();
new Promise((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => {
  console.log('promise2');
  return new Promise(function (resolve) {
    resolve();
  })
}).then((res) => {
  console.log('promise3');
})
console.log('script end');

/**
 * script start
 * async1 start
 * async2
 * promise1
 * script end
 * async1 end
 * promise2
 * promise3
 * setTimeout
 */

解答

  1. 整块 js 代码作为宏任务进入宏任务队列。
  2. 主线程和微任务队列都为空,将整块代码提到主线程执行。
  3. 清空主线程代码 (主逻辑;注册宏任务/微任务)
  4. 检查微任务队列是否为空: 4.1. 若不为空,将微任务提取到主线程执行,清空微任务队列 4.2. 若为空,检查宏任务队列是否为空,并将下一个宏任务提取到主线程之行
  5. 重复 3 - 4,直到主线程/宏任务/微任务队列都为空,程序执行完毕。
async1 = async () => {
  // 4. 打印 'async1 start'
  console.log('async1 start');
  // 5. 执行 async2,遇到 await,将后续代码放入微任务队列等待执行,交还执行权(跳出该函数体)
  await async2();
  // 5.1 被放入微任务队列
  // 10. 打印 async1 end
  console.log('async1 end');
}
function async2() {
  console.log('async2');
}
// 1. 打印 'script start'
console.log('script start');
// 2. 执行计时器,并向宏任务队列注册回调
setTimeout(() => {
  console.log('setTimeout');
})
// 3. 执行 async1
async1();
// 6. 创建 promise 实例并执行 executor
new Promise((resolve) => {
  // 7. 打印 promise1
  console.log('promise1');
  // 8. 执行 resolve() 并将 then 回调注册至微任务队列
  resolve();
}).then(() => {
  // 11. 打印 promise2
  console.log('promise2');
  return new Promise(function (resolve) {
    // 12. 注册 then 回调至微任务队列
    resolve();
  })
}).then((res) => {
  // 13. 打印 promise3
  console.log('promise3');
})
// 9.  打印 script end
console.log('script end');
// 主线程清空,接着清空微任务队列,将微任务队列提到主线程执行👆