《一文搞懂 JavaScript 事件循环(Event Loop):宏任务、微任务与 async/await》
1.预备知识
1.1 同步和异步
在讨论事件循环前,首先需要先了解同步与异步的概念
JavaScript 是单线程语言,一行代码执行完才会再执行下一行,这个概念称之为同步。
但是如果取一个服务器中拿数据,需要等待 10 秒才能拿到,等待途中无法进行任何操作,是很糟糕的使用体验,于是就有了异步。
异步的程式或事件,并不会阻碍主线程执行其他代码,例如,拿取资料当作是一个异步事件,异步事件会在完成之后再通知主线程,而在这之中,主线程可以继续执行其他代码、使用者互动也不受异步事件的阻挡。而浏览器或其他的执行环境(例如Node.js) 之所以能够实践异步,正是因为有事件循环(Event loop) 的机制。透过事件循环机制,能有效解决JavaScript 单执行绪的问题,让耗时的操作不会阻塞主线程。
2.事件循环基础知识
2.1 事件循环的组成-执行栈和任务队列
事件循环不存在JavaScript 本身,而是由JavaScript 的执行环境(浏览器或Node.js) 来实现的,其中包含几个概念:
- 堆(Heap):堆是一种数据结构,拿来储存物件
- 栈(Stack):采用后进先出的规则,当函数执行时,会被添加到栈的顶部,当执行完成时,就会从顶部移出,直到栈被清空
- 队列(Queue):也是一种数据结构,特性是先进先出(FIFO)。在JavaScript 的执行环境中,等待处理的任务会被放在队列(Queue) 里面,等待栈(Stack) 被清空时,会从队列(Queue)中拿取第一个任务进行处理
- 事件循环(Event loop):事件循环会不断地去查看栈(Stack) 是否空出,如果空出就会把队列(Queue)中等待的任务放进栈(Stack)中执行
2.2 事件循环(Event loop)的过程
1.User Interface(橙色部分)的User Interface,是浏览器页面,用户操作的来源,比如点击按钮,输入,页面展示等等
UI 不执行 JS,只是触发事件。
2.JavaScript Runtime(黄色部分),其中有 Heap(堆)和 Call Stack(调用栈)
Heap 负责,存数据,对象/数组/函数对象,不关心执行顺序,Heap=内存仓库。
Call Stack(调用栈),执行 JS 代码,函数一层一层压栈,单线程,只能有一个。
3.Web APIs(绿色部分)
理解事件循环的关键
这里的东西不是 JS 在跑,而是:浏览器提供的能力,独立于 JS 线程。
包括:
- DOM 事件(click)
- AJAX/fetch
- 定时器(setTimeout)
JS 调用它们,只是登记一下
4.Callback Queue(蓝色部分)
回调的候车室,分成了宏任务队列(Task Queue) 微任务队列(Microtask Queue)
中间的循环箭头,是一种机制,不停的检查:call Stack 是否为空,空了就从 Queue 拿一个回调,不空就等着
完整执行流程的描述:
Step 1:
UI 触发 JS,同步代码直接执行
Step 2:
遇到 Web API(异步任务)(如 setTimeout)
1.setTimeout() 进 Call Stack
2.浏览器 Web API 接管计时
3.JS 立刻继续往下走(不等)
setTimeout(fn, 1000)
setTimeout() 本身是一次“同步函数调用”,所以一定会先进 Call Stack;但 setTimeout 里传的 callback,才会进入宏任务队列。
部分 是什么 去哪 setTimeout() 同步函数调用 Call Stack fn(回调) 异步回调 宏任务队列(Task Queue)
Step 3:Web API 完成 → 回调进 Queue
- 浏览器说:「callback 已经准备好了」
- 放进 Callback Queue,不是立刻执行
Step 4:Event Loop 把回调塞回 Call Stack
条件只有一个:Call Stack 必须是空的
满足后:
- callback → Call Stack
- JS 开始执行回调函数
2.3 宏观任务 微观任务
JavaScript 中的异步任务又分成宏任务(Macro Task) 和微任务(Micro Task),这两者的执行顺序是不同的
常见的宏任务与微任务如下:
- 宏任务:
script(整体程式码)、setTimeout、setInterval、I/O、事件、postMessage、MessageChannel、setImmediate(Node.js) - 微任务:
Promise.then、Promise.catch、MutaionObserver、process.nextTick(Node.js)。
async 函数本身:同步调用,await 的本质:Promise.then 的语法糖.
比如 async1 中有一个await async2()
await async2() 会同步立即执行 async2,并且把 await 后面的代码包装成一个「微任务」去执行。
执行顺序如下:
- 执行一次宏任务(最开始会是整个
srcipt所以上面的例子会先执行console.log(1)) - 执行过程中如果遇到宏任务,就放进宏任务列队
- 执行过程中如果遇到微任务,就放进微任务列队
- 当执行栈空了,先检查微任务列队,如果有微任务,就依序执行直到微任务列队为空
- 接着进行浏览器的渲染,渲然完后开始下一个宏任务(回到最开始的步骤)
3.实战题目
3.1 分析含有Promise的实战题目
Promise 和 setTimeout 都是同步注册异步任务的机制,只是 Promise 注册的是微任务,setTimeout 注册的是宏任务。
Promised 中的执行器函数是同步立即执行,.then .catch 才是异步的微任务
console.log('A');
const p = new Promise((resolve, reject) => {
// 这段就是 executor(执行器函数)
console.log('B: executor start');
resolve('OK');
console.log('C: executor end');
});
p.then((value) => {
console.log('E: then', value);
});
console.log('D');
resolve 会标记 Promise 为 fulfilled,并在「当前执行上下文结束后」派发微任务,将.then 加入到微任务队列中
「当前执行上下文结束后」指的是整个当前宏任务(script)执行完毕,也就是 call Stack 为空。
输出如下:
A
B: executor start
C: executor end'
D
此时call Stack 栈空,将微任务队列加入 call Stack,保证call Stack 栈空后 1,微任务队列也为空,才能执行下一个宏任务
最终如下:
A
B: executor start
C: executor end'
D
E: then
promise 中的 resolve
.then 只有在 Promise「状态发生改变(resolve / reject)」的那一刻,才会被“派发”为微任务
Promise 的状态变化是同步的、确定的、不可回退的
resolve 在当前调用栈里已经执行了吗?
- 执行了 → Promise 已 fulfilled → then 进微任务
- 没执行 → Promise 还 pending → then 只注册
情况一: promise 立刻变成fulfilled
Promise.resolve().then(() => {
console.log("promise 1");
});
这个 promise 立刻变成fulfilled
情况二:执行到 resolve,promise 状态变成fulfilled
setTimeout(function () {
console.log("setTimeout 2");
resolve("resolve 1");
}, 0);
}).then((res) => {
这个就得等着resolve 执行才能变成fulfilled
情况三:不可预测
fetch('/api').then(res => {
console.log('then');
});
Promise 的 resolve 时机是由外部事件决定的(如网络、I/O、定时器),
对静态代码分析来说是不可预测的,但对运行时来说是确定的
所以面试题一般都是明确告知 resolve
3.2 题目实战
题目一:
console.log(1);
setTimeout(function () {
console.log(2);
}, 0);
Promise.resolve()
.then(function () {
console.log(3);
})
.then(function () {
console.log(4);
});
输出:
1;
3;
4;
2;
题目二:
console.log("begins");
setTimeout(() => { //1
console.log("setTimeout 1");
Promise.resolve().then(() => { //2
console.log("promise 1");
});
}, 0);
new Promise(function (resolve, reject) { // 3
console.log("promise 2");
setTimeout(function () { // 4
console.log("setTimeout 2");
resolve("resolve 1");
}, 0);
}).then((res) => { //5
console.log("dot then 1");
setTimeout(() => { //6
console.log(res);
}, 0);
});
宏任务队列:script(整体程式码)
微任务队列:无
宏任务队列:(1) (4)
微任务队列:无
此时微任务队列为空应该继续执行宏任务(1)
宏任务队列:(4)
微任务队列:(2)
此时宏任务(1)执行完应该执行微任务(2)
此时应该继续执行加入新的宏任务
宏任务队列:(4)
微任务队列:
执行到这 resolve("resolve 1"); (5)加入微任务队列
宏任务队列:
微任务队列:(5)
宏任务队列:(6)
微任务队列:
输出:
begins;
promise 2;
setTimeout 1;
promise 1;
setTimeout 2;
dot then 1;
resolve 1;
题目三:
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
console.log("script start");
setTimeout(function () {
console.log("setTimeout");
}, 0);
async1();
new Promise(function (resolve) {
console.log("promise1");
resolve();
}).then(function () {
console.log("promise2");
});
console.log("script end");
输出如下:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
注意:注意,await后的程式码会被放到微任务列队,所以不会马上印出'async1 end'而是会把它放到微任务列队
过程:
- 执行代码
- setTimeout 进入宏任务队列
- 执行async1,然后呼叫await async2()所以印出'async2'。但是 async2 是同步立即执行的。注意,await`后的程式码会被放到微任务列队,所以不会马上印出'async1 end而是会把它放到微任务列队
- 执行 promise 的执行器函数,promise 状态变成fullfiled,等待等待 call Stack 为空的时候将.then 加入微任务队列
- 将两个微任务队列加入 call Stack,执行两个微任务有先后顺序
- call Stack 为空,微任务队列为空,从宏任务队列中拿出一个宏任务加入 call Stack
- 执行 call Stack