JS 异步:Event-Loop+async/await
前言
在前端开发中,是不是经常被JS的异步代码绕晕?明明写的代码顺序一样,运行结果却大相径庭?其实核心原因就在于——JS是单线程语言,而异步操作全靠「Event-Loop(事件循环)」来调度。今天就结合具体代码案例,从进程线程、V8引擎到Event-Loop、async/await,一步步拆解,让你搞懂JS异步的底层逻辑~
一、先搞基础:进程 vs 线程
在聊JS异步之前,我们得先分清两个容易混淆的概念:进程和线程,这是理解后续内容的基石~
-
进程 :简单来说,进程就是CPU运行指令、加载和保存上下文所需的“容器”,是程序运行的独立单位。比如你打开浏览器,每多开一个Tab页,就相当于多开启了一个进程,每个进程之间相互独立,互不干扰。
-
线程 :线程是进程内的执行单元,是CPU实际执行指令的“最小单位”。一个进程可以包含多个线程,这些线程共享进程的资源,协同完成任务。
举个浏览器的例子:每个浏览器Tab进程中,会包含多个核心线程,其中和我们JS相关的有3个:
-
渲染线程:负责渲染页面(HTML、CSS渲染);
-
JS引擎线程:负责执行JS代码;
-
HTTP请求线程:负责发送网络请求。
这里有个关键知识点⚠️:因为JS代码可以修改DOM(比如document.write、appendChild),如果JS引擎线程和渲染线程同时运行,会导致页面渲染混乱,所以JS引擎线程和渲染线程是互斥的——也就是说,JS代码执行时,渲染线程会暂停,等JS执行完,渲染线程才会继续工作。这也是为什么有时候JS代码写得太复杂,页面会出现“卡顿”的原因~
二、V8引擎:JS单线程的“幕后推手”
我们写的JS代码,最终是由V8引擎来执行的。而V8引擎在执行JS代码时,默认只开启一个JS引擎线程——这就意味着,JS代码只能“自上而下、依次执行”,同一时间只能做一件事。
那问题来了:如果JS遇到耗时操作(比如setTimeout、网络请求、读取文件),难道要一直等着操作完成,再继续执行后续代码吗?这样会导致页面卡死,用户体验直接拉胯!
为了解决这个问题,JS引入了「异步机制」:单线程处理代码时,遇到同步任务,就立即执行;遇到异步任务,不等待、不阻塞,而是把它暂时存放到“任务队列”中,等JS引擎线程空闲时,再去执行任务队列中的异步任务。
三、核心重点:Event-Loop 事件循环
Event-Loop(事件循环)就是JS处理异步任务的“调度器”,它的执行流程决定了所有同步、异步代码的运行顺序。我们先明确两个核心概念:微任务和宏任务——所有异步任务,都会被分到这两个队列中。
3.1 微任务 vs 宏任务
微任务和宏任务的区别,在于它们的“优先级”:微任务优先级高于宏任务,会先于宏任务执行。
-
微任务(优先级高) :
-
Promise.then()、Promise.catch()、Promise.finally()
-
process.nextTick()(Node.js环境,浏览器不支持)
-
MutationObserver(监听DOM变化的API)
-
-
宏任务(优先级低) :
-
整个script脚本(最外层的同步代码,属于宏任务的开端)
-
setTimeout()、setInterval()
-
AJAX请求、I/O操作(比如读取文件)
-
UI渲染(页面渲染操作)
易错点提醒⚠️:很多人会误以为“setTimeout(fn, 0)”会立即执行,其实不然——setTimeout的延迟时间是“最小延迟”,不是“精确延迟”,即使设为0,也会被放入宏任务队列,等待同步代码、微任务全部执行完毕后,才会执行。
-
3.2 Event-Loop 执行顺序
记住这个顺序,就能搞定80%的异步代码输出题,结合后面的代码案例理解更透彻👇:
-
先执行同步代码(最外层script脚本,属于宏任务),执行过程中遇到异步任务,就分别存入微任务队列、宏任务队列;
-
同步代码执行完毕后,清空微任务队列(所有微任务依次执行,执行过程中产生的新微任务,也会在本次微任务队列中执行完毕);
-
微任务全部执行结束后,若有需要(如DOM发生变化),浏览器会进行页面渲染;
-
渲染完成后,从宏任务队列中取出第一个宏任务执行(执行该宏任务的过程中,遇到同步、异步任务,重复步骤1-2);
-
重复步骤1-4,形成“循环”,这就是Event-Loop。
3.3 代码实操:搞懂Event-Loop执行顺序
结合你给出的第一段代码,我们一步步拆解执行过程,看看为什么输出结果是「1 2 7 3 5 4 6」:
console.log(1); // 同步代码:输出1
new Promise((resolve) => {
console.log(2); // Promise构造函数内是同步代码:输出2
resolve()
})
.then(() => {
console.log(3); // 微任务:存入微任务队列
setTimeout(() => {
console.log(4); // 宏任务:存入宏任务队列(延迟0ms)
}, 0)
})
setTimeout(() => {
console.log(5); // 宏任务:存入宏任务队列(延迟0ms)
setTimeout(() => {
console.log(6); // 宏任务:存入宏任务队列(延迟0ms)
}, 0)
}, 0)
console.log(7); // 同步代码:输出7
执行步骤拆解👇:
-
console.log(1):同步,输出「1」;
-
new Promise:构造函数内是同步代码,console.log(2),输出「2」;调用resolve(),将then回调存入微任务队列(记为微1);
-
遇到setTimeout(延迟0ms):宏任务,存入宏任务队列(记为宏1);
-
console.log(7):同步,输出「7」;
-
同步代码执行完毕,开始清空微任务队列:执行微1(then回调),console.log(3),输出「3」;遇到setTimeout(延迟0ms),宏任务,存入宏任务队列(记为宏2);
-
微任务队列清空,渲染页面(本次无明显渲染);
-
执行宏任务队列第一个宏任务(宏2):console.log(5),输出「5」;遇到setTimeout(延迟0ms),宏任务,存入宏任务队列(记为宏3);
-
宏2执行完毕,再次检查微任务队列(无新微任务),执行下一个宏任务(宏2):console.log(4),输出「4」;
-
宏3执行完毕,检查微任务队列(无),执行下一个宏任务(宏3):console.log(6),输出「6」。
所以最终输出顺序就是:1 2 7 3 5 4 6 ✅
3.4 再练一题:巩固Event-Loop
再看这段代码👇:
console.log(1);
setTimeout(() => {
console.log(2);
setTimeout(() => {
console.log(3)
}, 1000)
}, 0)
setTimeout(() => {
console.log(4)
}, 2000)
console.log(5);
执行步骤拆解👇:
-
执行同步代码:
console.log(1)→ 输出1; -
遇到第一个
setTimeout(..., 0):延迟 0ms 后,把回调(输出 2 + 嵌套定时器)推入宏任务队列; -
遇到第二个
setTimeout(..., 2000):延迟 2000ms 后,把回调(输出 4)推入宏任务队列; -
执行同步代码:
console.log(5)→ 输出5; -
同步代码执行完毕,开始处理宏任务队列:
- 取出第一个宏任务:执行 → 输出
2; - 执行中遇到嵌套的
setTimeout(..., 1000):延迟 1000ms 后,把回调(输出 3)推入宏任务队列;
- 取出第一个宏任务:执行 → 输出
-
此时宏任务队列里,只有「延迟 2000ms 的输出 4」在等待;
-
时间流逝:
- 1000ms 到:
输出3被推入宏任务队列 → 立刻执行 → 输出3; - 再等 1000ms(总计 2000ms):
输出4被推入宏任务队列 → 执行 → 输出4。
- 1000ms 到:
最终输出顺序:1 → 5 → 2 → 3 → 4。
宏任务队列是先进先出,为什么 3 比 4 先输出?
关键点:两个定时器不是同时入队:
- 输出 4 的定时器:一开始就设定了 2000ms 延迟,2000ms 后才入队;
- 输出 3 的定时器:等第一个宏任务执行完(瞬间完成),才设定 1000ms 延迟,1000ms 后就入队执行。1000ms < 2000ms,所以
3必然比4先执行,和宏任务队列顺序无关。
JavaScript 中setTimeout的延迟时间是回调函数加入宏任务队列的等待时间,而非执行时间;宏任务队列遵循先进先出,但不同定时器的回调不是同时入队,谁的延迟时间先耗尽,谁就先入队先执行。
四、进阶:async/await 异步语法糖
async/await 是ES7引入的异步语法,本质是Promise的“语法糖”,让异步代码写起来更像同步代码,可读性大大提升。
4.1 async/await 核心规则
-
async关键字:函数前面加async,等同于函数内部自动返回一个Promise实例对象。比如:
async function fn() { return 1; // 等同于 return Promise.resolve(1); } fn().then(res => console.log(res)); // 输出1 -
await关键字:必须跟async配合使用,不能单独使用;如果await后面接的不是Promise对象,await就无法“约束”它,会直接执行后续代码;如果await后面接Promise对象,会“暂停”当前async函数的执行,等待Promise状态变为resolved(成功)或rejected(失败),再继续执行后续代码。
-
关键原理:await fn() 之所以能“当成同步看待”,核心是——await会把它后续的代码(当前async函数内,await后面的所有代码),挤到微任务队列中,等await后面的Promise执行完成后,再执行这个微任务。
4.2 代码实操:async/await 执行顺序
先看这段基础代码,理解async/await和Promise的关联:
function a() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('a'); // 宏任务
resolve()
}, 1000)
})
}
function b() {
console.log('b'); // 同步
}
// 案例1:Promise.then写法
a().then(() => {
b()
})
console.log('hello'); // 同步
输出顺序:hello → a → b(同步代码先执行,a()是Promise,then回调是微任务,等待a()的宏任务执行完,再执行微任务b())
再看async/await写法,对比差异:
function a() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('a'); // 宏任务
resolve()
}, 1000)
})
}
function b() {
console.log('b'); // 同步
}
async function foo() {
setTimeout(() => {
console.log('c'); // 宏任务(延迟1500ms)
}, 1500)
await a() // 等待a()的Promise resolve,后续代码进入微任务
b()
console.log('hello');
}
foo()
执行步骤拆解👇:
-
调用foo(),执行async函数内部代码;
-
遇到setTimeout(延迟1500ms):宏任务,存入宏任务队列(记为宏A);
-
遇到await a():a()返回Promise,里面有setTimeout(延迟1000ms,宏任务,记为宏B);此时foo()暂停执行,等待宏B执行完毕、Promise resolve;
-
同步代码执行完毕(此时foo()暂停,无其他同步代码),检查微任务队列(无),执行宏任务队列;
-
先执行宏B(延迟1000ms):console.log('a'),输出「a」;调用resolve(),此时await等待结束,将foo()后续的代码(b()、console.log('hello'))存入微任务队列;
-
宏B执行完毕,检查微任务队列,执行微任务:b()输出「b」,console.log('hello')输出「hello」;
-
微任务执行完毕,执行下一个宏任务(宏A,延迟1500ms):console.log('c'),输出「c」。
最终输出顺序:a → b → hello → c ✅
4.3 综合案例:async/await + Promise + setTimeout
console.log('script start'); // 同步
async function async1() {
await async2() // 等待async2()执行,后续代码进入微任务
console.log('async1 end'); // 微任务
}
async function async2() {
console.log('async2 end'); // 同步(async函数内,await前的代码是同步)
}
async1()
setTimeout(() => {
console.log('setTimeout'); // 宏任务
}, 0)
new Promise((resolve, reject) => {
console.log('promise'); // 同步
resolve()
})
.then(() => {
console.log('then1'); // 微任务
})
.then(() => {
console.log('then2'); // 微任务(then1执行完后存入)
});
console.log('script end'); // 同步
输出顺序:script start → async2 end → promise → script end → async1 end → then1 → then2 → setTimeout
💡 关键提醒:async函数内,await前面的代码是同步执行的;await后面的代码会被放入微任务队列,和Promise.then的微任务优先级相同,按顺序执行。
五、总结:异步核心知识点梳理
1. JS是单线程,由V8引擎的JS引擎线程执行,与渲染线程互斥;
2. 异步任务分为微任务(优先级高)和宏任务(优先级低);
3. Event-Loop执行顺序:同步代码 → 微任务队列 → 页面渲染 → 宏任务队列(循环);
4. async/await是Promise语法糖,await会将后续代码放入微任务队列,等待Promise resolve后执行。