事件循环与 JavaScript 执行模型

深入理解 JavaScript 单线程执行模型、事件循环的基本原理,以及为什么异步编程是前端工程师必须掌握的核心能力。


从一个经典面试题开始

console.log('1');

setTimeout(function() {
  console.log('2');
}, 0);

Promise.resolve().then(function() {
  console.log('3');
});

console.log('4');

输出是什么?

如果你不能立刻回答出 1 4 3 2,或者不确定为什么是这个顺序,这说明你对 JavaScript 的执行模型理解还不够深入。而这个问题,几乎是高级前端面试的必考题。


为什么 JavaScript 是单线程的

JavaScript 从诞生之初就是单线程的。这个设计选择不是偶然的,而是和它的诞生背景密切相关。

JavaScript 最初是为浏览器设计的脚本语言,用于处理页面交互、DOM 操作等。在一个浏览器环境里,如果有多个线程同时操作同一个 DOM,会产生复杂的同步问题。比如一个线程在读 DOM 节点属性,另一个线程在删除这个节点,应该以哪个为准?

为了避免这种复杂性,JavaScript 设计成了单线程:同一时间只有一个任务在执行,任务排队执行,不需要考虑锁和竞态条件。

这个设计选择带来的直接后果是:JavaScript 的执行是阻塞式的。如果一个任务执行时间很长,后面的任务就必须等待,整个页面会卡住。

这就引出了异步编程的必要性。

单线程并不意味着慢

很多人误解"单线程"等于"性能差"。实际上,JavaScript 的执行速度非常快。以 Chrome V8 引擎为例,它使用了多项优化技术:

  • JIT 编译:JavaScript 是解释型语言,但 V8 会对热点代码进行 JIT(Just-In-Time)编译,生成优化机器码
  • 隐藏类:V8 使用隐藏类(Hidden Class)机制,让动态类型也能高效执行
  • 内联缓存:针对频繁访问的对象属性,V8 使用内联缓存加速读取

单线程配合事件循环,在 IO 密集型场景(前端的主要场景)下,性能并不比多线程差。因为 IO 操作期间 CPU 是空闲的,单线程可以把控制权交给下一个任务,而不是阻塞等待。


事件循环的核心概念

事件循环(Event Loop)是 JavaScript 实现异步编程的核心机制。它的基本思想很简单:主线程从任务队列中取出任务执行,执行完后继续取下一个任务,这个循环永不停止

但"任务队列"这个说法不够精确。JavaScript 的任务调度实际上分为几个层次:

调用栈(Call Stack)

调用栈是 JavaScript 执行代码时的"工作记录"。当调用一个函数时,一个栈帧(Stack Frame)被推入调用栈;当函数返回时,栈帧被弹出。

function foo() {
  return bar();
}

function bar() {
  return baz();
}

function baz() {
  return 42;
}

console.log(foo());

执行过程:foo 入栈 → foo 调用 bar,bar 入栈 → bar 调用 baz,baz 入栈 → baz 返回 42,baz 出栈 → bar 返回 42,bar 出栈 → foo 返回 42,foo 出栈。

调用栈是 LIFO(后进先出)结构。如果调用栈不断增长而不出栈,就会发生栈溢出(Stack Overflow)。

任务队列(Task Queue)

任务队列是等待执行的任务的队列。当异步操作完成时(比如 setTimeout 到时了,或者用户点击了按钮),对应的回调函数被放入任务队列。

主线程不断从任务队列中取出任务执行。这个"取出-执行"的循环,就是事件循环。

事件循环的基本流程

while (true) {
  // 1. 执行同步任务,直到调用栈为空
  while (taskQueue.isEmpty()) {
    // 等待新任务...
  }

  // 2. 取出一个任务执行
  let task = taskQueue.dequeue();
  execute(task);
}

但这个伪代码还不够精确——实际的事件循环会区分宏任务和微任务,这是理解异步执行顺序的关键。


宏任务与微任务

这是理解 JavaScript 异步执行顺序的核心概念。

宏任务(Macro Task)

宏任务是宿主环境(比如浏览器或 Node.js)发起的大任务。每次事件循环迭代中,只有一个宏任务被执行。

宏任务包括:

  • setTimeout / setInterval
  • setImmediate(Node.js 独有)
  • I/O 操作
  • UI 渲染(浏览器的重排重绘)
  • requestAnimationFrame
  • 页面交互事件(click、keydown 等)

微任务(Micro Task)

微任务是在当前宏任务执行完毕后、下一个宏任务开始前执行的任务。微任务的优先级高于宏任务——每执行完一个宏任务,JavaScript 引擎会先把所有微任务执行完,再取下一个宏任务。

微任务包括:

  • Promise.then() / Promise.catch()
  • queueMicrotask()
  • MutationObserver
  • process.nextTick(Node.js 独有)

完整的执行顺序

console.log('1');

setTimeout(function() {
  console.log('2');
}, 0);

Promise.resolve().then(function() {
  console.log('3');
});

console.log('4');

执行顺序分析:

  1. 同步代码执行console.log('1')console.log('4'),输出 1 4
  2. 微任务检查:同步代码执行完毕后,检查微任务队列。Promise.resolve().then() 的回调在微任务队列中,执行它,输出 3
  3. 下一个宏任务:微任务执行完后,开始下一个宏任务。setTimeout 的回调在宏任务队列中,执行它,输出 2

最终输出:1 4 3 2


浏览器环境下的事件循环

在浏览器环境中,事件循环需要和渲染引擎协同工作。

渲染时机

浏览器的渲染(paint/repaint)并不是每帧都会发生。事件循环在特定时机检查是否需要渲染:

  1. 当所有微任务执行完毕
  2. 检查是否需要执行 requestAnimationFrame 回调
  3. 执行 requestAnimationFrame 回调
  4. 如果是合适的时机,执行渲染(style、layout、paint)

这个机制带来的一个重要结论是:setTimeout 来做动画是不精确的,因为 setTimeout 的回调只是放在宏任务队列里,不保证在哪个时机执行。requestAnimationFrame 才是为动画设计的正确 API——它会在浏览器的渲染时机被调用,保证每一帧都能得到更新。

宏任务的分类执行

在浏览器里,不同类型的宏任务有不同的优先级。常见的宏任务来源:

** timers 阶段**:setTimeoutsetInterval 的回调在 timers 阶段执行

** I/O callbacks 阶段**:上一轮 I/O 操作的回调

** check 阶段**:setImmediate(Node.js)/ requestAnimationFrame(浏览器)

** close callbacks 阶段**:关闭回调,如 socket 关闭


Node.js 环境下的事件循环

Node.js 的事件循环和浏览器有显著差异。

阶段划分

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

  1. timers:执行 setTimeoutsetInterval 的回调
  2. pending callbacks:执行上一轮延迟的 I/O 回调
  3. idle, prepare:内部使用
  4. poll:获取新的 I/O 事件;执行与 I/O 相关的回调
  5. checksetImmediate 的回调在这里执行
  6. close callbacks:执行关闭回调

setImmediate vs setTimeout

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});

在 REPL 环境中,两者的输出顺序是不确定的——取决于系统性能。但在 I/O 操作的回调里,setImmediate 总是先于 setTimeout 执行:

const fs = require('fs');

fs.readFile('test.txt', () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

这里 immediate 几乎总是先输出。

process.nextTick

process.nextTick 是 Node.js 独有的 API,它创建的回调优先级高于普通微任务:

Promise.resolve().then(() => console.log('promise'));

process.nextTick(() => console.log('nextTick'));

输出:nextTick promise

这是因为 process.nextTick 回调放在一个单独的队列里,在每个阶段结束时、在微任务队列之前执行。


事件循环与开发者体验

理解事件循环不只是为了应付面试题,更是为了写出正确、高效的 JavaScript 代码。

避免阻塞主线程

由于 JavaScript 是单线程,任何耗时操作都会阻塞主线程,导致页面卡顿、动画掉帧、用户交互无响应。

常见的阻塞场景:

  • 大数据量的同步计算(如排序一个百万级别的数组)
  • 密集型 DOM 操作
  • 同步 XMLHttpRequest

解决方案是将耗时操作拆分,或者使用 Web Worker 在后台线程执行。

合理使用微任务

Promise.then() 的回调是微任务,会在当前宏任务结束后立即执行。这在某些场景下很有用,但也要注意:

// 错误:每次 state 更新后都触发异步操作,可能导致竞态
async function updateAndFetch(id, data) {
  await updateData(id, data);
  return fetchData(id);
}

// 更好:先完成所有同步操作,再发起一次异步请求
async function updateAndFetch(id, data) {
  setState({ ...data }); // 同步更新 state
  return fetchData(id); // 异步获取最新数据
}

这一章想说的

事件循环是 JavaScript 异步编程的基础。理解它,你需要理解:

  • JavaScript 为什么是单线程(历史原因 + 设计权衡)
  • 调用栈和任务队列的协作方式
  • 宏任务和微任务的区别和执行顺序
  • 浏览器和 Node.js 在事件循环实现上的差异

这些知识点不是孤立的——它们和 Promise、async/await、Vue 的 nextTick、React 的状态更新等核心概念都有深层联系。打通这些联系,你的 JavaScript 理解才能真正融会贯通。


延展阅读


实践练习

练习一:分析下列代码的输出顺序(20 分钟)

console.log('script start');

setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => {
    console.log('promise in setTimeout');
  });
}, 0);

new Promise((resolve) => {
  console.log('promise executor');
  resolve();
}).then(() => {
  console.log('promise then 1');
});

Promise.resolve().then(() => {
  console.log('promise then 2');
});

setTimeout(() => {
  console.log('setTimeout 2');
}, 0);

console.log('script end');

试着自己分析输出顺序,然后运行代码验证。对每一行输出,标注它是在哪个"阶段"执行的(同步宏任务/微任务/下一个宏任务)。

练习二:理解 Node.js 和浏览器的差异(15 分钟)

在 Node.js 环境运行以下代码,观察输出:

const fs = require('fs');

fs.readFile('/etc/hostname', () => {
  console.log('1. file read callback');
  setTimeout(() => console.log('2. timeout'), 0);
  setImmediate(() => console.log('3. immediate'));
  process.nextTick(() => console.log('4. nextTick'));
});

console.log('5. synchronous');

思考:为什么 nextTick 的输出位置和其他微任务不同?