引言
JavaScript作为一门单线程语言,通过事件循环(Event Loop)机制实现了异步编程。理解事件循环对于编写高效、可预测的JavaScript代码至关重要。本文将深入探讨事件循环机制,特别是宏任务和微任务的执行顺序与应用场景。
事件循环基础概念
JavaScript引擎执行代码时,会将不同任务分为同步任务和异步任务。同步任务直接在主线程执行,而异步任务则进入任务队列,等待主线程空闲时执行。这种机制就是事件循环。
在事件循环中,任务又分为两类:
- 宏任务(Macro Task):如
setTimeout、setInterval、I/O操作等 - 微任务(Micro Task):如
Promise.then、queueMicrotask、MutationObserver等
浏览器环境下的事件循环
基本执行顺序
来看一个简单的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宏任务,微任务,任务队列</title>
</head>
<body>
<script>
// 一个script 就是宏任务开始
// static 静态方法
console.log('script start');
// 异步任务,宏任务
setTimeout(() => {
console.log('setTimeout');
}, 0)
// then 异步 微任务 任务队列
Promise.resolve().then(() => {
console.log('promise');
})
console.log('script end');
</script>
</body>
</html>
执行结果:
这个例子清晰地展示了事件循环的核心规则:
- 首先执行同步代码(
script start和script end) - 然后执行当前事件循环中的所有微任务(
promise) - 最后执行宏任务(
setTimeout)
微任务之MutationObserver
MutationObserver是浏览器提供的一个API,用于监听DOM变化,它也是一种微任务:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微任务</title>
</head>
<body>
<script>
// event loop 是 JS 执行机制,也是代码执行的开始
// html 是第一个BFC 块级格式化上下文
const target = document.createElement('div')
document.body.appendChild(target)
const observer = new MutationObserver(() => {
console.log('微任务:MutationObserver');
})
// 监听target 节点的变化
observer.observe(target, {
attributes: true,
childList: true
})
target.setAttribute('data-set', '123')
target.appendChild(document.createElement('span'))
target.setAttribute('style', 'background-color:green')
</script>
</body>
</html>
上面的代码创建了一个MutationObserver实例并监听DOM节点的变化。当我们对DOM节点进行修改时,回调函数会在当前事件循环的微任务阶段执行。虽然我们进行了三次DOM操作,但MutationObserver会将这些变化批量处理,在微任务阶段只触发一次回调。
queueMicrotask API
HTML规范提供了queueMicrotask API,它是一种更直接地创建微任务的方法:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微任务</title>
</head>
<body>
<script>
console.log('同步');
// 批量更新
// dom 树, cssom, layout 树 图层合并
queueMicrotask(() => {
// 让批量更新去到渲染队伍之前
// 会在渲染之前执行,保证DOM更新,但不是渲染完了
// 一个元素的高度 offsetHeight scrollTop getBoundingClientRect()
// 立即重绘重排 耗性能 用来观察DOM的新的API
console.log('微任务: queueMicrotask');
})
console.log("同步结束");
</script>
</body>
</html>
执行结果:
同步
同步结束
微任务: queueMicrotask
queueMicrotask对于DOM操作特别有用,它可以在DOM更新后但在浏览器重新渲染前执行代码,有助于避免不必要的重绘重排。
Node.js环境下的事件循环
Node.js的事件循环与浏览器有所不同,它增加了一些特有的微任务类型:
// Node.js 环境特有的微任务
console.log('Start');
// 微任务
Promise.resolve().then(()=>{
console.log('Promise Resolved');
})
// node 微任务
// process 进程对象
process.nextTick(()=>{
console.log('Process Next Tick');
})
// 宏任务
setTimeout(()=>{
console.log('haha');
Promise.resolve().then(()=>{
console.log('inner Promise');
})
},0)
console.log('end');
执行结果:
Start
end
Process Next Tick
Promise Resolved
haha
inner Promise
在Node.js环境中,process.nextTick的优先级高于Promise,会先于其他微任务执行。这是Node.js特有的一个API,用于将回调函数放到当前执行栈的尾部,在下一个事件循环之前执行。
复杂场景分析
让我们分析一个更复杂的例子,包含多个Promise和setTimeout:
console.log('同步Start');
const promise1 = Promise.resolve('First Promise')
const promise2 = Promise.resolve('Second Promise')
const promise3 = new Promise(resolve =>{
resolve('Third Promise')
console.log('promise3');
})
setTimeout(()=>{
console.log('下一把再相见');
const Promise4 = Promise.resolve('Fourth Promise')
Promise4.then(value => console.log(value))
},0)
setTimeout(()=>{
console.log('下一把再相见');
},0)
promise1.then(value => console.log(value))
promise2.then(value => console.log(value))
promise3.then(value => console.log(value))
console.log('同步end');
执行结果:
同步Start
promise3
同步end
First Promise
Second Promise
Third Promise
下一把再相见
Fourth Promise
下一把再相见
这个例子揭示了以下几点:
- Promise构造函数内的代码是同步执行的(
promise3立即输出) - 所有同步代码执行完后,才会执行微任务队列
- 微任务队列执行完后,才会执行宏任务队列
- 在执行一个宏任务过程中产生的新微任务,会在当前宏任务执行完后立即执行
事件循环的实际应用
理解事件循环机制在以下场景特别有用:
- UI渲染优化:使用微任务可以确保在下一次渲染之前完成DOM操作,减少重绘次数
- 异步操作编排:根据任务类型合理安排代码执行顺序
- 性能优化:将计算密集型任务拆分并使用微任务执行,避免阻塞主线程
来道经典的代码题
大家和我一起来看到下面的代码题,输出最终的打印顺序结果:
console.log('script start') // 1. script start
async function async1() {
await async2()
console.log('async1 end') // 5. async1 end
}
async function async2() {
console.log('async2 end') // 2. async2 end
}
async1()
setTimeout(function() {
console.log('setTimeout') // 8. setTimeout
}, 0)
new Promise(resolve => { // 3. Promise
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1') // 6. promise1
})
.then(function() {
console.log('promise2') // 7. promise2
})
console.log('script end') // 4. script end
可以看到打印结果如下:
首先执行同步代码,打印"script start",调用async1()函数,进入async1内部,在async1中遇到await async2(),立即执行async2()函数,async2函数执行,打印"async2 end"
await使async1剩余部分进入微任务队列等待;继续执行同步代码,遇到setTimeout,其回调被放入宏任务队列 ;遇到Promise构造函数,立即执行内部代码,打印"Promise";Promise的两个then回调被放入微任务队列。
打印"script end",同步代码执行完毕,开始清空微任务队列:
-
执行await后的代码,打印"async1 end"
-
执行第一个then回调,打印"promise1"
-
执行第二个then回调,打印"promise2"
微任务队列清空后,执行宏任务队列中的setTimeout回调,打印"setTimeout"
这体现了JavaScript的事件循环机制:先执行同步代码,然后是微任务,最后是宏任务。
总结
JavaScript的事件循环机制是理解异步编程的基础,掌握宏任务和微任务的执行顺序可以帮助我们编写更高效、更可预测的代码:
- 同步代码优先执行
- 微任务队列次之执行(
Promise、queueMicrotask、MutationObserver等) - 宏任务队列最后执行(
setTimeout、setInterval、I/O等) - Node.js中的
process.nextTick优先级高于其他微任务
参考资源: