返回

JavaScript 事件循环初步理解:同步、微任务、宏任务

用几个可直接在浏览器控制台运行的例子,理解 JavaScript 同步代码、微任务、宏任务与 async/await 的执行顺序,避免把 setTimeout 当成立即执行。

事件循环是前端异步代码绕不开的基础。只要页面里用了 Promiseasync/await、定时器或接口请求,就会遇到执行顺序的问题。

先给一个实用结论:当前同步代码执行完后,JavaScript 会优先清空微任务队列,再进入下一轮任务去执行定时器等宏任务。setTimeout(fn, 0) 不是立刻执行,它只是把 fn 放进后续任务队列。

同步、微任务、宏任务分别是什么

同步代码会直接进入调用栈,按从上到下的顺序运行。

微任务常见来源是 Promise.thencatchfinallyqueueMicrotask,以及 async/awaitawait 后面继续执行的部分。

宏任务常见来源是 setTimeoutsetInterval、用户交互事件和部分 I/O 回调。不同运行环境对任务来源的细节有区别,但浏览器开发中先掌握这套顺序已经足够解决大多数问题。

看一个最常见的输出顺序

把下面代码复制到浏览器控制台:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
console.log('1:同步开始')

setTimeout(() => {
  console.log('4:定时器任务')
}, 0)

Promise.resolve().then(() => {
  console.log('3:Promise 微任务')
})

console.log('2:同步结束')

输出顺序是:

1
2
3
4
1:同步开始
2:同步结束
3:Promise 微任务
4:定时器任务

原因是两条 console.log 都是同步代码,会先执行。Promise.then 进入微任务队列,setTimeout 回调进入后续任务队列。调用栈清空后,浏览器先执行微任务,最后才轮到定时器回调。

多个微任务按进入顺序执行

微任务不会互相抢占,而是按加入队列的先后顺序依次执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
console.log('A')

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

queueMicrotask(() => {
  console.log('D')
})

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

console.log('B')

输出顺序是 A -> B -> C -> D -> E。这里 Promise.thenqueueMicrotask 先注册,所以它也先执行;两个都完成后,才会执行定时器回调。

async / await 为什么看起来像异步暂停

async 函数在遇到 await 前仍然是同步执行的。await 后面的代码会被放入微任务队列。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
async function printOrder() {
  console.log('1:进入 async 函数')

  await Promise.resolve()

  console.log('3:await 后继续执行')
}

printOrder()
console.log('2:外层同步代码')

输出是:

1
2
3
1:进入 async 函数
2:外层同步代码
3:await 后继续执行

所以 await 并不会阻塞整个页面,它只暂停当前 async 函数。其他同步代码仍然会先走完。

在 Vue 中什么时候用 nextTick

Vue 修改响应式数据后,不会每改一个值就立刻更新 DOM,而是会把更新合并。需要拿到更新后的真实 DOM 时,用 nextTicksetTimeout 更准确。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<script setup lang="ts">
import { nextTick, ref } from 'vue'

const visible = ref(false)

const openDialog = async () => {
  visible.value = true

  await nextTick()

  document.querySelector<HTMLInputElement>('#search-input')?.focus()
}
</script>

nextTick 的语义是“等 Vue 完成本轮 DOM 更新后再执行”,而不是单纯延迟一会儿。它更适合处理弹窗打开后聚焦、列表更新后滚动到底部这类需求。

一个容易忽略的问题

微任务不是越多越好。每一轮宏任务结束后,浏览器会先清空当前微任务队列。若在微任务里不断创建新的微任务,渲染和用户交互就可能被推迟。

1
2
3
4
5
const runForever = () => {
  queueMicrotask(runForever)
}

runForever()

这类代码会让浏览器一直处理微任务,页面可能失去响应。实际业务里,耗时计算应该拆分处理,或者交给 Web Worker,避免堵住主线程。

小结

理解事件循环后,排查异步顺序可以按这个步骤走:先找同步代码,再找 Promiseawait 产生的微任务,最后看 setTimeout 等后续任务。大部分“为什么这个日志先出来”“为什么 DOM 还没更新”的问题,都会变得清楚很多。

使用 Hugo 构建
主题 StackJimmy 设计