一、JavaScript 单线程特性
JavaScript 采用单线程执行模式,这是由其设计初衷决定的:
- 避免 DOM 操作冲突:JavaScript 可以操作 DOM 结构,如果多个线程同时修改同一个 DOM,会导致渲染不一致和数据竞争问题。
- 简化编程模型:多线程需要引入锁机制来协调资源访问,单线程无需考虑锁机制,降低了编程复杂度,减少了设备性能开销。
二、js任务分类
JavaScript 中的任务分为两大类:同步任务 和异步任务。
2.1 同步任务
同步任务在计算机性能足够时几乎不耗时 ,它在主线程上立即执行 ,前一个任务不完成,后一个任务不会开始。例如变量赋值、算术运算、console.log() 等都是同步任务。
2.2 异步任务
异步任务是指需要等待特定条件触发后才能执行的任务,它们会被存放到任务队列中,等待主线程执行完同步任务时再执行。异步任务按耗时长短又分为两大类:
宏任务
异步任务中耗时相对较长的任务,会被存放到宏任务队列中,主要有以下几种:
script(script标签也属于一个宏任务)setTimeout()/setInterval()- I/O 操作(文件读写、网络请求)
- UI 渲染
微任务
异步任务中耗时相对较短的任务,会被存放到微任务队列中,微任务相较于宏任务优先执行。主要有以下几种:
Promise.then()/Promise.catch()/Promise.finally()process.nextTick()(Node.js)MutationObserver
以下面这段代码为例,可以直观看到同步与异步的划分:
javascript
let a = 1
console.log(a); // 同步:立即输出 1
new Promise((resolve) => {
a = 2
console.log(a); // 同步:Promise 构造函数内部同步执行,输出 2
resolve()
}).then(() => {
a = 3
console.log(a); // 异步微任务:等待同步代码全部执行完再执行,输出 3
})
setTimeout(() => {
a = 4
console.log(a); // 异步宏任务:1 秒后才执行,输出 4
}, 1000)
执行顺序分析:
a = 1和console.log(a)是同步任务,立即输出1new Promise(...)也是同步任务,a变为2,输出2;new Promise(...).then()是微任务,进入微任务队列setTimeout(...)进入宏任务队列,等待 1 秒- 同步代码执行完毕,此时开始执行异步任务,从队列中取出微任务:
.then()执行,a变为3,输出3 - 1 秒后,取出宏任务:
setTimeout执行,a变为4,输出4
三、事件循环机制
事件循环(Event Loop)是 JavaScript 协调同步任务和异步任务的核心机制。JS 引擎执行代码的完整流程如下:
- 先执行同步任务(script标签属于宏任务),这个过程中,遇到异步就存入对应的队列中
- 去微任务队列中查找微任务,并将微任务全部取出来执行
- 有需要的情况下,就渲染页面(只发生在微任务清空后)
- 去宏任务队列中查找宏任务,并将宏任务取出一个来执行,此时宏任务内部可能产生新的宏任务或微任务 (也是下一次循环的开始)
关键要点:每执行完一个宏任务,都会清空微任务队列,再进行下一轮循环。微任务的优先级高于宏任务。
代码示例:
javascript
console.log(1);
new Promise((resolve) => {
console.log(2);
resolve()
}).then(() => {
console.log(3);
setTimeout(() => {
console.log(4);
}, 0);
})
setTimeout(() => {
console.log(5);
}, 1000);
console.log(6);
分析:
console.log(1)------ 同步,输出1- 创建
Promise,同步执行 ------console.log(2)输出2,将.then(...)推入微任务队列 setTimeout(..., 1000)------ 将宏任务 A(1 秒后执行)加入宏任务队列console.log(6)------ 同步,输出6- 「同步代码执行完毕」------ 开始清空微任务队列
- 执行
.then()------ 输出3,setTimeout(..., 0)将宏任务 B 加入宏任务队列 - 「微任务队列已空」------ 等待宏任务
- 宏任务 B(0 毫秒)计时器先到 ------ 输出
4 - 宏任务 A(1000 毫秒)后到 ------ 输出
5
从这个例子可以清楚看出:同步代码先跑完 → 微任务清空 → 宏任务按时间顺序依次执行。即使 setTimeout(..., 0) 写在了 .then() 里面,也要等外层宏任务完了、微任务完了,才会轮到它。
注意: 所有 setTimeout 共用同一个计时器体系,先进队列的不一定先执行,执行顺序取决于各自的延迟时间长短。例如上面的例子中,当定时器延迟分别为 0ms 和 1000ms 时,0ms 的宏任务先执行,1000ms 的后执行。
进阶示例:宏任务内嵌套微任务
当宏任务内部又产生了新的微任务和宏任务,事件循环会如何处理?
javascript
console.log(1);
setTimeout(() => {
console.log(2);
new Promise((resolve) => {
console.log(3);
resolve()
}).then(() => {
console.log(4);
})
setTimeout(() => {
console.log(5);
}, 0)
}, 1000)
console.log(6);
逐步骤分析:
console.log(1)------ 同步,输出1setTimeout(..., 1000)------ 宏任务 A 加入队列console.log(6)------ 同步,输出6- 「同步代码执行完毕,微任务队列为空」------ 等待宏任务
- 1 秒后,取出宏任务 A 执行:
console.log(2)------ 输出2- 创建 Promise,同步执行 ------
console.log(3)输出3,resolve()将.then()加入微任务队列 setTimeout(..., 0)------ 宏任务 B 加入队列
- 「宏任务 A 执行完毕」------ 立即清空微任务队列
.then()执行 ------ 输出4- 「微任务队列已空」------ 取出下一个宏任务 B
- 宏任务 B 执行 ------ 输出
5
注意第 5、6、7 步:在宏任务 A 内部,console.log(3) 是同步代码、setTimeout(..., 0) 是新宏任务、.then() 是微任务。按照事件循环规则,宏任务 A 结束后,必须先清空微任务(执行 .then() 输出 4),才会去执行下一个宏任务 B(输出 5)。所以 4 出现在 5 之前,而不是 2, 3, 5, 4。
四、async/await 原理
async/await 是 Promise 的语法糖,让异步代码写起来像同步代码,但底层依然遵循事件循环规则。
核心规则
- 函数前加
async,等同于该函数返回一个 Promise 对象 await后面的表达式看作同步,然后将await之后的代码推入微任务队列
使用async/await 可以代替.then().then()的方式执行代码
javascript
function A() {
return new Promise(() => {
setTimeout((resolve) => {
console.log('a');
resolve()
}, 1000);
})
}
function B() {
console.log('b');
}
async function fn() {
await A() // 等待 A() 的 Promise resolve
B() // await 之后的代码,等价于 .then(() => B())
}
fn()
fn() 内部,await A() 会暂停 fn 的执行,等 A() 返回的 Promise 在 1 秒后 resolve,再继续执行 B()。这个效果等价于 A().then(() => B())
进阶示例:
javascript
console.log('script start');
async function async1() {
await async2()
console.log('async1 end'); // 微任务
}
async function async2() {
console.log('async2 end');
}
async1()
setTimeout(() => {
console.log('setTimeout');
}, 0)
new Promise((resolve, reject) => {
console.log('promise');
resolve()
})
.then(() => {
console.log('then1');
})
.then(() => {
console.log('then2');
});
console.log('script end');
逐步骤分析:
console.log('script start')------ 同步,输出script start- 调用
async1(),执行到await async2(),看作同步 async2()同步执行 ------console.log('async2 end')输出async2 endawait将async1中后续代码(console.log('async1 end'))推入微任务队列setTimeout(..., 0)------ 宏任务加入队列new Promise(...)同步执行 ------console.log('promise')输出promise,resolve()将第一个.then()推入微任务队列console.log('script end')------ 同步,输出script end- 「同步代码执行完毕」------ 清空微任务队列:
- 取出第一个微任务:
console.log('async1 end')输出async1 end - 取出第二个微任务:
console.log('then1')输出then1,并将第二个.then()推入微任务队列 - 取出第三个微任务:
console.log('then2')输出then2
- 取出第一个微任务:
- 「微任务队列已空」------ 取出宏任务
setTimeout回调执行 ------ 输出setTimeout
输出结果: script start, async2 end, promise, script end, async1 end, then1, then2, setTimeout
关键点:
await之后的代码已经变成了微任务,所以script end会在async1 end之前输出。- 多个微任务在同一次清空中按入队顺序依次执行(
async1 end→then1→then2)。 - 无论微任务有多少,都必须全部清空后才会执行宏任务
setTimeout。