前言
为什么 JavaScript 是单线程的却能处理异步 IO?为什么 setTimeout 并不总是准时?本文将从宏观的执行栈、任务队列,一直深入到浏览器底层的任务调度逻辑,带你彻底看透事件循环。
一、 为什么需要事件循环?
JavaScript 的核心是单线程的,这意味着它只有一个主线程来处理 DOM 解析、样式计算、脚本执行等。如果某个任务耗时过长,页面就会“卡死”。为了协调同步任务与异步任务(输入事件、网络请求、定时器),浏览器引入了事件循环系统来统一调度和处理这些任务。
二、 核心组件:执行栈与任务队列
1. 执行栈 (Execution Stack)
当多个方法被调用的时候,因为js是单线程的,所以每次只能执行一个方法,于是这些方法被排到了一个单独的地方,这个地方就是执行栈。执行栈里面执行的都是同步的操作。
2. 事件队列 (Task Queue)
- 在js执行过程中如果遇到异步事件(如 Ajax、定时器),就会首先将这个异步事件交给对应的浏览器模块(如网络进程),继续执行执行栈里面的任务。
- 当异步事件返回结果后,js不会立即执行这个回调,会将事件加入到事件队列中,只有当执行栈里面的全部执行完以后,主线程才会去查找事件队列中是否有任务。
- 如果有,那么主线程会取出事件队列里面排在最前面的事件,将这个事件对应的回调加入到执行栈中,然后执行其中的同步代码。然后在继续观察执行栈里面是否有任务,依次反复...就形成了一个无限的循环。
- 这就是这个过程被称为事件循环(Event loop)的原因。
循环逻辑:
- 检查执行栈是否为空。
- 若为空,从事件队列头部取出一个任务推入执行栈。
- 循环往复。
三、 异步任务的“等级”:宏任务与微任务
并非所有的异步任务优先级都一样。在同一次循环中,微任务永远在下一次宏任务之前执行!!!
| 类型 | 包含任务 | 执行时机 |
|---|---|---|
| 宏任务 (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 后执行?
参考回答:
- 浏览器最小限制:HTML5 规范规定,如果定时器嵌套超过 5 层,最小延迟为 4ms。
- 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 性能更高,且不会阻塞主线程渲染。
六、 总结建议
- 理解微任务的优先级:微任务是在当前宏任务结束后的“插队”行为,适合处理需要立即反馈的异步逻辑。