灏天阁

干货:宏任务和微任务执行顺序详解

· Yin灏

众所周知,JavaScript 单线程执行的,所以对于一些耗时的任务,我们可以将其丢入任务队列当中,这样一来,也就不会阻碍其他同步代码的执行。等到异步任务完成之后,再去进行相关逻辑的操作。

js 在主线程中执行的顺序:宏任务 -> 宏任务 -> 宏任务 …

在每一个宏任务中又可以产生微任务,当微任务全部执行结束后执行下一个宏任务。 【宏任务 [微任务]】 -> 【宏任务 [微任务]】-> 【宏任务 [微任务]】…

宏任务

生成方法:

  • 用户交互:用户在页面上进行交互操作(例如点击、滚动、输入等),会触发浏览器产生宏任务来响应用户操作。
  • 网络请求:当浏览器发起网络请求(例如通过 AjaxFetchWebSocket 等方式)时,会产生宏任务来处理请求和响应。
  • 定时器:通过 JavaScript 宿主环境提供的定时器函数(例如 setTimeoutsetInterval)可以设置一定的时间后产生宏任务执行对应的回调函数。
  • DOM 变化:当 DOM 元素发生变化时(例如节点的添加、删除、属性的修改等),会产生宏任务来更新页面。
  • 跨窗口通信:在浏览器中,跨窗口通信(例如通过 postMessage 实现)会产生宏任务来处理通信消息。
  • JavaScript 脚本执行事件;比如页面引入的 script 就是一个宏任务。

重点来看下 setTimeout

setTimeout(() => {
  console.log("setTimeout block");
}, 100);

while (true) {}

console.log("end here");

以上代码会输出什么?


什么都不会输出

上边代码相当于两个宏任务:

第一个宏任务就是上边的整个脚本

第二个宏任务是 setTimeout 传入的这个函数

() => {
    console.log('setTimeout block')
},

第一个宏任务执行到 while true 的时候死循环了,所以自己的 console.log('end here') 不会执行。

第二个宏任务也没有机会执行到。

因此什么都不会输出。

再来看一个:

const t1 = new Date();
setTimeout(() => {
  const t3 = new Date();
  console.log("setTimeout block");
  console.log("t3 - t1 =", t3 - t1);
}, 100);

let t2 = new Date();

while (t2 - t1 < 200) {
  t2 = new Date();
}

console.log("end here");

t1 记录开始的时间,设置一个 100 毫秒执行的定时器,定时器中输出执行当前任务的时间。

那么  console.log('t3 - t1 =', t3 - t1) 输出的是多少呢?


输出答案是 200

同样的,上边是两个宏任务。

整个脚本是第一个宏任务。

计时器生成了第二个宏任务。

只有第一个宏任务执行结束后才会执行第二个宏任务。

所以即使定时器时间到了也不会立刻执行,只有当第一个宏任务执行结束后才会去执行定时器的任务,此时已经过去了 200 毫秒。

微任务

生成方法:

  • PromisePromise 是一种异步编程的解决方案,它可以将异步操作封装成一个 Promise 对象,通过 then 方法注册回调函数,当 promise 变为 resolve 或者 reject 会将回调函数加入微任务队列中。
  • MutationObserverMutationObserver 是一种可以观察 DOM 变化的 API,通过监听 DOM 变化事件并注册回调函数,将回调函数加入微任务队列中。
  • process.nextTickprocess.nextTickNode.js 中的一个 API,它可以将一个回调函数加入微任务队列中。

重点看 Promise 的使用,关于 Promise 怎么用这里不细说了,重点放到输出顺序上。

const r = new Promise(function (resolve, reject) {
  console.log("1");
  resolve();
});
r.then(() => console.log("2"));
console.log("3");

上边的输出什么:


比较基础的使用。输出 1 3 2

new Promise 接受一个函数,返回一个 Promise 对象。值得注意的一点是传给 Promise 的那个函数会直接执行。所以会先输出 1

Promise 对象拥有一个 then 方法来注册回调函数,当 promise reslove 或者 reject 后会将注册函数加到微任务队列

上边的代码因为是直接 resolve 了,所以会将 () => console.log("2")   注册到微任务队列中。

宏任务执行完毕后开始执行微任务,所以最后输出  2

再看下 asyncawait

async function method() {
  await method2();
  console.log(1);
}

function method2() {
  const promise = new Promise((resolve) => resolve());
  return promise;
}

function main() {
  method();
  console.log(2);
}

上边的会输出什么呢?


先输出 2,再输出 1

这里需要明确一点,async 修饰的函数,相当于给当前函数包了一层 Promise

所以

function main() {
  method();
  console.log(2);
}

相当于

function main() {
  new Promise((resolve,reject){ resolve(method())}
  console.log(2)
}

结合前边说的传给 Promise 的那个函数会直接执行。

所以先执行 resolve(method()),进入 method 内部: 接下来是 await 的作用:遇到 await先执行 await 右边的逻辑,执行完之后会暂停到这里。跳出当前函数去执行之前的代码。 所以 method() 方法中,

async function method() {
  await method2();
  console.log(1);
}

function method2() {
  const promise = new Promise((resolve) => resolve());
  return promise;
}

先执行了 method2 ,当 method2 返回了 Promise 后就会暂定执行,跳回 main 函数。

function main() {
  new Promise((resolve,reject){ resolve(method())}
  console.log(2)
}

main 函数执行完毕后才会再回到 method 方法中。

所以先输出 2,后输出 1

如果想要先输出 1 再输出 2 需要怎么改呢?

async function method() {
  await method2();
  console.log(1);
}

function method2() {
  const promise = new Promise((resolve) => resolve());
  return promise;
}

async function main() {
  await method(); // 这里 await 即可
  console.log(2);
}

main();

再看一个:

async function method() {
  new Promise((resolve) => resolve()).then(() => console.log(1));
  const n = await method2();
  console.log(n);
}

function method2() {
  const promise = new Promise((resolve) => resolve(2));
  return promise;
}

function main() {
  method();
  console.log(3);
}

main();

上边的会输出什么呢?


main 函数执行结束后,按照之前说的应该是回到 await 那里,所以应该输出 3 2 1 吗?

其实是不对的,await 还有一个特性,它会把后边执行的代码整个注册为回调函数,相当于放到了 .then 里边,如果 Promise 直接 resolve,相当于将后边的代码放到了微任务队列中。

所以

async function method() {
  new Promise((resolve) => resolve()).then(() => console.log(1));
  const n = await method2();
  console.log(n);
}

等价于:

async function method() {
  new Promise((resolve) => resolve()).then(() => console.log(1));
  new Promise((resolve) => resolve(method2())).then((n) => console.log(n));
}

await 之前已经有一个 Promise 把任务加到了微任务队列中。所以正确的输出顺序是 3 1 2

所以回到 await 继续执行其实是表象,本质上是从微任务队列中把之前要执行的代码取了出来继续执行。

如果想输出 3 2 1 ,该怎么改代码呢?


可以将 new Promise((resolve) => resolve()).then(() => console.log(1)); 这句中的 reslove() 函数延迟调用,通过 setTimeout 放到下一个宏任务中执行。

async function method() {
  new Promise((resolve) => setTimeout(resolve, 0)).then(() => console.log(1));
  const n = await method2();
  console.log(n);
}

function method2() {
  const promise = new Promise((resolve) => resolve(2));
  return promise;
}

function main() {
  method();
  console.log(3);
}

综合

如果理解了上边的,下边的内容就简单了,首先明确几个点:

  1. 【宏任务 [微任务]】 -> 【宏任务 [微任务]】-> 【宏任务 [微任务]】…

当宏任务和当前宏任务产生的微任务全部执行完毕后,才会执行下一个宏任务。每遇到生成的微任务就放到微任务队列中,当前宏任 务代码全部执行后开始执行微任务队列中的任务

  1. 传给 new Promise 的函数会直接执行
  2. async 包装的函数相当于包了一层 Promise ,因此返回的一定是一个 Promise
  3. 执行到 await,先执行 await 右边的东西,执行完后后会暂停在 await 这里,并且把后边的内容丢到 then(再结合第 5 点)。跳到外边接着执行。外边都执行完之后开始执行微任务队列
  4. promise 变为 resolve 或者 reject 的时候才会将 then 中注册的回调函数加入微任务队列中
  5. setTimeout 产生宏任务

可以多读几遍下边开始正式练习,看代码的时候函数定义直接跳过,从执行函数开始看

练习

来一道魔鬼题:

async function method() {
  console.log(1);
  new Promise((resolve) => resolve()).then(() => console.log(2));
  new Promise((resolve) => {
    setTimeout(() => {
      resolve();
      new Promise((resolve) => resolve()).then(() => console.log(3));
    }, 0);
  }).then(() => console.log(4));
  await method3();
  console.log(5);
  const n = await method2();
  console.log(n);
}

function method2() {
  const promise = new Promise((resolve) => {
    console.log(6);
    setTimeout(() => {
      console.log(7);
      resolve(8);
    }, 0);
  });
  return promise;
}

function method3() {
  const promise = new Promise((resolve) => {
    console.log(9);
    resolve();
  });
  return promise;
}

function main() {
  method();
  new Promise((resolve) => {
    resolve();
  }).then(() => {
    console.log(10);
  });
  console.log(11);
}

main();
console.log(12);

上边的代码输出什么?


分析的时候我们需要明确什么时候产生了宏任务,什么时候产生了微任务,什么时候是直接执行的,结合上边总结 6 句话和注释可以看一下:

async function method() {
  console.log(1); //[1]
  new Promise((resolve) => resolve()).then(() => console.log(2)); // 第 1 个宏任务中注册微任务 1 // [5]
  new Promise((resolve) => {
    setTimeout(() => {
      resolve();
      new Promise((resolve) => resolve()).then(() => console.log(3)); // 第 2 个宏任务中注册微任务 2 // [10]
    }, 0); // 注册宏任务 2
  }).then(() => console.log(4)); // 第 2 个宏任务中注册微任务 1 // [9]
  await method3(); // 第 1 个宏任务中注册微任务 2
  console.log(5); // 第 1 个宏任务中注册微任务 2 // [6]
  const n = await method2(); // 第 1 个宏任务中注册微任务 2
  console.log(n); // 第 1 个宏任务中注册微任务 2 // 第 3 个宏任务中注册微任务 1 // [12]
}

function method2() {
  const promise = new Promise((resolve) => {
    console.log(6); // [7]
    setTimeout(() => {
      console.log(7); // [11]
      resolve(8);
    }, 0); // 注册宏任务 3
  });
  return promise;
}

function method3() {
  const promise = new Promise((resolve) => {
    console.log(9); //[2]
    resolve();
  });
  return promise;
}

function main() {
  method();
  new Promise((resolve) => {
    resolve();
  }).then(() => {
    console.log(10); // 第 1 个宏任务中注册微任务 3 // [8]
  });
  console.log(11); //[3]
}

main();
console.log(12); //[4]

总结

当然上边的规则也不是黄金原则,归根到底还依赖于我们运行的环境是什么,现在 js 的运行时有 V8Node.js 等,它们也有各自的版本。

对于下边的代码:

const p = Promise.resolve();

(async () => {
  await p;
  console.log("after:await");
})();

p.then(() => console.log("tick:a")).then(() => console.log("tick:b"));

按照之前规则,先执行 await p  ,因为 p   已经 resolve 了,所以会把后边的代码 console.log("after:await"); 加入到微任务队列中。

接着又依次把 () => console.log("tick:a")() => console.log("tick:b")   加到微任务队列中。

所以输出是 after:await,tick:a, tick:b

在浏览器中运行符合我们的想法:

Node.js V16 中运行符合我们的想法:

但在 Node.js V10 中运行就些许不一样了:

至于为什么就是文章开头说的了,不管输出什么,其实就是其底层代码所决定的了。再具体的原因就需要去看 Node.js 相应的源码了。

当底层的逻辑影响到我们的业务逻辑的时候,可能就真的得去看这些源码和解决方案了。

- Book Lists -