js中的宏任务与微任务

505 阅读9分钟

在前端 JavaScript 中,宏任务(Macro Task)和微任务(Micro Task)是用于处理异步操作的两种不同的任务队列。它们的执行顺序和优先级不同,对于理解 JavaScript 异步编程非常重要。在ES6规范中,宏任务称为task, 微任务称为jobs

一、同步任务

同步任务是按照代码顺序执行的任务,每个任务必须等待前一个任务执行完成后才能执行。同步任务会阻塞代码的执行,直到任务完成为止。例如,通过函数调用、变量赋值等操作都属于同步任务。而宏任务和微任务都是异步任务

二、宏任务

包括以下一些常见的异步操作:

  1. 定时器任务(setTimeout、setInterval)的回调函数:通过定时器指定的时间间隔执行的任务属于宏任务 以及 setImmediate。
  2. DOM 事件:当用户操作触发的事件(如点击、滚动、键盘输入等)属于宏任务。
  3. Ajax 请求:发送异步请求并处理响应的过程属于宏任务。
  4. 文件读写操作(I/O):读取或写入文件的操作属于宏任务。
  5. 页面渲染:浏览器对页面进行重绘和重排的过程属于宏任务。
  6. script(同步代码)

三、微任务

包括以下一些常见的异步操作:

  1. Promise:Promise 的 resolve 和 reject 回调函数属于微任务。
  2. MutationObserver:监测 DOM 变化的回调函数属于微任务。
  3. process.nextTick(Node.js 环境下):在 Node.js 环境下执行的微任务。
  4. Object.observe

四、执行顺序

  1. 执行所有同步代码,直到执行完成。
  2. 在同步任务执行栈微空,会检查微任务队列,如果有微任务,则立即执行所有微任务,然后浏览器会进行页面的渲染和更新
  3. 当微任务执行完毕后,必要的话渲染UI然后开启下一轮 Event loop会去检查宏任务队列,如果宏任务队列中有任务,则选择1个宏任务执行
  4. 执行完一个宏任务后,会再次检查微任务队列,执行所有微任务。
  • 上述过程(2-4)循环执行,直到宏任务队列和微任务队列都为空。

简而言之,宏任务的执行是异步的,需要等待当前的同步任务执行完成后才会执行。而微任务则是在当前任务执行完毕后立即执行,不需要等待其他任务。通过这种机制,JavaScript实现了异步编程,允许执行一些耗时操作而不阻塞主线程的执行。 根据上面的顺序,如果宏任务的异步代码中有大量的计算并且要操作DOM,那么为了更快让页面响应,可以把操作DOM放在微任务中?

总结: 宏任务的执行会触发浏览器的渲染,而微任务的执行则是在宏任务执行完毕、渲染之前进行,这保证了微任务可以在页面更新之前执行,从而能够及时处理一些需要在下一帧之前完成的操作。

这种任务队列的机制使得 JavaScript 可以进行高效的异步编程,通过合理地利用宏任务和微任务,可以避免阻塞主线程,提高页面的响应性能和用户体验。

  • axois的回调函数属于宏任务,但是回调函数的内部可能是宏任务与微任务交替进行。

五、demo

github地址-等待完善

1、简单例子

console.log('Start'); // 同步任务

setTimeout(() => {
  console.log('Timeout 1'); // 宏任务
}, 2000);

setTimeout(() => {
  console.log('Timeout 2'); // 宏任务
  setTimeout(() => {
    console.log('Timeout 2-0'); // 宏任务
  }, 0);
  Promise.resolve().then(() => {
    console.log('Promise inside Timeout 2'); // 微任务
  });
}, 1000);

Promise.resolve().then(() => {
  console.log('Promise 1'); // 微任务
}).then(() => {
  console.log('Promise 2'); // 微任务
});

for(let i=0;i<2000000000;i++); // 同步任务
console.log('for循环结束');

setTimeout(() => {
  console.log('Timeout 0'); // 宏任务
}, 0);

console.log('End'); // 同步任务

/**
Start、for循环结束、End [同步任务]
Promise 1、Promise 2 [微任务]
Timeout 0 [宏任务]
Timeout 2 [宏任务]
Promise inside Timeout 2 [微任务]
Timeout 2-0 [宏任务]
Timeout 1 [微任务]
*/

2、分析

  • 这个例子中展示了宏任务和微任务之间的交替执行顺序,以及它们在事件循环中的执行顺序。
  • 注意,虽然定时器的延迟时间被设置为 0,但它仍然被视为宏任务,因此在微任务执行完成之后才会执行它。
  • Promise.resolve().then() 的方式,这是一种立即可执行的方式,即 then() 方法会立即执行,并将回调函数(Promise 1 )添加到微任务队列中,而不是等待 Promise 对象的状态改变。

3、任务与微任务的加入队列的时机分析

队列中的内容是在运行时动态添加的,不是固定不变的

按照同步任务、(微任务、宏任务)1、(微任务、宏任务)2... 的执行顺序看:

  • 最外侧的宏任务是一次性加入到宏任务队列中、最外侧的微任务也是一次性加入到微任务队列中。最外侧指的是不是在宏任务或者微任务内部创建的任务
  • 当执行宏任务过程中产生了微任务,这些微任务会被添加到微任务队列中。当宏任务执行完毕后,会立即检查微任务队列,如果队列中有微任务,则按照顺序依次立即执行所有微任务,直到微任务队列为空(这期间不会被其他宏任务打断)。然后才会继续执行下一个宏任务。

这种动态创建和执行的机制确保了微任务能够在宏任务之间进行及时处理,以便在整个执行过程中保持正确的顺序和响应性。

4、队列情况分析

以下是在上述代码每一步执行之后,剩余的宏任务与微任务队列的情况:

  1. 收集最外侧所有的同步任务。
    • 同步任务:[Start、for循环结束、End]
    • 宏任务队列:[setTimeout 1 (2000ms), setTimeout 2 (1000ms), setTimeout 0 (0ms)]
    • 微任务队列:此时产生[Promise 1, Promise 2](...)
  2. 开始顺序执行同步任务,输出 Start for循环结束 End,之后。
    • 宏任务队列:[setTimeout 1 (2000ms), setTimeout 2 (1000ms), setTimeout 0 (0ms)]
    • 微任务队列:此时保留[Promise 1, Promise 2]
  3. 处理所有微任务队列,输出 Promise 1、Promise 2,之后。
    • 宏任务队列:[setTimeout 1 (2000ms), setTimeout 2 (1000ms), setTimeout 0 (0ms)]
    • 微任务队列:此时没产生[]
  4. 开始处理宏任务队列,输出 Timeout 0,之后。
    • 宏任务队列:[setTimeout 1 (2000ms), setTimeout 2 (1000ms)]
    • 微任务队列:此时没产生[]
  5. 处理所有微任务队列,但是没有,之后。
    • 宏任务队列:[setTimeout 1 (2000ms), setTimeout 2 (1000ms)]
    • 微任务队列:此时没产生[]
  6. 开始处理宏任务队列,输出 Timeout 2,之后。
    • 宏任务队列:[setTimeout 1 (2000ms), setTimeout 2-0 (0ms),]
    • 微任务队列:此时产生[Promise inside Timeout 2]
  7. 处理所有微任务队列,输出 Promise inside Timeout 2,之后。
    • 宏任务队列:[setTimeout 1 (2000ms), setTimeout 2-0 (0ms),]
    • 微任务队列:此时没产生[]
  8. 开始处理宏任务队列,输出 Timeout 2-0,之后。
    • 宏任务队列:[setTimeout 1 (2000ms),]
    • 微任务队列:此时没产生[]
  9. 处理所有微任务队列,但是没有,之后。
    • 宏任务队列:[setTimeout 1 (2000ms),]
    • 微任务队列:此时没产生[]
  10. 开始处理宏任务队列,输出 Timeout 1,之后。
    • 宏任务队列:[]
    • 微任务队列:此时没产生[]

注意,执行完所有任务后,宏任务队列和微任务队列都为空。

5、await = 同步任务|暂停操作

await 表达式会暂停当前的异步函数的执行,同时也会阻塞后续的任务(无论是同步任务、宏任务还是微任务),直到等待的 Promise 对象状态解决。只有当 await 后面的操作完成后,才会继续执行后续的代码。

6、解释延时不准确的原因

6.1 setTimeout

function taskA() {...}

setTimeout(() => {
  taskA();
},3000) // 3秒之后,将taskA加入到Event Queue

sleep(5000); // 等待5秒

setTimeout函数,是经过指定时间后,把要执行的任务加入到Event Queue中, 而不是经过指定时间之后马上执行任务。因为是JS是非阻塞性单线程语言,所以任务要一个一个顺序执行。如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于3秒,所以延时程序并不能精确控制时间。setTimeout(taskA,0)的含义是只要主线程执行栈内的同步任务全部执行完成之后,马上执行(注意不是立即执行,而是要等到同步任务的栈为空)即使此时主线程为空,那么时实际上也达不到0毫秒。根据HTML的标准,最低是4毫秒

6.2 setInterval

setInterval会每隔固定的时间,将函数任务置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。但是,对于setInterval(taskA,2000)来说,可能会出现一个神奇的现象,比如下面例子(一旦setInterval的回调函数的执行时间超过了设置的延迟时间,那么就完全看不出来有时间间隔了)

let cnt = 0;
const interval = setInterval({
  console.log('taskA'); // 或者改成 sleep等同步操作。
  cnt += 1; 
  if (cnt == 5) clearInterval(interval);
}, 1000); // 每隔2秒就会把任务加入到Event Queue中

sleep(10000); // 等待10秒

6.3

是指定时间之后才加入队列,还是加入队列之后等待指定时间。

7、Promise

Promise的构造函数中的代码是同步任务,而then方法中的回调函数是异步任务

console.log('start');
setTimeout(function(){console.log('timeout')},0);
new Promise((resolve) => {
  console.log('Promise');
  resolve();
}).then(function(){
  console.log('Promise 1');
}).then(function() {
  console.log('Promise 2');
});
console.log('end');
/**
start、Promise、end、Promise 1、Promise 2、timeout
*/