◆

JavaScript 事件循环 Event Loop 机制

2023-02-14 02:400

为什么需要事件循环

JavaScript 的执行是单线程的,同一时刻只有一个调用栈在跑。这意味着任何耗时操作如果同步执行,页面就会卡死——包括无法响应点击、无法重绘。

但浏览器本身不是单线程的。发起网络请求、定时器计时、监听 DOM 事件这些工作由浏览器的其他线程负责,完成之后它们需要把回调交还给 JS 线程执行。事件循环(Event Loop)就是这个「交还」的调度机制:它决定了下一个要执行的回调是哪一个。

调用栈、任务队列与循环

先理清三个角色:

  • 调用栈(Call Stack):同步代码的执行场所,函数调用压栈、返回出栈
  • 任务队列:存放待执行的回调。分宏任务队列和微任务队列
  • 事件循环:一个不停转的循环,它做的事情是——等调用栈空了,去队列里取任务放进栈里执行

关键点是「等调用栈空了」。只要还有同步代码在跑,队列里的回调一个都不会被执行,哪怕定时器早就到期了。

宏任务与微任务

任务分两类,这是理解执行顺序的核心。

宏任务(macrotask / task):

  • setTimeout / setInterval
  • setImmediate(Node 独有)
  • I/O、UI 渲染
  • MessageChannel、requestAnimationFrame(严格说 rAF 在渲染阶段,不在宏任务队列)

微任务(microtask):

  • Promise.then / catch / finally
  • queueMicrotask
  • MutationObserver
  • process.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 ——微任务是在同一轮里清空的,浏览器根本没有机会插入渲染。

💬留言板0

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

PAGE LOAD / LIVEJavaScript 事件循环 Event Loop 机制加载中