作为前端开发者,你是否曾遇到过这样的困惑:明明写在后面的代码,为什么先执行了?或者为什么 setTimeout(fn, 0) 不是立即执行?这些问题的答案,都藏在JavaScript的 事件循环(Event Loop) 机制里。今天我们就从实际代码出发,彻底搞懂事件循环的工作原理。
一、为什么需要事件循环?
JavaScript是一门 单线程 语言,这意味着它同一时间只能做一件事。如果所有代码都是同步执行,那么一个耗时操作(比如网络请求)就会阻塞整个页面,用户体验会非常糟糕。
事件循环就是JavaScript实现 非阻塞异步编程 的核心机制。它像一个交通指挥官,合理安排代码的执行顺序,让同步任务和异步任务和谐共处。
二、事件循环的核心概念
1. 三大组成部分
- 调用栈(Call Stack) :执行同步代码的地方,遵循"先进后出"原则
- 任务队列(Task Queue) :存放异步任务,分为 微任务 和 宏任务 两类
- 事件循环(Event Loop) :不断检查调用栈和任务队列,协调代码执行
2. 任务分类(附代码示例)
宏任务(Macrotasks)
需要排队等待执行的异步任务,常见类型:
- setTimeout / setInterval (定时器)
- I/O操作(如 fetch /AJAX)
- UI渲染
- 整个 script 脚本执行(初始宏任务)
// 同步代码
console.log('同步开始');
// 宏任务1
setTimeout(() => {
console.log('宏任务1');
}, 0);
// 宏任务2
setTimeout(() => {
console.log('宏任务2');
}, 0);
console.log('同步结束');
微任务(Microtasks)
优先级高于宏任务的异步任务,常见类型:
- Promise.then / .catch / .finally
- queueMicrotask()
- MutationObserver (DOM变化监听)
- Node环境的 process.nextTick (优先级最高)
console.log('同步Start');
// 三个微任务按顺序执行
Promise.resolve('First Promise').then(value => console.log(value));
Promise.resolve('Second Promise').then(value => console.log(value));
new Promise(resolve => resolve('Third Promise')).then(value => console.log(value));
console.log('同步end');
三、事件循环执行规则(必看!)
记住这个 黄金流程 :
- 先执行同步代码(调用栈不为空)
- 同步代码执行完毕,调用栈为空
- 执行 所有微任务 (按队列顺序)
- 执行 一个宏任务
- 重复步骤3-4,直到任务队列为空
比如这段代码:
console.log('同步开始');
setTimeout(() => {
console.log('宏任务1');
Promise.resolve().then(() => console.log('宏任务1中的微任务'));
}, 0);
Promise.resolve().then(() => {
console.log('微任务1');
setTimeout(() => console.log('微任务1中的宏任务'), 0);
});
Promise.resolve().then(() => console.log('微任务2'));
setTimeout(() => {
console.log('宏任务2');
Promise.resolve().then(() => console.log('宏任务2中的微任务'));
}, 0);
console.log('同步结束');
最后打印的结果为:
同步开始 →
同步结束 →
微任务1 →
微任务2 →
宏任务1 →
宏任务1中的微任务 →
宏任务2 →
宏任务2中的微任务 →
微任务1中的宏任务
四、浏览器 vs Node.js 事件循环差异
浏览器环境 :
- 采用 单宏任务队列 + 单微任务队列 模型
- 宏任务队列: setTimeout / setInterval / script 整体代码/UI渲染等
- 微任务队列: Promise.then/catch/finally / MutationObserver / queueMicrotask
- 执行规则 :一个宏任务 → 清空所有微任务 → 渲染 → 下一个宏任务
Node.js环境 :
- 采用 多阶段宏任务队列 + 微任务队列 模型(基于libuv)
- 宏任务队列优先级(从高到低):
- timers :
setTimeout/setInterval回调 - pending callbacks :延迟到下一轮的I/O回调
- idle, prepare :内部使用
- poll :获取新的I/O事件(核心阶段)
- check :
setImmediate回调 - close callbacks :关闭回调(如 socket.on('close', ...) )
- timers :
- 微任务队列:
- nextTick 队列 (优先级最高,非标准微任务)
- Promise 微任务队列 :
Promise.then/catch/finally/queueMicrotask
- 执行规则 :完成一个阶段的所有宏任务 → 清空nextTick队列 → 清空Promise微任务队列 → 进入下一阶段
五、实战中的常见问题
1. setTimeout(fn, 0) 为什么不是立即执行?
因为它是宏任务,必须等当前同步代码和所有微任务执行完才会触发。实际延迟通常大于0ms(最小延迟约4ms)。
2. 为什么DOM操作后立即读取属性可能不准?
DOM更新是异步的,属于UI渲染宏任务。如果需要在DOM更新后操作,应该用 requestAnimationFrame 或 setTimeout 。
3. React的useEffect和useLayoutEffect区别?
useEffect 和 useLayoutEffect 均在渲染后执行,但前者异步(不阻塞渲染),后者同步(阻塞渲染)。大多数情况优先使用 useEffect,仅在需要同步 DOM 操作时使用
代码示例对比
视觉闪烁问题(useEffect 的局限性)
function FlickerDemo() {
const [width, setWidth] = useState(0);
const ref = useRef(null);
// 使用useEffect会导致闪烁
useEffect(() => {
// DOM已更新,但浏览器已渲染,此处修改会触发二次渲染
setWidth(ref.current.offsetWidth);
}, []);
return (
<div ref={ref} style={{ width: '100px', height: '100px', background: 'red' }}>
{width}
</div>
);
}
每次刷新页面时都会导致数字闪烁,对用户体验很不友好。这个时候就可以使用useLayoutEffect了
function NoFlickerDemo() {
const [width, setWidth] = useState(0);
const ref = useRef(null);
// 使用useLayoutEffect避免闪烁
useLayoutEffect(() => {
// DOM已更新,但浏览器未渲染,此处修改会合并到一次渲染中
setWidth(ref.current.offsetWidth);
}, []);
return (
<div ref={ref} style={{ width: '100px', height: '100px', background: 'blue' }}>
{width}
</div>
);
}
- 优先使用 useEffect ,避免阻塞渲染
- useLayoutEffect 中避免复杂计算,可能导致页面卡顿
- 两者清理函数执行时机相同:组件卸载或依赖变化时
六、总结
事件循环是JavaScript的灵魂,理解它能帮我们:
- 预测代码执行顺序
- 解决异步相关bug
- 优化页面性能(避免长任务阻塞)
记住这个口诀: 同步先行,微任务次之,宏任务最后,循环往复。希望这篇文章对你有帮助!