事件循环(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 / finally | Promise 回调 |
MutationObserver | DOM 变化观察 |
queueMicrotask | 显式添加微任务 |
process.nextTick(Node.js) | 当前阶段结束后立即执行,优先级高于 Promise |
执行顺序
同步代码 → 所有微任务 → 一个宏任务 → 所有微任务 → 下一个宏任务 ...同步代码 → 所有微任务 → 一个宏任务 → 所有微任务 → 下一个宏任务 ...- 执行同步代码(整体 script 宏任务)
- 清空微任务队列
- (浏览器可能在这里渲染)
- 执行下一个宏任务
- 清空微任务队列
- 回到步骤 3
为什么微任务要一次清空:微任务可以「无限生出微任务」(then 里再 then),如果每执行一个就回去渲染,会造成闪烁和时序混乱。所以规范要求:清空到队列为空为止,才会进入渲染。
这也带来一个坑:微任务死循环会冻结页面(见第八节)。
经典示例
console.log("1") // 同步
setTimeout(() => {
console.log("2") // 宏任务
}, 0)
Promise.resolve().then(() => {
console.log("3") // 微任务
})
console.log("4") // 同步
// 输出: 1, 4, 3, 2console.log("1") // 同步
setTimeout(() => {
console.log("2") // 宏任务
}, 0)
Promise.resolve().then(() => {
console.log("3") // 微任务
})
console.log("4") // 同步
// 输出: 1, 4, 3, 2嵌套示例
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
// promise2console.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 回调(微任务):
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, 4async 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 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)
})
}两条推论:
await之后的所有代码都变成了「后续微任务」,所以同一个函数里await之后的代码,不会在await那一刻同步执行;async函数被调用时,函数体是同步开始执行的,直到遇到第一个await才交出控制权。
更复杂的例子
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) 的实际间隔不稳定,可能一帧跑两次或掉帧。
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 微任务
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
Promise.resolve()
.then(() => { console.log(1) })
.then(() => { console.log(2) })
Promise.resolve()
.then(() => { console.log(3) })
.then(() => { console.log(4) })
// 输出: 1, 3, 2, 4Promise.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:构造器同步执行
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, 1setTimeout(() => 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:微任务里加微任务
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, t1Promise.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, t1m3 在 t1 之前:微任务期间新加的微任务,仍然属于本轮要清空的队列。
八、五个常见坑
1. 微任务死循环会冻结页面
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 之后不是「立刻」
async function run() {
console.log('a')
await null // 即使 await 一个非 Promise,也会让出一次
console.log('b') // 微任务
}
run()
console.log('c') // 输出 a, c, basync function run() {
console.log('a')
await null // 即使 await 一个非 Promise,也会让出一次
console.log('b') // 微任务
}
run()
console.log('c') // 输出 a, c, b4. 在微任务里改 DOM 不会立刻渲染
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 |
Cao Note