从单线程到事件循环:彻底理解 JS 的同步、异步、微任务和宏任务
1. JS 是一门单线程语言
JS 的核心执行环境是单线程的。
也就是说,同一时间 JS 主线程只能做一件事:
js
console.log('start');
for (let i = 0; i < 1000000000; i++) {}
console.log('end');
上面代码里,只有等 for 循环执行完,才会继续执行后面的 console.log('end')。
这就是单线程的特点:
text
一行一行执行,前面的代码没执行完,后面的代码不能开始。
2. 为什么 JS 要设计成单线程
JS 最初主要运行在浏览器中,用来操作页面。
浏览器里的 JS 经常要操作 DOM:
js
document.body.innerHTML = 'hello';
如果 JS 是多线程的,就可能出现这种情况:
text
线程 A 正在修改一个 DOM 节点
线程 B 同时删除了这个 DOM 节点
这样浏览器就很难判断到底听谁的。
所以 JS 主线程采用单线程模型,最大的好处是:
text
避免多个线程同时操作 DOM 带来的复杂冲突。
简单说:
text
JS 单线程,是为了让代码执行和 DOM 操作更加可控。
3. 单线程会带来什么问题
单线程最大的问题是:容易阻塞。
比如:
js
console.log('start');
// 假设这里发起一个网络请求,并且必须等结果回来
// 如果整个过程都由 JS 主线程等待,页面就会卡住
console.log('end');
网络请求、定时器、用户点击、文件读取,这些事情都有一个共同特点:
text
它们不一定马上有结果。
如果 JS 主线程一直等它们完成,就会导致页面无法响应。
所以 JS 需要异步。
4. 为什么会有同步和异步
同步和异步,本质上是为了解决"任务是否需要等待"的问题。
同步任务
同步任务会直接进入 JS 主线程执行。
常见同步任务:
console.log- 变量声明和赋值
- 普通函数调用
if / elsefor / whilenew Promise里的执行器函数setTimeout这一行注册动作
比如:
js
console.log('1');
function fn() {
console.log('2');
}
fn();
console.log('3');
输出:
text
1
2
3
异步任务
异步任务不会立刻执行回调,而是先交给浏览器或运行环境处理。
常见异步任务:
setTimeout回调setInterval回调Promise.then / catch / finallyasync / await中await后面的代码- DOM 事件回调,比如
click - 网络请求完成后的回调
比如:
js
console.log('start');
setTimeout(() => {
console.log('timer');
}, 0);
console.log('end');
输出:
text
start
end
timer
注意:
text
setTimeout 这一行是同步执行的。
真正异步执行的是 setTimeout 里面的回调函数。
5. JS 单线程,异步是谁帮它做的
这是理解异步的关键。
JS 主线程本身是单线程的,但是浏览器不是单线程的。
浏览器提供了很多能力:
- 定时器模块
- 网络请求模块
- DOM 事件监听模块
- 渲染模块
- 任务队列
所以异步的本质不是 JS 自己开了很多线程去执行代码,而是:
text
JS 主线程负责执行代码。
浏览器其他模块负责处理耗时任务。
任务完成后,把回调放进任务队列。
最后事件循环再把回调交给 JS 主线程执行。
可以把过程理解成:
text
JS 主线程:我遇到了 setTimeout,帮我计时一下。
浏览器定时器模块:好,我来计时。
JS 主线程:我继续往下执行同步代码。
浏览器定时器模块:时间到了,我把回调放进任务队列。
事件循环:等主线程空了,我把回调拿出来执行。
所以:
text
异步不是 JS 同时执行多段代码。
异步是浏览器协作 + 事件循环调度。
6. 调用栈、任务队列、事件循环
要理解执行顺序,需要知道三个概念。
调用栈
调用栈用来执行同步代码。
函数调用时入栈,函数执行完出栈。
js
function a() {
b();
}
function b() {
console.log('b');
}
a();
大致过程:
text
a 入栈
b 入栈
console.log 执行
b 出栈
a 出栈
任务队列
异步回调不会直接进入调用栈。
它们会先进入任务队列,等待主线程空闲后再执行。
事件循环
事件循环负责不断检查:
text
调用栈是否为空?
如果为空,就从任务队列里取任务执行。
这就是 Event Loop。
7. 宏任务和微任务
异步任务还可以继续分成两类:
text
宏任务
微任务
宏任务
常见宏任务:
setTimeout回调setInterval回调- DOM 事件回调
- 网络请求回调
- 整体 script 脚本
微任务
常见微任务:
Promise.thenPromise.catchPromise.finallyawait后面的代码queueMicrotaskMutationObserver
宏任务和微任务的执行顺序
核心规则:
text
先执行同步任务。
同步任务执行完后,清空所有微任务。
微任务清空后,执行一个宏任务。
这个宏任务执行完后,再清空它产生的微任务。
然后再执行下一个宏任务。
面试里先记这个简化版:
text
同步任务 -> 微任务 -> 宏任务
8. 经典执行顺序题
来看一题:
js
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
分析:
js
console.log('1');
同步任务,先输出:
text
1
然后:
js
setTimeout(() => {
console.log('2');
}, 0);
setTimeout 注册动作同步执行,但它的回调是宏任务,先放一边。
然后:
js
Promise.resolve().then(() => {
console.log('3');
});
.then 里的回调是微任务。
最后:
js
console.log('4');
同步任务,输出:
text
4
同步任务执行完后,先执行微任务:
text
3
微任务清空后,再执行宏任务:
text
2
最终输出:
text
1
4
3
2
9. Promise 里哪些是同步,哪些是异步
这是一个很容易混的点。
js
console.log('start');
new Promise((resolve) => {
console.log('promise');
resolve();
}).then(() => {
console.log('then');
});
console.log('end');
输出:
text
start
promise
end
then
原因:
text
new Promise 里的执行器函数是同步执行的。
then 里的回调是微任务。
所以不要简单地说 Promise 是异步。
更准确的说法是:
text
Promise 构造函数里的代码同步执行。
Promise.then 里的回调异步执行,属于微任务。
10. async / await 的执行顺序
async / await 本质上也是 Promise 的语法糖。
js
async function fn() {
console.log('1');
await 100;
console.log('2');
}
fn();
console.log('3');
输出:
text
1
3
2
原因:
text
await 前面的代码同步执行。
await 后面的代码会被放到微任务中执行。
所以可以先这样记:
text
async 函数里,await 前面是同步,await 后面是微任务。
11. setTimeout(fn, 0) 为什么不是立刻执行
很多人看到 0,会以为它马上执行。
其实:
js
setTimeout(fn, 0);
意思不是"立刻执行",而是:
text
最快 0ms 后,把 fn 放进宏任务队列。
但是它什么时候真正执行,还要看:
text
主线程同步代码有没有执行完?
微任务队列有没有清空?
前面有没有其他宏任务?
所以它仍然要排队。
12. 一道综合题
js
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
new Promise((resolve) => {
console.log('3');
resolve();
}).then(() => {
console.log('4');
});
async function fn() {
console.log('5');
await 100;
console.log('6');
}
fn();
console.log('7');
分析:
同步任务:
text
1
3
5
7
微任务:
text
4
6
宏任务:
text
2
最终输出:
text
1
3
5
7
4
6
2
13. 面试怎么答
如果面试官问:
text
说一下 JS 的同步异步和事件循环。
可以这样答:
text
JS 是单线程语言,同一时间主线程只能执行一个任务。为了避免定时器、网络请求、事件监听这类耗时操作阻塞主线程,浏览器提供了异步能力。JS 遇到异步任务时,会把对应任务交给浏览器其他模块处理,等任务完成后,回调会进入任务队列。
事件循环会不断检查调用栈是否为空。当同步代码执行完后,会先清空微任务队列,比如 Promise.then、catch、finally、await 后面的代码;微任务清空后,再执行宏任务,比如 setTimeout、setInterval、DOM 事件回调。整体顺序可以先记为:同步任务 -> 微任务 -> 宏任务。
setTimeout 本身是同步注册定时器,真正异步执行的是它的回调;new Promise 里的执行器是同步执行,then 回调是微任务。
14. 记忆口诀
text
JS 主线程,只能干一件事。
耗时任务交给浏览器。
回调先进队列,事件循环来调度。
先跑同步,再清微任务,最后跑宏任务。
15. 最后总结
JS 同步异步的本质不是"JS 自己多线程执行代码",而是:
text
JS 单线程执行代码。
浏览器提供异步能力。
事件循环负责调度回调。
微任务优先级高于宏任务。
所以遇到执行顺序题,可以按这四步分析:
text
1. 先找同步代码。
2. 再找微任务。
3. 再找宏任务。
4. 按顺序写输出。