Skip to content

事件循环(Event Loop) ​

JavaScript 是单线程语言,通过事件循环机制实现异步非阻塞。 理解它的关键不是背「宏任务微任务」的定义,而是记住一条规则:每执行完一个宏任务,必须清空微任务队列。

一、为什么需要事件循环 ​

JS 只有一个主线程,如果 setTimeout 或网络请求是「同步等待」,整个页面就卡死了。所以:

  • 同步代码在调用栈上跑;
  • 异步任务交给浏览器(定时器线程、网络线程)或 Node 的 libuv;
  • 完成后把回调放进队列,等主线程空了再回来取。

这个「取任务 → 执行 → 清空微任务 → 看看要不要渲染 → 再取下一个」的循环,就是事件循环。

二、JavaScript 运行时 ​

调用栈(Call Stack) ​

  • 函数调用时压入栈,执行完毕弹出
  • 栈溢出(Stack Overflow):递归过深时发生

堆(Heap) ​

  • 对象分配在堆中
  • 无序存储

任务队列(Task Queue) ​

  • 存放待执行的异步回调
  • 分为宏任务队列和微任务队列
        ┌──────────────────────────┐
        │        调用栈(同步)      │
        └───────────┬──────────────┘
                    │ 栈空
                    ▼
        ┌──────────────────────────┐
        │  微任务队列(全部清空)     │  Promise.then / queueMicrotask / MutationObserver
        └───────────┬──────────────┘
                    │ 空了
                    ▼
        ┌──────────────────────────┐
        │  渲染(style → layout →   │  只在一个宏任务之间发生,且不是每次都发生
        │  paint,rAF 在这里之前)   │
        └───────────┬──────────────┘
                    │
                    ▼
        ┌──────────────────────────┐
        │  取一个宏任务执行          │  setTimeout / I/O / 事件回调
        └──────────────────────────┘
        ┌──────────────────────────┐
        │        调用栈(同步)      │
        └───────────┬──────────────┘
                    │ 栈空
                    ▼
        ┌──────────────────────────┐
        │  微任务队列(全部清空)     │  Promise.then / queueMicrotask / MutationObserver
        └───────────┬──────────────┘
                    │ 空了
                    ▼
        ┌──────────────────────────┐
        │  渲染(style → layout →   │  只在一个宏任务之间发生,且不是每次都发生
        │  paint,rAF 在这里之前)   │
        └───────────┬──────────────┘
                    │
                    ▼
        ┌──────────────────────────┐
        │  取一个宏任务执行          │  setTimeout / I/O / 事件回调
        └──────────────────────────┘

三、宏任务(MacroTask)与微任务(MicroTask) ​

宏任务 ​

宏任务来源说明
script(整体代码)最外层同步代码,本身就是一个宏任务
setTimeout / setInterval定时器回调
setImmediate(Node.js)check 阶段执行
I/O 操作文件读写、网络回调
UI 事件回调click / scroll 等
MessageChannel消息通道(常用于替代 setTimeout 0)
requestAnimationFrame注意:它既不是宏任务也不是微任务,属于渲染前的回调(见第五节)

微任务 ​

微任务来源说明
Promise.then / catch / finallyPromise 回调
MutationObserverDOM 变化观察
queueMicrotask显式添加微任务
process.nextTick(Node.js)当前阶段结束后立即执行,优先级高于 Promise

执行顺序 ​

同步代码 → 所有微任务 → 一个宏任务 → 所有微任务 → 下一个宏任务 ...
同步代码 → 所有微任务 → 一个宏任务 → 所有微任务 → 下一个宏任务 ...
  1. 执行同步代码(整体 script 宏任务)
  2. 清空微任务队列
  3. (浏览器可能在这里渲染)
  4. 执行下一个宏任务
  5. 清空微任务队列
  6. 回到步骤 3

为什么微任务要一次清空:微任务可以「无限生出微任务」(then 里再 then),如果每执行一个就回去渲染,会造成闪烁和时序混乱。所以规范要求:清空到队列为空为止,才会进入渲染。

这也带来一个坑:微任务死循环会冻结页面(见第八节)。

经典示例 ​

js
console.log("1") // 同步

setTimeout(() => {
  console.log("2") // 宏任务
}, 0)

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

console.log("4") // 同步

// 输出: 1, 4, 3, 2
console.log("1") // 同步

setTimeout(() => {
  console.log("2") // 宏任务
}, 0)

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

console.log("4") // 同步

// 输出: 1, 4, 3, 2

嵌套示例 ​

js
console.log("start")

setTimeout(() => {
  console.log("timeout1")
  Promise.resolve().then(() => console.log("promise1"))
}, 0)

setTimeout(() => {
  console.log("timeout2")
  Promise.resolve().then(() => console.log("promise2"))
}, 0)

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

console.log("end")

// 输出:
// start
// end
// promise3
// timeout1
// promise1
// timeout2
// promise2
console.log("start")

setTimeout(() => {
  console.log("timeout1")
  Promise.resolve().then(() => console.log("promise1"))
}, 0)

setTimeout(() => {
  console.log("timeout2")
  Promise.resolve().then(() => console.log("promise2"))
}, 0)

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

console.log("end")

// 输出:
// start
// end
// promise3
// timeout1
// promise1
// timeout2
// promise2

看这个例子要抓两点:timeout1 里产生的 promise1 属于「当前宏任务产生的微任务」,所以它紧跟着 timeout1 执行完就被清空,而不会等到两个 timeout 都跑完。

四、async/await 与事件循环 ​

await 右边的代码会立即执行,await 之后的代码相当于 then 回调(微任务):

js
async function foo() {
  console.log("2")
  await bar()
  console.log("4") // 相当于 then 回调(微任务)
}

async function bar() {
  console.log("3")
}

console.log("1")
foo()
console.log("5")

// 输出: 1, 2, 3, 5, 4
async function foo() {
  console.log("2")
  await bar()
  console.log("4") // 相当于 then 回调(微任务)
}

async function bar() {
  console.log("3")
}

console.log("1")
foo()
console.log("5")

// 输出: 1, 2, 3, 5, 4

等价转换 ​

js
async function f() {
  const a = await getA()
  console.log(a)
}

// 大致等价于:
function f() {
  return getA().then((a) => {
    console.log(a)
  })
}
async function f() {
  const a = await getA()
  console.log(a)
}

// 大致等价于:
function f() {
  return getA().then((a) => {
    console.log(a)
  })
}

两条推论:

  1. await 之后的所有代码都变成了「后续微任务」,所以同一个函数里 await 之后的代码,不会在 await 那一刻同步执行;
  2. async 函数被调用时,函数体是同步开始执行的,直到遇到第一个 await 才交出控制权。

更复杂的例子 ​

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

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

console.log("script start")

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

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   (微任务1)
// promise2     (微任务2)
// setTimeout   (宏任务)
async function async1() {
  console.log("async1 start")
  await async2()
  console.log("async1 end")
}

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

console.log("script start")

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

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   (微任务1)
// promise2     (微任务2)
// setTimeout   (宏任务)

注意 async1 end 在 promise2 之前:因为 async2 的 await 先注册了微任务,Promise 的 then 后注册 —— 微任务按注册顺序执行。

五、渲染时机:rAF 与空闲回调 ​

浏览器渲染发生在微任务清空之后、下一个宏任务之前,而且不是每个宏任务后都渲染(通常跟着屏幕刷新率,约 16.7ms 一次)。

API执行时机典型用途
requestAnimationFrame(rAF)渲染前,每帧一次动画、读 DOM 尺寸再写样式
requestIdleCallback(rIC)浏览器空闲时低优先级任务(埋点、预计算)
queueMicrotask微任务队列需要在当前任务结束前插入逻辑

为什么动画要用 rAF 而不是 setInterval:rAF 与屏幕刷新同步,不会掉帧;setInterval(fn, 16) 的实际间隔不稳定,可能一帧跑两次或掉帧。

js
function animate() {
  // 读(在渲染前,避免强制重排两次)
  const width = box.offsetWidth
  box.style.width = width + 1 + 'px'
  requestAnimationFrame(animate)
}
requestAnimationFrame(animate)
function animate() {
  // 读(在渲染前,避免强制重排两次)
  const width = box.offsetWidth
  box.style.width = width + 1 + 'px'
  requestAnimationFrame(animate)
}
requestAnimationFrame(animate)

六、Node.js 事件循环 ​

Node.js 的事件循环分为六个阶段:

   ┌───────────────────────────┐
┌─>│         timers            │  setTimeout/setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  延迟到下一轮的 I/O 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          poll             │  轮询 I/O,获取新事件
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          check            │  setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  关闭事件的回调
│  └───────────────────────────┘
   ┌───────────────────────────┐
┌─>│         timers            │  setTimeout/setInterval 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  延迟到下一轮的 I/O 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          poll             │  轮询 I/O,获取新事件
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │          check            │  setImmediate 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │      close callbacks      │  关闭事件的回调
│  └───────────────────────────┘

与浏览器的三个关键差异 ​

差异点浏览器Node.js
渲染阶段有(rAF / 布局 / 绘制)没有(不负责渲染)
process.nextTick不存在有,比 Promise 微任务更早
setImmediate不存在有,在 check 阶段
每阶段结束——都会清空 nextTick 与微任务队列

process.nextTick ​

  • process.nextTick 不属于任何阶段,在当前操作结束后立即执行
  • 优先级高于 Promise 微任务
js
setTimeout(() => console.log("timeout"), 0)
setImmediate(() => console.log("immediate"))
process.nextTick(() => console.log("nextTick"))
Promise.resolve().then(() => console.log("promise"))
console.log("sync")

// 输出:
// sync
// nextTick
// promise
// timeout 或 immediate(取决于性能)
setTimeout(() => console.log("timeout"), 0)
setImmediate(() => console.log("immediate"))
process.nextTick(() => console.log("nextTick"))
Promise.resolve().then(() => console.log("promise"))
console.log("sync")

// 输出:
// sync
// nextTick
// promise
// timeout 或 immediate(取决于性能)

为什么 timeout 和 immediate 顺序不确定:在主模块里,两者都可能先进队列,取决于进程启动耗时是否超过 1ms。但在 I/O 回调内部,setImmediate 一定先于 setTimeout(因为 check 阶段紧跟 poll 阶段)。

七、常见面试题 ​

题 1:链式 then ​

js
Promise.resolve()
  .then(() => { console.log(1) })
  .then(() => { console.log(2) })

Promise.resolve()
  .then(() => { console.log(3) })
  .then(() => { console.log(4) })
// 输出: 1, 3, 2, 4
Promise.resolve()
  .then(() => { console.log(1) })
  .then(() => { console.log(2) })

Promise.resolve()
  .then(() => { console.log(3) })
  .then(() => { console.log(4) })
// 输出: 1, 3, 2, 4

为什么不是 1,2,3,4:两个 Promise 的第一层 then 先入队(1 和 3),执行时又各自注册第二层 then,排在队尾 —— 所以是 1、3、2、4。

题 2:构造器同步执行 ​

js
setTimeout(() => console.log(1), 0)
new Promise((resolve) => {
  console.log(2)
  resolve()
  console.log(3)
}).then(() => console.log(4))
console.log(5)
// 输出: 2, 3, 5, 4, 1
setTimeout(() => console.log(1), 0)
new Promise((resolve) => {
  console.log(2)
  resolve()
  console.log(3)
}).then(() => console.log(4))
console.log(5)
// 输出: 2, 3, 5, 4, 1

关键:new Promise(executor) 的 executor 是同步执行的,resolve() 只是把状态改成 fulfilled,then 回调才是微任务。

题 3:微任务里加微任务 ​

js
Promise.resolve().then(() => console.log('m1'))
setTimeout(() => console.log('t1'), 0)
Promise.resolve().then(() => {
  console.log('m2')
  Promise.resolve().then(() => console.log('m3'))
})
// 输出: m1, m2, m3, t1
Promise.resolve().then(() => console.log('m1'))
setTimeout(() => console.log('t1'), 0)
Promise.resolve().then(() => {
  console.log('m2')
  Promise.resolve().then(() => console.log('m3'))
})
// 输出: m1, m2, m3, t1

m3 在 t1 之前:微任务期间新加的微任务,仍然属于本轮要清空的队列。

八、五个常见坑 ​

1. 微任务死循环会冻结页面 ​

js
function loop() {
  Promise.resolve().then(loop)   // ❌ 微任务永远清不空,页面彻底卡死
}
loop()
function loop() {
  Promise.resolve().then(loop)   // ❌ 微任务永远清不空,页面彻底卡死
}
loop()

因为微任务必须全部清空才进入渲染,所以这种写法比 setTimeout 递归更致命(后者至少每轮能渲染)。

2. setTimeout(fn, 0) 不是 0ms ​

浏览器对嵌套超过 5 层的定时器有 4ms 最小间隔限制;后台标签页里定时器还会被节流(可能 1 秒才跑一次)。

3. await 之后不是「立刻」 ​

js
async function run() {
  console.log('a')
  await null                  // 即使 await 一个非 Promise,也会让出一次
  console.log('b')            // 微任务
}
run()
console.log('c')              // 输出 a, c, b
async function run() {
  console.log('a')
  await null                  // 即使 await 一个非 Promise,也会让出一次
  console.log('b')            // 微任务
}
run()
console.log('c')              // 输出 a, c, b

4. 在微任务里改 DOM 不会立刻渲染 ​

js
el.textContent = 'x'
Promise.resolve().then(() => {
  el.textContent = 'y'        // 仍在同一轮,最终只渲染一次 'y'
})
el.textContent = 'x'
Promise.resolve().then(() => {
  el.textContent = 'y'        // 仍在同一轮,最终只渲染一次 'y'
})

5. 长任务阻塞输入 ​

一个 200ms 的同步任务期间,点击事件排队不处理 —— 这就是 INP 变差的主因(见 性能优化-概述与指标)。

九、答题口径(面试用) ​

「JS 是单线程的,靠事件循环做异步。执行顺序是:先跑同步代码(整体 script 是一个宏任务),然后清空所有微任务,之后浏览器可能渲染一次(rAF 在渲染前跑),再取下一个宏任务,循环往复。 微任务包括 Promise 的 then、queueMicrotask、MutationObserver;宏任务包括 setTimeout、I/O、UI 事件。 async/await 本质是 Promise 的语法糖,await 之后的代码等价于 then 回调,属于微任务;process.nextTick 是 Node 特有的,比 Promise 微任务更早。」

十、速查 ​

问题答案
事件循环的核心规则一个宏任务 → 清空所有微任务 → 可能渲染
微任务有哪些Promise.then、queueMicrotask、MutationObserver、nextTick(Node)
宏任务有哪些script、setTimeout、I/O、UI 事件、MessageChannel
rAF 属于哪类都不属于,它是渲染前的回调
await 之后是宏任务吗不是,是微任务
微任务死循环会怎样页面冻结(永远轮不到渲染)
setTimeout 0 真是 0 吗不是,嵌套 5 层后有 4ms 下限
Node 和浏览器最大差异没有渲染阶段;多了 nextTick 与 setImmediate