从一个经典面试题开始
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/setIntervalsetImmediate(Node.js 独有)I/O操作- UI 渲染(浏览器的重排重绘)
requestAnimationFrame- 页面交互事件(click、keydown 等)
微任务(Micro Task)
微任务是在当前宏任务执行完毕后、下一个宏任务开始前执行的任务。微任务的优先级高于宏任务——每执行完一个宏任务,JavaScript 引擎会先把所有微任务执行完,再取下一个宏任务。
微任务包括:
Promise.then()/Promise.catch()queueMicrotask()MutationObserverprocess.nextTick(Node.js 独有)
完整的执行顺序
console.log('1');
setTimeout(function() {
console.log('2');
}, 0);
Promise.resolve().then(function() {
console.log('3');
});
console.log('4');
执行顺序分析:
- 同步代码执行:
console.log('1')→console.log('4'),输出1 4 - 微任务检查:同步代码执行完毕后,检查微任务队列。
Promise.resolve().then()的回调在微任务队列中,执行它,输出3 - 下一个宏任务:微任务执行完后,开始下一个宏任务。
setTimeout的回调在宏任务队列中,执行它,输出2
最终输出:1 4 3 2
浏览器环境下的事件循环
在浏览器环境中,事件循环需要和渲染引擎协同工作。
渲染时机
浏览器的渲染(paint/repaint)并不是每帧都会发生。事件循环在特定时机检查是否需要渲染:
- 当所有微任务执行完毕
- 检查是否需要执行
requestAnimationFrame回调 - 执行
requestAnimationFrame回调 - 如果是合适的时机,执行渲染(style、layout、paint)
这个机制带来的一个重要结论是:用 setTimeout 来做动画是不精确的,因为 setTimeout 的回调只是放在宏任务队列里,不保证在哪个时机执行。requestAnimationFrame 才是为动画设计的正确 API——它会在浏览器的渲染时机被调用,保证每一帧都能得到更新。
宏任务的分类执行
在浏览器里,不同类型的宏任务有不同的优先级。常见的宏任务来源:
** timers 阶段**:setTimeout、setInterval 的回调在 timers 阶段执行
** I/O callbacks 阶段**:上一轮 I/O 操作的回调
** check 阶段**:setImmediate(Node.js)/ requestAnimationFrame(浏览器)
** close callbacks 阶段**:关闭回调,如 socket 关闭
Node.js 环境下的事件循环
Node.js 的事件循环和浏览器有显著差异。
阶段划分
Node.js 的事件循环分为六个阶段:
- timers:执行
setTimeout和setInterval的回调 - pending callbacks:执行上一轮延迟的 I/O 回调
- idle, prepare:内部使用
- poll:获取新的 I/O 事件;执行与 I/O 相关的回调
- check:
setImmediate的回调在这里执行 - 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 理解才能真正融会贯通。
延展阅读
- Jake Archibald: Tasks, microtasks, queues and schedules — 事件循环最经典的可视化解释
- MDN: The event loop — MDN 官方参考
- Philip Roberts: What the heck is the event loop anyway? — JSConf 上的著名演讲,帮你建立事件循环的直觉
实践练习
练习一:分析下列代码的输出顺序(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 的输出位置和其他微任务不同?