彻底搞懂事件循环:从原理到12道经典输出题

0 阅读10分钟

事件循环是JS面试的"照妖镜",看似简单,一做题就翻车。今天从原理到实战,一次性讲透。


前言

如果说原型链、闭包、this是JS的"三大基石",那事件循环就是JS的"灵魂机制"。

为什么这么说?因为JavaScript是单线程语言,但它又要处理异步操作(网络请求、定时器、用户交互等),那怎么办?答案就是事件循环。

我之前面试的时候,事件循环相关的题几乎每次都考,而且每次都能给我整出新花样。后来我把这块彻底搞明白了,发现其实就那么回事——理解了执行顺序的规则,什么题都不怕


一、先搞清楚几个基本概念

1.1 为什么需要事件循环?

JavaScript是单线程的,一次只能做一件事。如果遇到耗时的操作(比如发请求、读文件),整个页面就卡住了,用户疯狂点按钮没反应——这体验谁受得了?

所以JS的设计是:把耗时操作交给浏览器(或Node.js)的后台线程去处理,处理完了再通过回调函数通知主线程。

而管理"什么时候执行哪个回调"的机制,就是事件循环(Event Loop)

1.2 执行栈(Call Stack)

JS代码执行的时候,会有一个调用栈,遵循"后进先出"的原则。

function foo() {
    console.log('foo');
    bar();
}
function bar() {
    console.log('bar');
}
foo();
// 执行顺序:foo入栈 → 输出foo → bar入栈 → 输出bar → bar出栈 → foo出栈

这个很好理解,就像叠盘子,最后放上去的最先拿下来。

1.3 任务队列(Task Queue)

浏览器在执行代码的过程中,会遇到各种异步任务。这些任务不会立即执行,而是被放到不同的队列里,等执行栈空了再按规则取出来执行。

这里的关键是:队列不止一个


二、核心:宏任务与微任务

2.1 两类任务

事件循环中有两种任务:

类型常见API通俗理解
宏任务(Macro Task)setTimeoutsetIntervalI/OUI渲染requestAnimationFrame大事,慢慢来
微任务(Micro Task)Promise.then/catch/finallyMutationObserverprocess.nextTick(Node)小事,赶紧办

2.2 事件循环的执行流程

这是最核心的部分,一定要理解:

event_loop_flow.svg

关键规则:每次执行完一个宏任务(包括第一个同步代码块),都会先把所有微任务清空,再去执行下一个宏任务。

用大白话说就是:每干完一件大事,先把积攒的小事全部处理完,再干下一件大事。

2.3 一个简单的例子热热身

console.log('1');

setTimeout(() => {
    console.log('2');
}, 0);

Promise.resolve().then(() => {
    console.log('3');
});

console.log('4');

输出顺序:1 → 4 → 3 → 2

解析:

  1. 执行同步代码:输出 1,把 setTimeout 回调放入宏任务队列,把 Promise.then 放入微任务队列,输出 4
  2. 同步代码执行完毕,检查微任务队列:输出 3
  3. 微任务清空,取下一个宏任务:输出 2

记住这个顺序:同步 → 微任务 → 宏任务。


三、Promise 相关的坑

3.1 Promise 的执行时机

Promise的构造函数是同步执行的,但.then()里的回调是微任务

console.log('1');

new Promise((resolve) => {
    console.log('2');  // 同步执行!
    resolve();
}).then(() => {
    console.log('3');  // 微任务
});

console.log('4');

输出:1 → 2 → 4 → 3

这个很多人会搞错,以为Promise整体都是异步的。构造函数里的代码是同步的,只有.then/catch/finally里的回调才是微任务。

3.2 then 链式调用

new Promise((resolve) => {
    console.log('1');
    resolve();
})
.then(() => {
    console.log('2');
})
.then(() => {
    console.log('3');
});

console.log('4');

输出:1 → 4 → 2 → 3

每个.then()都会返回一个新的Promise,所以每个.then()的回调都会作为一个微任务入队。它们按顺序执行。

3.3 then 里 return 一个 Promise

Promise.resolve()
.then(() => {
    console.log('1');
    return Promise.resolve('2');
})
.then((res) => {
    console.log(res);
});

输出:1 → 2

注意:如果.then()return一个Promise,下一个.then()会等这个Promise resolve之后再执行。这个过程中会产生两个微任务(规范要求至少两个),但在实际开发中,我们通常只需要关心顺序,不需要关心具体产生了几个微任务。


四、async/await 的本质

4.1 async/await 就是 Promise 的语法糖

async函数返回一个Promise,await会"暂停"函数执行,等待Promise resolve。

async function foo() {
    console.log('1');
    await Promise.resolve();
    console.log('2');
}

console.log('3');
foo();
console.log('4');

输出:3 → 1 → 4 → 2

解析:

  1. 输出 3
  2. 调用foo(),输出 1
  3. 遇到awaitawait后面的代码相当于.then()里的回调,放入微任务队列
  4. 继续执行同步代码,输出 4
  5. 微任务队列:输出 2

关键理解:await后面的代码 = .then()里的回调 = 微任务。

4.2 await 连续多个

async function foo() {
    console.log('1');
    await console.log('2');  // 注意这里
    console.log('3');
    await console.log('4');
    console.log('5');
}

console.log('6');
foo();
console.log('7');

输出:6 → 1 → 2 → 7 → 3 → 4 → 5

解析:

  1. 同步代码:输出 6
  2. 进入foo():输出 1
  3. await console.log('2'):先同步执行console.log('2')(输出2),然后await undefined,后面的代码放入微任务
  4. 继续同步代码:输出 7
  5. 微任务:输出 3,然后await console.log('4')同步输出4,后面的代码再放入微任务
  6. 微任务:输出 5

注意await右边的表达式是同步执行的,只有await后面的代码才是微任务。


五、特殊 API 的执行时机

5.1 requestAnimationFrame

requestAnimationFrame(简称rAF)在宏任务中的执行时机比较特殊。在大多数浏览器中,rAF的回调会在微任务执行完毕后、下一次重绘前执行。

但不同浏览器实现可能有差异,面试中一般不会考得太深,知道它是宏任务就行。

5.2 process.nextTick(Node.js)

在Node.js中,process.nextTick的优先级高于Promise微任务。它会在当前微任务队列清空前执行。

// Node.js 环境
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');

输出:sync → nextTick → promise

这个是Node.js特有的,浏览器里没有process.nextTick

5.3 MutationObserver

MutationObserver是微任务,用于监听DOM变化。它在Promise微任务之后执行(在Node.js中对应Promise.then之后)。


六、12 道经典输出题

建议先自己想答案,再看解析。每道题都覆盖了不同的考点。

🟢 第1题:基础热身

console.log('start');

setTimeout(() => {
    console.log('timeout');
}, 0);

Promise.resolve().then(() => {
    console.log('promise');
});

console.log('end');
点击查看答案

输出start → end → promise → timeout

考点:同步代码优先 → 微任务(Promise.then)→ 宏任务(setTimeout)


🟢 第2题:Promise构造函数是同步的

console.log('1');

new Promise((resolve) => {
    console.log('2');
    resolve();
    console.log('3');
}).then(() => {
    console.log('4');
});

console.log('5');
点击查看答案

输出1 → 2 → 3 → 5 → 4

考点:Promise构造函数里的代码是同步执行的,只有.then()的回调是微任务。resolve()调用后,构造函数里的代码还会继续执行。


🟡 第3题:then链式调用

setTimeout(() => {
    console.log('A');
}, 0);

new Promise((resolve) => {
    console.log('B');
    resolve();
}).then(() => {
    console.log('C');
}).then(() => {
    console.log('D');
});

console.log('E');
点击查看答案

输出B → E → C → D → A

考点:多个.then()按顺序入微任务队列。同步代码执行完后,依次清空微任务,最后执行宏任务。


🟡 第4题:async/await 基础

async function foo() {
    console.log('1');
    await Promise.resolve();
    console.log('2');
}

console.log('3');
foo();
console.log('4');
点击查看答案

输出3 → 1 → 4 → 2

考点await后面的代码相当于.then()回调,是微任务。foo()调用时,先同步执行到await,然后让出执行权。


🟡 第5题:await 右侧表达式是同步的

async function foo() {
    console.log('1');
    await console.log('2');
    console.log('3');
}

console.log('4');
foo();
console.log('5');
点击查看答案

输出4 → 1 → 2 → 5 → 3

考点await右边的console.log('2')是同步执行的!只有await之后的代码(console.log('3'))才是微任务。这是很多人容易搞混的地方。


🟡 第6题:宏任务中嵌套微任务

setTimeout(() => {
    console.log('1');
    Promise.resolve().then(() => {
        console.log('2');
    });
}, 0);

setTimeout(() => {
    console.log('3');
}, 0);

console.log('4');
点击查看答案

输出4 → 1 → 2 → 3

考点:第一个setTimeout的回调执行时,产生了微任务。执行完这个宏任务后,会先清空微任务队列,再执行下一个宏任务。所以顺序是1 → 2 → 3,而不是1 → 3 → 2


🟠 第7题:微任务中产生微任务

Promise.resolve()
.then(() => {
    console.log('1');
    return Promise.resolve();
})
.then(() => {
    console.log('2');
})
.then(() => {
    console.log('3');
});
点击查看答案

输出1 → 2 → 3

考点:微任务执行过程中产生的新的微任务,会在当前轮次继续执行(而不是等到下一轮)。所以三个.then()按顺序执行。


🟠 第8题:async函数中的多个await

async function foo() {
    console.log('1');
    await Promise.resolve();
    console.log('2');
    await Promise.resolve();
    console.log('3');
}

console.log('4');
foo();
console.log('5');
点击查看答案

输出4 → 1 → 5 → 2 → 3

考点:第一个await让出执行权,console.log('2')是微任务。第二个await又让出执行权,console.log('3')是下一个微任务。每个await都会把后面的代码推入微任务队列。


🟠 第9题:经典综合题

console.log('start');

setTimeout(() => {
    console.log('timeout1');
    Promise.resolve().then(() => {
        console.log('promise1');
    });
}, 0);

setTimeout(() => {
    console.log('timeout2');
}, 0);

Promise.resolve()
.then(() => {
    console.log('promise2');
})
.then(() => {
    console.log('promise3');
});

console.log('end');
点击查看答案

输出start → end → promise2 → promise3 → timeout1 → promise1 → timeout2

解析

  1. 同步代码:start → end
  2. 微任务队列:promise2 → promise3(第二个then是第一个then执行后产生的)
  3. 第一个宏任务(timeout1):输出timeout1,产生微任务promise1
  4. 清空微任务:输出promise1
  5. 第二个宏任务(timeout2):输出timeout2

考点:宏任务执行后产生的微任务会在下一个宏任务之前清空。


🔴 第10题:async + setTimeout 混合

async function foo() {
    console.log('1');
    setTimeout(() => {
        console.log('2');
    }, 0);
    await Promise.resolve();
    console.log('3');
    setTimeout(() => {
        console.log('4');
    }, 0);
    console.log('5');
}

console.log('6');
foo();
console.log('7');
点击查看答案

输出6 → 1 → 7 → 3 → 5 → 2 → 4

解析

  1. 同步:6
  2. 进入foo()1,setTimeout回调入宏任务队列
  3. await让出执行权
  4. 同步:7
  5. 微任务:3,第二个setTimeout入宏任务队列,5
  6. 宏任务:2,然后4

考点:await之前的代码是同步的,await之后的代码是微任务。setTimeout不管在哪,回调都是宏任务。


🔴 第11题:终极综合题

console.log('1');

async function async1() {
    console.log('2');
    await async2();
    console.log('3');
}

async function async2() {
    console.log('4');
}

setTimeout(() => {
    console.log('5');
}, 0);

async1();

new Promise((resolve) => {
    console.log('6');
    resolve();
}).then(() => {
    console.log('7');
});

console.log('8');
点击查看答案

输出1 → 2 → 4 → 6 → 8 → 3 → 7 → 5

解析

  1. 同步:1
  2. 调用async1()2,调用async2()4,await让出执行权
  3. Promise构造函数:6,resolve,then回调入微任务队列
  4. 同步:8
  5. 微任务队列:先执行await后面的3,再执行then回调7
  6. 宏任务:5

考点:async函数调用时,await之前的代码是同步执行的。async2()也是同步调用的(只是返回Promise,但函数体本身同步执行)。微任务按入队顺序执行。


🔴 第12题:地狱难度(面试真题)

setTimeout(() => {
    console.log('A');
    Promise.resolve().then(() => {
        console.log('B');
    });
}, 0);

new Promise((resolve) => {
    console.log('C');
    resolve();
    console.log('D');
}).then(() => {
    console.log('E');
    setTimeout(() => {
        console.log('F');
    }, 0);
}).then(() => {
    console.log('G');
});

console.log('H');

Promise.resolve().then(() => {
    console.log('I');
});
点击查看答案

输出C → D → H → E → I → G → A → B → F

解析

  1. 同步代码:setTimeout回调入宏任务队列
  2. 同步代码:Promise构造函数 C → D,resolve,then回调入微任务队列
  3. 同步代码H
  4. 同步代码:Promise.resolve().then入微任务队列
  5. 微任务队列(当前有两个:E的then、I的then):
    • 执行E的then:输出E,setTimeout入宏任务队列,返回的Promise resolve后,下一个then入微任务队列
    • 执行I的then:输出I
    • 执行G的then:输出G
  6. 宏任务队列(当前有两个:A的setTimeout、F的setTimeout):
    • 执行A的setTimeout:输出A,Promise.then入微任务队列
    • 清空微任务:输出B
    • 执行F的setTimeout:输出F

考点:微任务执行过程中产生的微任务会在当前轮次继续执行。宏任务执行后产生的微任务在下一个宏任务之前清空。


七、一张图总结事件循环

event_loop_flow_v3.svg


八、速记口诀

为了方便记忆,我总结了几条口诀:

  1. 同步先行:同步代码永远最先执行
  2. 微任务优先:每次宏任务执行完,先把微任务全部清空
  3. 宏任务排队:宏任务按入队顺序,一个一个来
  4. await是分界线:await前面的同步,后面的微任务
  5. Promise构造同步:构造函数里的代码是同步的
  6. 微任务套微任务:微任务执行中产生的新微任务,当前轮次就执行

写在最后

事件循环的题目看似千变万化,但核心规则就那几条。把宏任务和微任务的执行顺序搞清楚,再复杂的题也能拆解出来

建议把上面12道题自己手写一遍,每道题都画出执行过程,比看十遍文章都有用。

有问题欢迎评论区交流,觉得有帮助的话点个赞呗 👋