JS-深度解构JS事件循环(Event Loop)

51 阅读4分钟

前言

为什么 JavaScript 是单线程的却能处理异步 IO?为什么 setTimeout 并不总是准时?本文将从宏观的执行栈、任务队列,一直深入到浏览器底层的任务调度逻辑,带你彻底看透事件循环。

一、 为什么需要事件循环?

JavaScript 的核心是单线程的,这意味着它只有一个主线程来处理 DOM 解析、样式计算、脚本执行等。如果某个任务耗时过长,页面就会“卡死”。为了协调同步任务与异步任务(输入事件、网络请求、定时器),浏览器引入了事件循环系统来统一调度和处理这些任务。


二、 核心组件:执行栈与任务队列

1. 执行栈 (Execution Stack)

当多个方法被调用的时候,因为js是单线程的,所以每次只能执行一个方法,于是这些方法被排到了一个单独的地方,这个地方就是执行栈。执行栈里面执行的都是同步的操作。

2. 事件队列 (Task Queue)

  • 在js执行过程中如果遇到异步事件(如 Ajax、定时器),就会首先将这个异步事件交给对应的浏览器模块(如网络进程),继续执行执行栈里面的任务。
  • 当异步事件返回结果后,js不会立即执行这个回调,会将事件加入到事件队列中,只有当执行栈里面的全部执行完以后,主线程才会去查找事件队列中是否有任务。
  • 如果有,那么主线程会取出事件队列里面排在最前面的事件,将这个事件对应的回调加入到执行栈中,然后执行其中的同步代码。然后在继续观察执行栈里面是否有任务,依次反复...就形成了一个无限的循环。
  • 这就是这个过程被称为事件循环(Event loop)的原因。

循环逻辑:

  1. 检查执行栈是否为空。
  2. 若为空,从事件队列头部取出一个任务推入执行栈。
  3. 循环往复。

三、 异步任务的“等级”:宏任务与微任务

并非所有的异步任务优先级都一样。在同一次循环中,微任务永远在下一次宏任务之前执行!!!

类型包含任务执行时机
宏任务 (MacroTask)setTimeout, setInterval, ajax, dom事件每次事件循环开始时处理一个
微任务 (MicroTask)Promise.then/catch, MutaionObserver, process.nextTick (Node.js)当前执行栈清空后,立即清空整个微任务队列

注意: new Promise() 构造函数内部的代码是同步执行的,只有 .then().catch() 里的回调才是微任务。(后续会专门出一篇promise相关文章)


四、 底层揭秘:定时器是如何实现的?

很多开发者认为 setTimeout 是直接进入消息队列的,但浏览器底层其实维护了一个延迟执行队列 (Delayed Incoming Queue)

1. 任务数据结构

当调用 setTimeout 时,渲染进程内部会创建一个任务结构体:

struct DelayTask{
  int64 id;
  CallBackFunction cbf;
  int start_time;
  int delay_time;
};

2. 执行循环模拟

浏览器的主线程循环逻辑伪代码如下:

void MainThread() {
  for(;;) {
    // 1. 执行普通消息队列中的一个任务 (宏任务)
    Task task = task_queue.takeTask();
    ProcessTask(task);
    
    // 2. 执行微任务队列 (本阶段由 JS 引擎控制)
    // ProcessMicrotasks(); 

    // 3. 执行延迟队列中到期的任务 (定时器任务在此处理)
    ProcessDelayTask();

    if(!keep_running) break; 
  }
}

关键点: 浏览器会在处理完一个普通宏任务后,去检查延迟队列中是否有任务到期(ProcessDelayTask),并依次执行它们。


五、 面试模拟题

Q1:为什么 setTimeout(fn, 0) 并不一定是 0ms 后执行?

参考回答:

  1. 浏览器最小限制:HTML5 规范规定,如果定时器嵌套超过 5 层,最小延迟为 4ms。
  2. Event Loop 阻塞:由于定时器任务是在 ProcessDelayTask 中处理的,如果当前的宏任务(比如一个复杂的计算循环)执行时间过长,主线程就无法及时跳转到延迟队列的检查步骤,导致定时器推迟执行。

Q2:说出以下代码的打印顺序:

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

参考回答:

1 -> 4 -> 3 -> 2。

  • 1, 4 是同步任务,直接输出。
  • 3 是微任务,在当前脚本(宏任务)执行完后立即执行。
  • 2 是下一次宏任务。

Q3:MutationObserver 属于什么任务?它有什么应用场景?

参考回答:

MutationObserver 属于微任务。它用于监听 DOM 树的变化。由于它是微任务,它会在 DOM 变化引起的多次修改全部完成后,在浏览器重新渲染之前异步执行,这比传统的 Mutation Events 性能更高,且不会阻塞主线程渲染。


六、 总结建议

  • 理解微任务的优先级:微任务是在当前宏任务结束后的“插队”行为,适合处理需要立即反馈的异步逻辑。