从单线程到事件循环:彻底理解 JS 的同步、异步、微任务和宏任务

从单线程到事件循环:彻底理解 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 / else
  • for / while
  • new Promise 里的执行器函数
  • setTimeout 这一行注册动作

比如:

js 复制代码
console.log('1');

function fn() {
  console.log('2');
}

fn();

console.log('3');

输出:

text 复制代码
1
2
3

异步任务

异步任务不会立刻执行回调,而是先交给浏览器或运行环境处理。

常见异步任务:

  • setTimeout 回调
  • setInterval 回调
  • Promise.then / catch / finally
  • async / awaitawait 后面的代码
  • 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.then
  • Promise.catch
  • Promise.finally
  • await 后面的代码
  • queueMicrotask
  • MutationObserver

宏任务和微任务的执行顺序

核心规则:

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. 按顺序写输出。
相关推荐
不一样的少年_1 小时前
明明做了很多事,为什么简历看起来还是没含金量?
前端·后端·招聘
mashang1234567891 小时前
mac安装SnailGitLite并配置Beyond Compare
前端·macos
雪芽蓝域zzs1 小时前
第三十四节:用户新增编辑弹窗增加 el‑tree‑select 部门选择器
前端·javascript·vue.js
程序员老赵1 小时前
Docker 部署 go2rtc:轻松搭建摄像头多协议流媒体平台
前端·docker·直播
酷酷的逗逗乐1 小时前
前端转 Agent 开发 · 第三节:LangChain 提示词模板
前端
Cache技术分享1 小时前
516. Java 方法句柄 - 转换(进阶篇·下)
前端·后端
妙码生花1 小时前
全网 8k star 的 BuildAdmin 正式发布 Golang 版本,这次我们在CRUD赛道杀死了比赛。
前端·后端·ai编程
写代码像蔡徐抻1 小时前
三年了,AI为何还没有抢走程序员饭碗?
前端·后端·面试
梦曦i2 小时前
unix-router v0.1.0 首发:uni-app x 的 vue-router 风格路由库
前端·uni-app