深入理解JavaScript事件循环机制:从代码到实战

128 阅读5分钟

作为前端开发者,你是否曾遇到过这样的困惑:明明写在后面的代码,为什么先执行了?或者为什么 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');

三、事件循环执行规则(必看!)

记住这个 黄金流程 :

  1. 先执行同步代码(调用栈不为空)
  2. 同步代码执行完毕,调用栈为空
  3. 执行 所有微任务 (按队列顺序)
  4. 执行 一个宏任务
  5. 重复步骤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)
  • 宏任务队列优先级(从高到低):
    1. timerssetTimeout / setInterval 回调
    2. pending callbacks :延迟到下一轮的I/O回调
    3. idle, prepare :内部使用
    4. poll :获取新的I/O事件(核心阶段)
    5. checksetImmediate 回调
    6. close callbacks :关闭回调(如 socket.on('close', ...) )
  • 微任务队列:
    1. nextTick 队列 (优先级最高,非标准微任务)
    2. Promise 微任务队列 : Promise.then/catch/finally / queueMicrotask
  • 执行规则 :完成一个阶段的所有宏任务 → 清空nextTick队列 → 清空Promise微任务队列 → 进入下一阶段

五、实战中的常见问题

1. setTimeout(fn, 0) 为什么不是立即执行?

因为它是宏任务,必须等当前同步代码和所有微任务执行完才会触发。实际延迟通常大于0ms(最小延迟约4ms)。

2. 为什么DOM操作后立即读取属性可能不准?

DOM更新是异步的,属于UI渲染宏任务。如果需要在DOM更新后操作,应该用 requestAnimationFramesetTimeout

3. React的useEffect和useLayoutEffect区别?

useEffectuseLayoutEffect 均在渲染后执行,但前者异步(不阻塞渲染),后者同步(阻塞渲染)。大多数情况优先使用 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
  • 优化页面性能(避免长任务阻塞)

记住这个口诀: 同步先行,微任务次之,宏任务最后,循环往复。希望这篇文章对你有帮助!