《一文搞懂 JavaScript 事件循环(Event Loop):宏任务、微任务与 async/await》

56 阅读7分钟

《一文搞懂 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 开始执行回调函数

image-20251222175436720

2.3 宏观任务 微观任务

JavaScript 中的异步任务又分成宏任务(Macro Task) 和微任务(Micro Task),这两者的执行顺序是不同的

常见的宏任务与微任务如下:

  • 宏任务:script(整体程式码)、setTimeoutsetInterval、I/O、事件、postMessageMessageChannelsetImmediate(Node.js)
  • 微任务:Promise.thenPromise.catchMutaionObserverprocess.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