2026-07-08 · 一段与 batype 的对话(AI) · 由提问者公开

浏览器的宏任务和微任务,是什么?

回答由 AI 基于 batype 的笔记生成,不代表本人实时观点。

结论:他没有写过这个话题。

以下是我的推测,不代表 batype 本人观点:

我尽量讲得系统一点,但还是先抓主线:浏览器里的 JavaScript 不是“同时处理很多事”,而是靠 Event Loop 在“任务队列”之间切换。宏任务决定一轮一轮地做什么,微任务决定这一轮结束前必须清空什么。


1. 为什么会有宏任务和微任务

JavaScript 在浏览器主线程里通常是单线程执行的。

这意味着:

  • 同一时刻只能做一件事
  • 但浏览器又要同时处理:
    • 执行 JS
    • 响应点击、输入、滚动
    • 发网络请求
    • 做页面渲染
    • 执行定时器回调

所以浏览器不会把所有事情都塞进一个同步调用栈里,而是拆成一批一批任务,让 Event Loop 去调度。

为了更细地控制优先级,任务被分成了两类:

  • 宏任务(macrotask / task)
  • 微任务(microtask)

可以先用一句话理解:

  • 宏任务:一次“大的工作单元”
  • 微任务:当前这次大工作结束后,立刻补做完的小工作

2. 宏任务是什么

常见宏任务包括:

  • 整体脚本执行(<script>
  • setTimeout
  • setInterval
  • DOM 事件回调(click、input 等)
  • 网络事件
  • postMessage
  • MessageChannel
  • 一些 I/O 事件

你可以把宏任务理解成:Event Loop 每次从外面拿一个大任务进来执行。

比如:

console.log('A')
console.log('B')

这整个脚本本身,就是一个宏任务。

再比如:

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

这个回调不会立刻执行,它会在未来某个时刻作为一个新的宏任务进入队列。


3. 微任务是什么

常见微任务包括:

  • Promise.then / catch / finally
  • queueMicrotask
  • MutationObserver
  • async/awaitawait 之后续上的代码,本质上也进入微任务队列

微任务的特点是:

  • 不会立刻打断当前同步代码
  • 但会在当前宏任务结束后立即执行
  • 而且会一次性清空整个微任务队列

也就是说,微任务优先级高于“下一个宏任务”。


4. Event Loop 的核心流程

浏览器里的简化流程可以理解成这样:

  1. 取出一个宏任务执行
  2. 执行过程中,可能产生新的微任务
  3. 当前宏任务执行结束
  4. 清空所有微任务
  5. 浏览器有机会做一次渲染
  6. 再取下一个宏任务

我故意说“有机会渲染”,因为渲染不是每步都必然发生,但你可以先这么理解。

伪代码可以写成:

while (true) {
  执行一个宏任务

  while (微任务队列不为空) {
    执行一个微任务
  }

  可能进行渲染
}

这段伪代码是理解一切输出顺序的关键。


5. 先看最经典的例子

console.log('start')

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

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

console.log('end')

结果通常是:

start
end
promise
timeout

执行过程拆解

第一步:整个 script 作为一个宏任务开始执行

执行同步代码:

console.log('start') // 输出 start

遇到:

setTimeout(...)

浏览器把它注册出去,等时间到了以后,把回调放进宏任务队列。现在不会执行。

遇到:

Promise.resolve().then(...)

then 回调会进入微任务队列

继续:

console.log('end') // 输出 end

第二步:当前宏任务执行结束

这时开始清空微任务队列。

执行:

console.log('promise') // 输出 promise

第三步:进入下一轮 Event Loop

宏任务队列里已经有前面的 setTimeout 回调。

执行它:

console.log('timeout') // 输出 timeout

所以整体顺序是:

start
end
promise
timeout

6. 一个更容易踩坑的例子

setTimeout(() => {
  console.log('t1')

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

  console.log('t2')
}, 0)

输出:

t1
t2
p1

为什么不是 t1 p1 t2

因为微任务不会“插队打断”当前同步代码。

这里 setTimeout 回调本身是一个宏任务。进入这个宏任务后:

  1. 输出 t1
  2. 注册一个微任务 p1
  3. 输出 t2
  4. 当前宏任务结束
  5. 清空微任务,输出 p1

关键点:

  • 微任务优先于下一个宏任务
  • 但不会打断当前正在执行的同步代码

7. 微任务会被“清空”,这点非常重要

看这个例子:

Promise.resolve().then(() => {
  console.log('p1')

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

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

输出:

p1
p3
p2

为什么?

开始时微任务队列里有:

  • p1
  • p3

执行 p1 时,又塞进去一个新的微任务 p2

队列就变成:

  • p3
  • p2

然后继续清空,所以顺序是:

  • p1
  • p3
  • p2

这说明一件事:微任务队列在当前轮次会一直执行,直到彻底为空。


8. 为什么说微任务可能阻塞渲染

因为浏览器通常会在:

  • 一个宏任务结束后
  • 且微任务全部清空后

才有机会渲染。

如果你一直往微任务里塞微任务,就可能让浏览器长期没机会渲染页面。

比如:

function loop() {
  queueMicrotask(() => {
    console.log('microtask')
    loop()
  })
}

loop()

这会不断制造新的微任务。理论上浏览器会一直忙着清空微任务,页面渲染、事件响应都可能被饿死。

所以:

  • 微任务优先级高
  • 但不能滥用

这也是为什么一些“让 UI 先更新,再做重活”的场景,不应该用微任务,而更适合用 setTimeoutrequestAnimationFrame 或分片调度。


9. setTimeout(fn, 0) 为什么不是“立即执行”

很多人第一次学异步会误解这里。

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

console.log('sync')

输出一定是:

sync
timeout

因为 setTimeout(..., 0) 的意思不是“马上执行”,而是:

  • 至少等待 0ms
  • 然后把回调放入宏任务队列
  • 等当前宏任务结束、微任务清空、轮到它时才执行

它只是“尽快安排到下一轮宏任务”,不是同步插入。

另外真实浏览器里还有一些最小延迟、嵌套定时器限制,所以它连“精确 0ms”都不是。


10. Promise.then 为什么比 setTimeout 更早

setTimeout(() => console.log('timeout'), 0)
Promise.resolve().then(() => console.log('promise'))

因为:

  • setTimeout 回调进入宏任务队列
  • Promise.then 回调进入微任务队列

而规则是:

  • 当前宏任务结束后,先清空微任务
  • 再执行下一个宏任务

所以 promise 总是先于 timeout


11. async/await 和微任务的关系

async/await 本质上是 Promise 的语法糖。

看例子:

async function test() {
  console.log('a')
  await Promise.resolve()
  console.log('b')
}

console.log('start')
test()
console.log('end')

输出:

start
a
end
b

为什么?

执行过程:

  1. console.log('start')
  2. 调用 test()
  3. 进入 test,输出 a
  4. 遇到 await Promise.resolve()
    • await 后面的部分会被挂起
    • 后续的 console.log('b') 会作为微任务继续执行
  5. 回到主流程,输出 end
  6. 当前宏任务结束,清空微任务,输出 b

所以可以记住:

  • await 不是阻塞整个线程
  • 它只是把函数后续逻辑拆到未来的微任务里继续执行

12. DOM 渲染和任务队列的关系

这是前端里特别实用的一点。

假设你写:

button.innerText = 'loading...'
heavyWork()

如果 heavyWork() 很重,页面上可能不会立刻看到 loading...,因为:

  • DOM 改了
  • 但浏览器还没机会渲染
  • 主线程已经继续被 heavyWork() 占住了

有时你会想“先让页面渲染,再做重活”,这时如果你写成微任务,往往不够:

button.innerText = 'loading...'

Promise.resolve().then(() => {
  heavyWork()
})

这通常还是不行,因为微任务会在渲染前执行,浏览器仍然没机会把 loading... 画出来。

更合理的方式一般是:

button.innerText = 'loading...'

setTimeout(() => {
  heavyWork()
}, 0)

或者:

button.innerText = 'loading...'

requestAnimationFrame(() => {
  heavyWork()
})

这背后的关键就是:

  • 微任务通常发生在渲染之前
  • 宏任务切到下一轮时,浏览器才更有机会先渲染一帧

这个区别在写 loading、动画、长任务分片时很有用。


13. 一个综合例子

console.log(1)

setTimeout(() => {
  console.log(2)

  Promise.resolve().then(() => {
    console.log(3)
  })
}, 0)

Promise.resolve().then(() => {
  console.log(4)
})

console.log(5)

输出:

1
5
4
2
3

拆解

初始 script 宏任务中:

  1. 输出 1
  2. setTimeout 注册一个未来宏任务
  3. Promise.then 注册一个微任务 4
  4. 输出 5

当前宏任务结束,清空微任务:

  1. 输出 4

进入下一轮宏任务,执行 setTimeout

  1. 输出 2
  2. 注册微任务 3

当前这个 setTimeout 宏任务结束,清空微任务:

  1. 输出 3

最终:

1
5
4
2
3

14. 再来一个更绕但很典型的面试题

console.log('script start')

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

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}

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

async1()

new Promise((resolve) => {
  console.log('promise1')
  resolve()
}).then(() => {
  console.log('promise2')
})

console.log('script end')

输出通常是:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

解释重点

  • script 是当前宏任务
  • async1() 先同步执行到 await
  • async2() 里同步输出 async2
  • await 后续的 async1 end 进入微任务
  • Promise.thenpromise2 也进入微任务
  • 微任务按进入顺序执行
  • 最后才轮到 setTimeout

15. 宏任务和微任务的“优先级”到底怎么理解

很多人会说“微任务优先级更高”,这句话不算错,但容易让人误会。

更准确的说法是:

  • 当前宏任务内的同步代码永远先执行
  • 当前宏任务结束后,必须先清空微任务
  • 然后才会执行下一个宏任务

所以微任务不是“随时更高优先级”,而是“在当前轮结束阶段拥有更高调度权”。


16. 浏览器和 Node.js 的差异

你问的是浏览器,我只点一下,不展开太多。

在 Node.js 里也有事件循环,但实现细节和浏览器不完全一样,尤其是:

  • 不同 phase
  • process.nextTick
  • setImmediate

这些和浏览器里的宏任务 / 微任务模型有相似之处,但不能完全混着背。

所以如果你是在浏览器里写前端代码,先牢牢记住浏览器这套就够了。


17. 实际开发里最常见的几个结论

1)想“本轮同步代码执行完就立刻做”

用微任务:

Promise.resolve().then(fn)
// 或
queueMicrotask(fn)

适合:

  • 补齐状态
  • 延后一点点执行,但不想拖到下一轮

2)想“下一轮再做”

用宏任务:

setTimeout(fn, 0)

适合:

  • 避免阻塞当前轮
  • 给渲染留机会
  • 拆分大任务

3)想“浏览器下一帧渲染前做”

用:

requestAnimationFrame(fn)

适合:

  • 动画
  • 读写布局
  • 跟渲染节奏对齐

4)不要无限制造微任务

否则可能:

  • 卡 UI
  • 阻塞渲染
  • 阻塞事件响应

18. 一张简单的脑图式记忆法

你可以这么背:

  • script / setTimeout / click 回调 → 宏任务
  • Promise.then / await 后续 / queueMicrotask → 微任务

执行顺序:

  • 先跑当前宏任务里的同步代码
  • 再清空所有微任务
  • 再去下一个宏任务
  • 渲染通常夹在两轮之间

19. 最后给你一个判断模板

以后看到题目,按这个顺序拆:

第一步:找同步代码

先把所有同步 console.log 按出现顺序写出来。

第二步:找当前宏任务里注册的微任务

比如:

  • Promise.then
  • await 后续

这些放到“本轮末尾执行”。

第三步:找宏任务

比如:

  • setTimeout
  • DOM 事件回调

这些放到“下一轮”。

第四步:注意嵌套

在某个宏任务里新注册的微任务,会在那个宏任务结束后立刻执行


20. 一句话总结

宏任务决定“下一轮做什么”,微任务决定“这一轮结束前必须清空什么”。

如果你愿意,我下一条可以继续给你:

  1. 画一张 Event Loop 时序图
  2. 出 5 道题让你自己判断输出
  3. 结合 React 讲它和状态更新、渲染批处理的关系

你选一个。

推测
画一张 Event Loop 时序图

结论:他没有写过这个话题。

以下是我的推测,不代表 batype 本人观点:

我直接画一张够用的图。先看最核心版本


1. Event Loop 基本时序图

┌──────────────────────────────────────────────┐
│                宏任务队列 Task Queue         │
│  [ script ] [ setTimeout ] [ click ] ...    │
└──────────────────────────────────────────────┘
                      │
                      ▼
             ┌─────────────────┐
             │   Call Stack    │
             │   执行当前任务   │
             └─────────────────┘
                      │
                      ▼
       当前宏任务执行完毕? ──────── 否 ────────→ 继续执行同步代码
                      │
                     是
                      │
                      ▼
┌──────────────────────────────────────────────┐
│             微任务队列 Microtask Queue       │
│ [ Promise.then ] [ queueMicrotask ] ...      │
└──────────────────────────────────────────────┘
                      │
                      ▼
             清空所有微任务(一直清到空)
                      │
                      ▼
             ┌─────────────────┐
             │   浏览器渲染?    │
             │ layout / paint   │
             └─────────────────┘
                      │
                      ▼
         回到宏任务队列,取下一个宏任务继续

这张图表达的就是一句话:

执行一个宏任务 → 清空所有微任务 → 浏览器有机会渲染 → 再执行下一个宏任务


2. 对应一段代码来看

console.log('start')

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

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

console.log('end')

3. 这段代码的时序图

时间向下
│
├─ 宏任务 #1:script 开始
│
│   console.log('start')
│   → 输出 start
│
│   setTimeout(...)
│   → 把 timeout 回调放到“未来的宏任务队列”
│
│   Promise.resolve().then(...)
│   → 把 promise 回调放入“微任务队列”
│
│   console.log('end')
│   → 输出 end
│
├─ 宏任务 #1:script 结束
│
├─ 开始清空微任务队列
│   └─ 执行 promise 回调
│      → 输出 promise
│
├─ 微任务清空完毕
│
├─ 浏览器有机会渲染
│
├─ 宏任务 #2:执行 setTimeout 回调
│   └─ 输出 timeout
│
└─ 结束

输出:

start
end
promise
timeout

4. 更像“泳道图”的版本

这个形式通常更直观。

时间 ↓

主线程(Call Stack)        宏任务队列              微任务队列
────────────────────────────────────────────────────────────
取出 script 执行          [script]                []
│
├─ console.log('start')
│
├─ setTimeout(cb, 0)      [timeout cb]            []
│
├─ Promise.then(cb)       [timeout cb]            [promise cb]
│
├─ console.log('end')
│
└─ script 执行结束         [timeout cb]            [promise cb]

开始清空微任务队列
执行 promise cb                                     []
│
└─ 输出 promise

微任务清空完成
取出下一个宏任务          []                       []
执行 timeout cb
│
└─ 输出 timeout

注意这里我把 script 也当成一个宏任务看,这是理解 Event Loop 最稳定的方式。


5. 再画一个“宏任务里再产生微任务”的图

代码:

setTimeout(() => {
  console.log('t1')

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

  console.log('t2')
}, 0)

时序图:

时间 ↓

宏任务队列                 Call Stack              微任务队列
────────────────────────────────────────────────────────────
[ timeout cb ]             空                       []

取出 timeout cb 执行
                           执行 timeout cb         []
                           │
                           ├─ console.log('t1')
                           │
                           ├─ Promise.then(...)
                           │                        [p1]
                           │
                           ├─ console.log('t2')
                           │
                           └─ timeout cb 结束       [p1]

开始清空微任务队列
                           执行 p1                  []
                           └─ console.log('p1')

输出:

t1
t2
p1

这说明:

微任务不会打断当前同步代码,只会在当前宏任务结束后立刻执行。


6. 再画一个“微任务套微任务”的图

代码:

Promise.resolve().then(() => {
  console.log('p1')

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

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

时序图:

时间 ↓

当前宏任务执行结束后,微任务队列初始为:
[p1, p3]

1. 执行 p1
   输出 p1
   p1 内部再注册一个微任务 p2

此时队列变成:
[p3, p2]

2. 执行 p3
   输出 p3

队列变成:
[p2]

3. 执行 p2
   输出 p2

队列清空

输出:

p1
p3
p2

关键点:

清空微任务队列时,如果微任务内部又产生新的微任务,这些新微任务也会在本轮继续执行。


7. 把“渲染”也放进图里

浏览器不是执行完每一行 JS 就渲染一次。更接近这样:

一轮 Event Loop:

[1] 执行一个宏任务
        │
        ▼
[2] 清空所有微任务
        │
        ▼
[3] 如果需要,执行渲染
        │
        ▼
[4] 进入下一轮

所以如果你写:

element.textContent = 'A'

Promise.resolve().then(() => {
  element.textContent = 'B'
})

setTimeout(() => {
  element.textContent = 'C'
}, 0)

大概率视觉上你会更接近看到:

  • 当前宏任务里改成 A
  • 微任务里又马上改成 B
  • 浏览器渲染时可能直接显示 B
  • 下一轮宏任务再改成 C

也就是说:

  • A 不一定有机会单独出现在屏幕上
  • 因为在渲染前,微任务已经把它改成 B

8. 一张“为什么微任务可能卡 UI”的图

宏任务结束
   │
   ▼
清空微任务:
   microtask 1
      └─ 又塞一个 microtask
   microtask 2
      └─ 又塞一个 microtask
   microtask 3
      └─ 又塞一个 microtask
   ...
   ...
   ...
   (始终清不完)
   │
   └─ 浏览器一直没机会渲染

这就是为什么滥用 Promise.then / queueMicrotask 可能让页面卡住。


9. 最后给你一张“记忆版极简图”

┌──────────────┐
│ 1. 执行宏任务 │
└──────┬───────┘
       ▼
┌──────────────┐
│ 2. 清空微任务 │
└──────┬───────┘
       ▼
┌──────────────┐
│ 3. 可能渲染   │
└──────┬───────┘
       ▼
┌──────────────┐
│ 4. 下一轮     │
└──────────────┘

你只要记住两句话基本就够用了:

  1. Promise.thensetTimeout(..., 0) 更早执行
  2. 每个宏任务结束后,会把微任务一次性清空

如果你愿意,我下一条可以继续给你画一张更具体的:

  • async/await 的 Event Loop 时序图
  • 或者 浏览器 Event Loop vs Node.js Event Loop 对比图
推测