JavaScript 事件循环 Event Loop 机制
为什么需要事件循环
JavaScript 的执行是单线程的,同一时刻只有一个调用栈在跑。这意味着任何耗时操作如果同步执行,页面就会卡死——包括无法响应点击、无法重绘。
但浏览器本身不是单线程的。发起网络请求、定时器计时、监听 DOM 事件这些工作由浏览器的其他线程负责,完成之后它们需要把回调交还给 JS 线程执行。事件循环(Event Loop)就是这个「交还」的调度机制:它决定了下一个要执行的回调是哪一个。
调用栈、任务队列与循环
先理清三个角色:
- 调用栈(Call Stack):同步代码的执行场所,函数调用压栈、返回出栈
- 任务队列:存放待执行的回调。分宏任务队列和微任务队列
- 事件循环:一个不停转的循环,它做的事情是——等调用栈空了,去队列里取任务放进栈里执行
关键点是「等调用栈空了」。只要还有同步代码在跑,队列里的回调一个都不会被执行,哪怕定时器早就到期了。
宏任务与微任务
任务分两类,这是理解执行顺序的核心。
宏任务(macrotask / task):
setTimeout/setIntervalsetImmediate(Node 独有)- I/O、UI 渲染
MessageChannel、requestAnimationFrame(严格说 rAF 在渲染阶段,不在宏任务队列)
微任务(microtask):
Promise.then/catch/finallyqueueMicrotaskMutationObserverprocess.nextTick(Node 独有,优先级比其他微任务更高)
规则是这样的:调用栈清空后,事件循环会把微任务队列彻底清空(清空过程中新产生的微任务也会在本轮一起执行完),然后才去取下一个宏任务。取宏任务是一次只取一个,执行完这一个宏任务,又要把微任务队列清空一遍。
一句话概括:一个宏任务 + 清空所有微任务 = 一轮事件循环。
一个经典例子
console.log('1 同步');
setTimeout(() => {
console.log('2 宏任务 timeout');
Promise.resolve().then(() => console.log('3 timeout 里的微任务'));
}, 0);
Promise.resolve().then(() => {
console.log('4 微任务');
Promise.resolve().then(() => console.log('5 微任务里的微任务'));
});
console.log('6 同步');
输出顺序是:
1 同步
6 同步
4 微任务
5 微任务里的微任务
2 宏任务 timeout
3 timeout 里的微任务
逐步拆解:同步代码先跑完,打印 1、6。此时调用栈空了,清空微任务队列,打印 4;4 的回调里又产生了一个微任务,它同样在本轮被清掉,打印 5。微任务全空后取一个宏任务,打印 2;这个宏任务里注册的微任务不用等下一轮,宏任务一执行完就清微任务,打印 3。
注意 5 排在 2 前面——这就是「微任务队列要彻底清空」的含义。反过来说,如果在微任务里无限地产生新微任务,宏任务永远得不到执行机会,页面会彻底卡住:
// 不要这么写,会死循环卡死页面
function loop() {
Promise.resolve().then(loop);
}
loop();
async / await 的顺序
async/await 是 Promise 的语法糖,await 后面的代码相当于放进 .then 里,也就是微任务:
async function a1() {
console.log('a1 start');
await a2();
console.log('a1 end'); // 这行是微任务
}
async function a2() {
console.log('a2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
a1();
new Promise(resolve => {
console.log('promise 构造函数'); // 同步执行
resolve();
}).then(() => console.log('promise then'));
console.log('script end');
输出:
script start
a1 start
a2
promise 构造函数
script end
a1 end
promise then
setTimeout
两个容易错的点:new Promise 的执行器函数是同步执行的,只有 .then 里的回调才是微任务;await a2() 会先同步执行 a2() 打印 a2,然后才把后面的 a1 end 挂成微任务。
setTimeout 0 并不是立刻
setTimeout(() => console.log('timeout'), 0);
for (let i = 0; i < 1e9; i++) {} // 长同步任务
console.log('sync done');
这里 timeout 一定在 sync done 之后打印,而且实际延迟远超 0ms。原因是回调必须等调用栈空了才能进栈。另外 HTML 规范规定嵌套层级超过 5 层的定时器最小延迟是 4ms,所以 setTimeout(fn, 0) 在嵌套调用时实际至少 4ms。
Node 与浏览器的差异
Node 的事件循环分多个阶段(timers、pending callbacks、poll、check、close callbacks),setTimeout 在 timers 阶段,setImmediate 在 check 阶段。此外 Node 有 process.nextTick,它的队列优先级高于 Promise 的微任务队列,每个阶段之间都会先清空 nextTick 队列再清 microtask 队列。
在 Node 11 之前,同一个 timers 阶段里到期的多个定时器回调会连续执行完才清微任务;11 之后行为对齐了浏览器,每执行一个宏任务就清一次微任务。所以看老文章讲的执行顺序结论时要注意版本。
实际用途
理解事件循环最直接的用处是拆分长任务。如果有一段计算会阻塞几百毫秒,可以切成小块用 setTimeout 或 MessageChannel 让出主线程,给浏览器渲染和响应用户输入的机会:
function chunkedWork(items, handle) {
const CHUNK = 100;
let i = 0;
function run() {
const end = Math.min(i + CHUNK, items.length);
while (i < end) handle(items[i++]);
if (i < items.length) setTimeout(run, 0); // 让出主线程
}
run();
}
这里必须用宏任务让出,不能用 Promise.resolve().then ——微任务是在同一轮里清空的,浏览器根本没有机会插入渲染。

还没有留言,来发表第一条吧~