跳到主要内容
浏览器
导航
网页浏览器

事件循环与任务队列

解析 JavaScript 的调度模型:宏任务与微任务的划分、事件循环每一轮的执行顺序、渲染时机的插入位置,以及微任务队列的清空规则与 Node.js 事件循环的差异。

发布于 4 分钟阅读 1234 字

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. 从某个任务队列中取出一个任务并执行
  2. 执行一次微任务检查点:不断取出微任务执行,直到队列为空
  3. 判断是否到达渲染时机,若是则执行更新渲染的步骤
  4. 回到第 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 阶段执行,与定时器的先后关系取决于当前所处的阶段。

小结

事件循环把并发的输入汇入单线程的执行模型:任务提供排队单位,微任务提供当前任务结束后的清空点,渲染时机决定画面更新的位置。分析时序问题时,先确认代码属于哪一类任务,再沿「任务 → 清空微任务 → 渲染」这条顺序推演即可。

网页浏览器 · 相关文章