js宏任务和微任务执行顺序详解

最近看了死月的 趣学 Node.js 小册,关于宏任务、微任务部分突然意识到所谓的执行顺序其实就是底层 C++ 写的各种代码的结果,当了解了 Node.js 代码或者 V8 代码再看这些问题真的就是降维打击(当然我只是有了这个感觉,还没细看过[旺柴])。

但如果平常用不到,我们也没必要真的去看底层的代码,即使不了解底层代码,我们也可以根据具体的表现来自己定一些规则进行理解,只要根据这个规则来判断执行顺序是正确的,能指导平常开发也就足够了。

这篇文章只讲基本的概念,不进行深入,能够判断 setTimemoutPromise 的执行顺序即可。

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

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

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

宏任务

生成方法:

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

重点来看下 setTimeout

1
2
3
4
5
6
7
8
9
setTimeout(() => {
console.log('setTimeout block')
}, 100)

while (true) {

}

console.log('end here')

以上代码会输出什么?


什么都不会输出

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

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

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

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

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

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

因此什么都不会输出。

再来看一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 怎么用这里不细说了,重点放到输出顺序上。

1
2
3
4
5
6
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

所以

1
2
3
4
function main() {
method()
console.log(2)
}

相当于

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

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

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

1
2
3
4
5
6
7
8
9
async function method() {
await method2();
console.log(1)
}

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

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

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

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

所以先输出 2,后输出 1

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


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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()

再看一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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,相当于将后边的代码放到了微任务队列中。

所以

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

等价于:

1
2
3
4
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 放到下一个宏任务中执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 产生宏任务

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

练习

来一道魔鬼题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 句话和注释可以看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 等,它们也有各自的版本。

对于下边的代码:

1
2
3
4
5
6
7
8
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

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

image-20230409083903731

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

image-20230409083940125

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

image-20230409084009540

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

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

windliang wechat