JavaScript 的执行线程只有一个调用栈,同一时刻只能运行一段代码。但页面需要同时处理定时器、网络回调、用户输入与动画,这些任务必须被排队后依次执行。负责排队与调度的模型就是事件循环(Event Loop)。
理解这个模型的关键,是分清任务被放进哪个队列,以及每一轮循环在什么时候检查哪个队列。
任务与微任务
规范把待执行的代码划分为两类:
- 任务(task),也常被称为宏任务:脚本的初次执行、定时器回调、事件回调、网络回调、
postMessage消息等,各自归属不同的任务源,进入对应的任务队列。 - 微任务(microtask):
Promise的回调、queueMicrotask注册的函数、MutationObserver的通知等。
两者的差别不在于长短,而在于检查时机:一个任务执行完成后,微任务队列会被完整清空;而下一个任务必须等到本轮微任务全部处理完毕才会开始。
console.log('A');
setTimeout(() => console.log('D'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('B');
// 输出顺序:A B C D
上例中 A、B 属于当前任务(脚本执行)的同步部分,微任务 C 在当前任务结束后立即执行,定时器回调 D 属于另一个任务,因此排在 C 之后。
一轮循环的执行顺序
事件循环的每一轮大致包含以下步骤:
- 从某个任务队列中取出一个任务并执行
- 执行一次微任务检查点:不断取出微任务执行,直到队列为空
- 判断是否到达渲染时机,若是则执行更新渲染的步骤
- 回到第 1 步
从哪个任务队列取任务由实现决定,规范只要求每个队列内部保持先进先出。这意味着当多个队列都有待处理任务时,它们的相对顺序并不固定,不同实现在同一段代码上可能给出不同结果。
渲染时机
更新渲染不是每个任务之后都会发生,而是由浏览器按显示刷新节奏决定。一次更新渲染大致按下列顺序进行:
- 执行
requestAnimationFrame注册的回调,回调中会执行一次微任务检查点 - 计算样式与布局
- 绘制并提交合成
- 布局完成后投递观察者通知,例如
IntersectionObserver的回调
因此 requestAnimationFrame 适合安放写样式的操作:此时读取与写入都发生在同一帧的同一阶段,能减少重复的布局计算。相关内容可与 渲染管线 中关于强制同步布局的部分对照阅读。
微任务的清空规则与任务饥饿
清空微任务队列时,如果在执行过程中又产生了新的微任务,它们会被追加到同一队列并继续执行,直到队列真正为空。这一过程不会中途让出执行权。
| 现象 | 成因 | 表现 |
|---|---|---|
| 微任务持续排队 | 微任务中不断注册新的微任务 | 渲染被推迟,页面无法更新 |
| 任务持续排队 | 任务中不断注册新的任务 | 队列延迟增大,回调错过预定时刻 |
| 定时器延迟变大 | 前序任务长时间占用线程 | 回调执行时刻晚于设定时刻 |
规范规定嵌套层级超过 5 层的定时器至少延迟 4 毫秒,多数实现也在此基础上做时间对齐。即便如此,单个耗时较长的任务仍会把后续所有任务推后,因此把长任务切分为多个较短的任务,是让渲染及时发生的常见做法。
与 Node.js 事件循环的差异
Node.js 同样有事件循环,但它的轮次被划分为固定阶段,并且没有更新渲染这一步:
| 对比项 | 浏览器 | Node.js |
|---|---|---|
| 轮次结构 | 取一个任务后清空微任务 | 按 timers、pending、poll、check 等阶段推进 |
| 渲染步骤 | 每轮可能更新渲染 | 无 |
| 高优先级队列 | 无对应概念 | process.nextTick 队列,先于微任务执行 |
| 清空时机 | 每个任务之后 | 每个回调之后及阶段切换之间 |
在 Node.js 中,process.nextTick 的回调优先于 Promise 回调;setImmediate 在 check 阶段执行,与定时器的先后关系取决于当前所处的阶段。
小结
事件循环把并发的输入汇入单线程的执行模型:任务提供排队单位,微任务提供当前任务结束后的清空点,渲染时机决定画面更新的位置。分析时序问题时,先确认代码属于哪一类任务,再沿「任务 → 清空微任务 → 渲染」这条顺序推演即可。