技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

阿里一面:熟悉事件循环?那谈谈为什么会分为宏任务和微任务

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/131-阿里一面:熟悉事件循环?那谈谈为什么会分为宏任务和微任务。
本文目录5 个章节
  1. setTimeout 的误区
  2. Node.js 中的 setTimeout 和 setImmediate
  3. Node.js 的 Promise 与 process.nextTick
  4. 浏览器和 Node.js 示例
  5. 浏览器中的渲染机会

阿里一面:熟悉事件循环?那谈谈为什么会分为宏任务和微任务

Category(分类): JavaScript Status: 未知

配图来自原文页面,已下载并转换为本地 WebP;原始授权未核实,发布前请人工确认。

原文以“宏任务和微任务”解释事件循环。这个模型适合入门,但浏览器规范更常使用 task(任务)microtask(微任务),Node.js 还存在 libuv 的事件循环阶段和 process.nextTick 队列。下面保留原文的推导,并把不同宿主分开说明。

什么是事件循环

在讨论事件循环前,需要一些有关 JavaScript 的前置知识。

JavaScript 代码在同一个执行代理(agent)的单次 job/task 中通常是 run-to-completion:当前同步代码没有执行完之前,其他 JavaScript 回调不会插入到当前调用栈中。浏览器主线程通常一次执行一个 JavaScript 回调,但 Worker 可以在独立的执行代理中运行代码;因此“JavaScript 是单线程”只能作为主线程的简化模型,不能概括所有宿主。

JavaScript 任务大致分为同步工作和异步协调:同步代码会连续执行;网络、定时器、文件 I/O 等工作由宿主环境或操作系统处理,完成后再把回调安排到后续任务中。异步并不代表 JavaScript 引擎同时执行同一段代码,而是把等待时间交给宿主,并在合适时机继续执行回调。

原文配图 131-01

如果没有异步调度,JavaScript 在等待网络或文件操作时就只能阻塞当前执行流程;而通过宿主提供的非阻塞 API,当前代码可以先继续运行,I/O 完成后再执行对应回调:

原文配图 131-02

事件循环负责协调任务、微任务、I/O 和(浏览器中的)渲染机会。它不是 JavaScript 语言独有的一条“总队列”,不同宿主的细节并不相同。

任务和微任务

“宏任务”是中文技术文章中常见的俗称;在 HTML 标准里通常称为 task。一个 task 执行完后,宿主会执行 microtask checkpoint,清空当前 microtask 队列,然后才可能进入渲染机会或下一个 task。

常见的 task 来源包括:

  • classic script 或模块脚本的执行;
  • setTimeoutsetInterval 到期后的回调;
  • 用户交互、网络事件和部分消息事件;
  • 浏览器的其他任务源。

常见的 microtask 来源包括:

  • Promise reaction,也就是 thencatchfinally 的处理函数;
  • queueMicrotask()
  • MutationObserver 回调。

requestAnimationFrame 不应简单归为普通“宏任务”:它由浏览器安排在下一次渲染前的回调时机。process.nextTick 是 Node.js 特有的队列,也不属于 ECMAScript Promise microtask;Node.js 会在继续事件循环前优先处理它。

微任务具有“当前任务结束后尽快清空”的特点,因此可以观察到它插队到下一个 task 之前:

Promise.resolve().then(() => {
  console.log('第一个回调函数:微任务 1')
  setTimeout(() => {
    console.log('第三个回调函数:任务 2')
  }, 0)
})

setTimeout(() => {
  console.log('第二个回调函数:任务 1')
  Promise.resolve().then(() => {
    console.log('第四个回调函数:微任务 2')
  })
}, 0)

在浏览器和现代 Node.js 的常见运行方式中,输出通常是:

第一个回调函数:微任务 1
第二个回调函数:任务 1
第四个回调函数:微任务 2
第三个回调函数:任务 2

这里“第四个”先于“第三个”并不是因为所有微任务都能抢占正在执行的 task,而是因为任务 1 执行结束后,运行时先清空它产生的微任务,之后才取出任务 2。

setTimeout 的误区

setTimeout(fn, delay)delay 是一个最小等待阈值,不是精确执行时间。阈值到了以后,回调只是具备了被宿主安排的资格;当前 JavaScript 仍在执行、其他任务排队、系统调度、页面后台节流等情况都可能让它更晚执行。

setTimeout(() => {
  console.log('定时器回调')
}, 0)

console.log('同步代码')

通常先输出 同步代码,再输出 定时器回调setTimeout(fn, 0) 也不会同步执行。

“默认最小时间固定为 4ms”是过度简化。浏览器对嵌套定时器、后台页面等有不同的最小延迟和节流规则,Node.js 也有自己的定时器实现;在所有环境和所有调用层级中都不能把 4ms 当成绝对保证。

Node.js 中的 setTimeoutsetImmediate

Node.js 的事件循环由 libuv 协调,常见阶段包括:

  1. timers:处理 setTimeoutsetInterval 达到阈值的回调;
  2. pending callbacks:处理部分延后的系统 I/O 回调;
  3. idle、prepare:Node.js 内部阶段;
  4. poll:获取和处理 I/O 事件,必要时等待;
  5. check:处理 setImmediate 回调;
  6. close callbacks:处理部分关闭事件。

不同 Node.js 和 libuv 版本可能调整 timers 与 poll 的相对时机。Node.js 20 使用的 libuv 1.45 之后,定时器阶段在每轮 poll 后的安排有所变化,所以旧文章中的“六个宏任务队列按固定优先级执行”并不是现代 Node.js 的准确模型。

如果两个 API 都在主模块中注册,先后顺序可能受进程调度影响:

setTimeout(() => {
  console.log('setTimeout')
}, 0)

setImmediate(() => {
  console.log('setImmediate')
})

如果它们在 I/O 回调中注册,setImmediate 通常会先于 setTimeout(…, 0),因为 check 阶段紧跟在 poll 阶段之后:

const { readFile } = require('node:fs')

readFile(__filename, () => {
  setTimeout(() => console.log('setTimeout'), 0)
  setImmediate(() => console.log('setImmediate'))
})

上面的示例是 Node.js CommonJS 代码;Node.js ESM 可以改用 import。不要把 setImmediate 当作浏览器标准 API。

原文配图 131-03

Node.js 的 Promise 与 process.nextTick

process.nextTick() 不在 libuv 阶段图中,它会在当前操作完成、JavaScript 调用栈清空后优先处理。Promise reaction 属于 microtask。Node.js 的常见顺序是先清空 nextTick 队列,再处理 Promise microtask:

console.log('同步开始')

process.nextTick(() => {
  console.log('nextTick')
})

queueMicrotask(() => {
  console.log('queueMicrotask')
})

Promise.resolve().then(() => {
  console.log('promise')
})

setImmediate(() => {
  console.log('setImmediate')
})

console.log('同步结束')

Node.js 中常见输出:

同步开始
同步结束
nextTick
queueMicrotask
promise
setImmediate

递归安排大量 process.nextTick 或 microtask 可能让 I/O 和渲染长期得不到机会,因此不能把“优先执行”当作性能优化手段。

浏览器和 Node.js 示例

下面的代码在浏览器和现代 Node.js 中都能帮助观察 Promise 处理函数何时执行:

console.log('Script开始')

setTimeout(() => {
  console.log('第一个回调函数,任务 1')
  Promise.resolve().then(() => {
    console.log('第四个回调函数,微任务 2')
  })
}, 0)

setTimeout(() => {
  console.log('第二个回调函数,任务 2')
  Promise.resolve().then(() => {
    console.log('第五个回调函数,微任务 3')
  })
}, 0)

Promise.resolve().then(() => {
  console.log('第三个回调函数,微任务 1')
})

console.log('Script结束')

常见输出是:

Script开始
Script结束
第三个回调函数,微任务 1
第一个回调函数,任务 1
第四个回调函数,微任务 2
第二个回调函数,任务 2
第五个回调函数,微任务 3

旧版 Node.js(尤其 Node 11 之前)对 timers 阶段中多个回调之间的 microtask 检查点与现代版本不同,可能先执行多个定时器再处理其中的 Promise。阅读历史面试题时要注明 Node 版本,不要把旧版本输出当作当前 Node.js 的固定答案。

浏览器中的渲染机会

浏览器通常在一个 task 结束、microtask 队列清空后,才会寻找合适的渲染机会;但这不是“每个 task 后必然绘制一次”。如果 microtask 不断生成新的 microtask,可能造成 microtask starvation,阻塞渲染和用户交互:

let count = 0

function schedule() {
  queueMicrotask(() => {
    count++
    console.log('microtask', count)
    if (count < 3) schedule()
  })
}

schedule()
setTimeout(() => console.log('下一个 task'), 0)

动画逻辑通常使用 requestAnimationFrame,网络请求使用 fetch 并结合 AbortController 取消,长时间计算则考虑拆分任务或移到 Worker。

结语

事件循环中的任务之所以常被分为“宏任务”和“微任务”,是为了表达不同的调度时机:当前 task 结束后,Promise 和其他 microtask 通常会在下一个 task 前被清空,从而让状态变化尽快可见。但这并不意味着存在一个跨浏览器、Node.js、Worker 的统一“宏任务优先级表”。

可以用下面的规则分析大多数题目:

  1. 先执行当前同步代码,当前调用栈不会被异步回调打断。
  2. 当前 task/job 结束后,清空对应的 microtask 队列。
  3. 浏览器再决定是否渲染,然后进入下一个 task;Node.js 按 libuv 阶段继续运行。
  4. Node.js 额外先处理 process.nextTick,并且 setImmediatesetTimeout 的顺序要结合调用位置和版本判断。
  5. 定时器的时间是阈值,不是精确执行时间。

参考资料

  1. HTML Standard:Event loops
  2. MDN:微任务指南
  3. MDN:JavaScript 执行模型
  4. Node.js:The Node.js Event Loop
  5. Node.js:Understanding process.nextTick()

作者:前端私教年年

链接:https://juejin.cn/post/7073099307510923295

来源:稀土掘金。著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS