事件循环
JavaScript 是一门单线程语言,这意味着在任何给定时刻,它只能执行一个任务。如果遇到耗时长的操作,比如网络请求或复杂计算,主线程就会被阻塞,导致页面无法响应、动画卡顿,用户体验极差。为了解决这个问题,JavaScript 引入了事件循环机制,配合宿主环境(如浏览器或 Node.js)提供的异步 API,实现了非阻塞的并发模型。
组成部分
事件循环模型主要由以下几个部分协同工作:
- 调用栈 (Call Stack):一个后进先出(LIFO)的结构,用于存储和管理函数调用。所有同步代码都在这里执行。
- 宿主环境 API (Web APIs / Node.js APIs):这些是宿主环境提供的能力,并非 JavaScript 引擎的一部分,例如 setTimeout, DOM 事件, fetch 等。当我们在代码中调用它们时,它们会在后台独立运行。
- 任务队列 (Task Queues):一个先进先出(FIFO)的结构,用于存放待执行的回调函数。它分为两种类型:
- 宏任务队列 (Macrotask Queue):存放 setTimeout, setInterval, I/O 操作,UI 渲染等任务的回调。
- 微任务队列 (Microtask Queue):存放 Promise.then/catch/finally, async/await 的后续代码, MutationObserver 等任务的回调。微任务的优先级高于宏任务。
浏览器的事件循环
事件循环的整个过程可以被精确地描述为一个持续的循环,每一轮循环称为一个 tick。
- 执行同步代码:首先,执行全局脚本中的所有同步代码。当函数被调用时,其执行上下文被推入调用栈;函数返回后,再被弹出。在这个过程中,遇到的异步 API 调用会被交给宿主环境处理。
- 清空微任务队列:当调用栈为空时(即所有同步代码执行完毕),事件循环会立即检查微任务队列。它会执行并清空整个队列。如果在执行微任务的过程中,又产生了新的微任务,这些新任务也会被添加到队列末尾,并在当前 tick 内一并执行完毕。
- 执行一个宏任务:微任务队列被清空后,事件循环会去宏任务队列检查。如果宏任务队列不为空,它会取出队列中的第一个任务,将其回调函数推入调用栈中执行。
- 重复循环:当这个宏任务执行完毕,调用栈再次变为空后,事件循环会回到第 2 步,再次检查微任务队列,如此往复。 这个流程的核心在于:一个 tick 内会清空所有微任务,但只会处理一个宏任务。
代码示例与分析
Promise.then() (微任务) 和 setTimeout() (宏任务) 的执行差异最能体现该机制。
javascript
console.log('脚本开始'); // 1. 同步
setTimeout(function () {
console.log('setTimeout 回调'); // 4. 宏任务
}, 0);
Promise.resolve().then(function () {
console.log('Promise 回调 1'); // 2. 微任务
Promise.resolve().then(function () {
console.log('Promise 回调 2'); // 3. 微任务
});
});
console.log('脚本结束'); // 1. 同步
执行分析:
- 同步执行:
- 输出 "脚本开始"。
- setTimeout 的回调被注册到 Web API,然后被放入宏任务队列。
- 第一个 Promise.then 的回调被放入微任务队列。
- 输出 "脚本结束"。
- 此时,同步代码执行完毕,调用栈为空。
- 清空微任务:
- 事件循环发现微任务队列中有任务,开始执行。
- 输出 "Promise 回调 1"。
- 在执行过程中,又遇到了一个新的 Promise.then,它的回调被加入微任务队列的末尾。
- 事件循环继续检查微任务队列,发现还不为空,继续执行。
- 输出 "Promise 回调 2"。
- 现在微任务队列为空。
- 执行一个宏任务:
- 事件循环转向宏任务队列,取出一个任务执行。
- 输出 "setTimeout 回调"。
Node 的事件循环
Node.js 的事件循环由 libuv 库实现,其设计目标是高效地处理服务器端的大量 I/O 操作。它的宏任务阶段划分得非常精细。 Node.js 事件循环的六个阶段 (Phases): 事件循环会按顺序反复执行这六个阶段,每个阶段都有自己专用的 FIFO 队列。
- timers (定时器) 阶段: 执行由 setTimeout() 和 setInterval() 安排的回调。
- pending callbacks (待定回调) 阶段: 执行延迟到下一个循环迭代的 I/O 回调(比如 TCP 错误)。
- idle, prepare 阶段: 仅内部使用。
- poll (轮询) 阶段: 这是最重要的阶段。
- 计算应该阻塞和轮询 I/O 的时间。
- 然后,处理 poll 队列中的事件(几乎所有的 I/O 回调都在这里执行,如 fs.readFile)。
- 如果 poll 队列为空,Node.js 会检查是否有 setImmediate 的回调。如果有,就进入 check 阶段;如果没有,它会在此处等待新的 I/O 事件,直到超时或有新事件进来。
- check (检查) 阶段: 执行 setImmediate() 安排的回调。
- close callbacks (关闭回调) 阶段: 执行一些关闭回调,如 socket.on('close', ...)。
在每个阶段切换之前,Node.js 都会像浏览器一样,先清空微任务队列。
关键区别与特例:setTimeout vs setImmediate
这两者哪个先执行,是不确定的,取决于 Node.js 进程启动时的性能。
javascript
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
- 如果在主模块中直接运行: 哪个先执行不确定。因为 timers 阶段是在 poll 阶段之前的,但如果主模块代码执行得非常快,事件循环可能在 timers 队列准备好之前就进入了,这时它会先进入 poll 阶段,然后是 check 阶段(执行 setImmediate)。
- 如果在 I/O 回调中运行: setImmediate 总是先于 setTimeout 执行。
javascript
const fs = require('fs');
fs.readFile(\_\_filename, () => {
// 当前处于 poll 阶段的回调中
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});
// 输出总是:
// setImmediate
// setTimeout
因为 poll 阶段结束后,下一个阶段就是 check(执行 setImmediate),再下一个循环才会到 timers(执行 setTimeout)。
Node.js 特有的微任务:process.nextTick
process.nextTick 虽然技术上不属于事件循环的一部分,但它的行为类似微任务。它拥有最高优先级,其队列会在当前操作完成后(即在事件循环的任何阶段切换之前)立即被清空,优先级高于其他所有微任务。
javascript
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// 输出总是:
// nextTick
// promise