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

显示模式

登录
ARCHIVE DOCUMENTJS

前端基础进阶(十四):深入核心,详解事件循环机制

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/85-前端基础进阶(十四):深入核心,详解事件循环机制
本文目录10 个章节
  1. 一、先明确讨论范围
  2. 二、浏览器中的 task 与 microtask
  3. 三、浏览器事件循环练习题
  4. 四、渲染与 Event Loop 的关系
  5. 五、Node.js 中的事件循环
  6. 六、Node.js 的异步、非阻塞 I/O
  7. 七、用数组模拟任务队列
  8. 八、为什么 Promise 使用 microtask
  9. 九、常见错误结论
  10. 总结

前端基础进阶(十四):深入核心,详解事件循环机制

Category(分类): JavaScript Status: 已整理

JavaScript 的学习零散而庞杂,很多时候我们学到了一些东西,却没办法感受到进步。事件循环(Event Loop)是把同步代码、异步回调、Promise、浏览器渲染和 Node.js I/O 串起来的一条重要线索。

本文保留原文的面试题、浏览器/Node.js 对照和手写队列示例,但把“宏任务严格 FIFO”“每个宏任务后必然渲染”“Node.js 与浏览器完全相同”等过于绝对的说法改成当前规范和实现都更准确的表述。

一、先明确讨论范围

学习事件循环之前,最好已经了解:

  • 执行上下文和函数调用栈
  • 队列与迭代器
  • Promise 和 Promise reaction
  • 浏览器 task、microtask 的基本概念

JavaScript 语言规范本身定义 Promise reaction 等 job,并通过宿主钩子把 job 交给浏览器或 Node.js。HTML Standard 的事件循环和 Node.js 的 libuv 事件循环不是同一套规范,下面会分别说明。

“JavaScript 单线程”通常指一个 JavaScript agent 的脚本执行不会被多个脚本线程同时交错执行;浏览器可以有多个 agent、Worker 和内部线程。Web Worker 有自己的事件循环,不能直接操作页面 DOM。

二、浏览器中的 task 与 microtask

1. 一个适合学习的近似流程

一次浏览器事件循环可以近似理解为:

  1. 选择一个可运行的 task 并执行到结束。
  2. 执行 microtask checkpoint,直到微任务队列清空;微任务中新增的微任务也会继续执行。
  3. 在满足条件且存在渲染机会时,用户代理运行更新渲染相关步骤,例如 requestAnimationFrame 回调;是否绘制、何时合成并不是脚本可以强制保证的。
  4. 再选择后续 task。

这只是教学近似。HTML Standard 定义了多个 task source 和 task queue,同一 task source 通常保持顺序,但不同来源之间不是一个由开发者可见的全局 FIFO 队列。

2. 常见任务来源

常见 task 包括:

  • 整段 classic script 或 module script 的启动执行
  • setTimeoutsetInterval 等计时器回调
  • 用户交互事件回调
  • 网络、文件或消息完成后的回调
  • postMessage 等消息任务

常见 microtask 包括:

  • Promise reaction(thencatchfinally
  • queueMicrotask 注册的回调
  • MutationObserver 的通知回调

fetch 的网络完成不会让回调直接插进当前代码,而是通过 Promise reaction 进入微任务流程。requestAnimationFrame 是渲染更新阶段的回调,不是普通 timer,也不是“下一轮事件循环”的同义词。

3. 基础示例

console.log('global 1')

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

const promise = new Promise((resolve) => {
  console.log('promise 1')
  resolve()
})

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

console.log('global 2')

在常见浏览器环境中,输出是:

global 1
promise 1
global 2
then 1
timeout 1

Promise 构造器的 executor 是同步调用的,then 的 reaction 会在当前 script task 结束后运行,计时器回调属于后续 task。

4. 微任务会清空到稳定

console.log('start')

queueMicrotask(() => {
  console.log('microtask 1')
  queueMicrotask(() => {
    console.log('microtask 2')
  })
})

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

console.log('end')

输出为:

start
end
microtask 1
microtask 2
timer

如果微任务不断产生新的微任务,就会延迟计时器、用户交互和渲染,这种现象通常称为 microtask starvation:

function loop() {
  queueMicrotask(loop)
}

// 不要执行:它会让后续 task 很难获得机会
// loop()

三、浏览器事件循环练习题

console.log('start')

setTimeout(() => {
  console.log('timeout 1')
  Promise.resolve().then(() => {
    console.log('promise in timeout')
  })
}, 0)

Promise.resolve().then(() => {
  console.log('promise 1')
  setTimeout(() => {
    console.log('timeout 2')
  }, 0)
})

console.log('end')

常见输出为:

start
end
promise 1
timeout 1
promise in timeout
timeout 2

分析方法是:整段脚本先执行,脚本中的同步输出先完成;脚本结束后处理 promise 1;随后选择一个计时器 task。计时器 task 内排入的 Promise reaction 会在该 task 结束后的 microtask checkpoint 中执行,所以 promise in timeout 早于后面等待中的 timeout 2

不要把这个例子推广为“所有 task source 永远这样排列”。在同一浏览器、同一可见性和相近负载下,计时器的相对顺序通常容易观察;规范层面仍然不保证所有来源共享一个固定的全局队列。

四、渲染与 Event Loop 的关系

原文把“执行所有微任务后执行 UI 线程渲染”写成了固定步骤,这适合做入门图示,但不应当作浏览器的强保证:

  • 浏览器只有在存在渲染机会、页面状态允许且确实需要更新时才可能更新渲染。
  • requestAnimationFrame 回调属于更新渲染相关流程,回调时间戳表示该帧的时间基准,不是固定的 16.67ms。
  • 隐藏页面、后台标签页、低刷新率、CPU 负载和省电策略都会影响调度。
  • 样式计算、布局、绘制、光栅化和合成是实现层面的简化管线;读取布局信息可能触发同步布局,但不能把所有浏览器都描述成同一条固定流水线。

动画应使用 requestAnimationFrame

const box = document.querySelector('.box')
let start

function frame(timestamp) {
  start ??= timestamp
  const elapsed = timestamp - start
  box.style.transform = `translateX(${elapsed / 2}px)`
  requestAnimationFrame(frame)
}

requestAnimationFrame(frame)

不要写成“每个宏任务之后一定绘制一帧”,也不要用 setTimeout(..., 16) 代替帧调度。

五、Node.js 中的事件循环

Node.js 使用 libuv,并把事件循环划分为多个阶段。常见的阶段包括:

  1. timers:处理到期的 setTimeout/setInterval 回调(当前 Node 版本的具体时机应以官方文档为准)。
  2. pending callbacks:处理某些系统操作推迟的回调。
  3. idle、prepare:Node/libuv 内部阶段。
  4. poll:处理 I/O 回调,并在合适时等待新的 I/O。
  5. check:处理 setImmediate 回调。
  6. close callbacks:处理诸如 socket close 事件的回调。

Node 的每个版本、操作系统和代码所在阶段都会影响计时器与 setImmediate 的相对顺序。浏览器没有 Node 的这些阶段,也没有 process.nextTick

1. process.nextTick 与 Promise

process.nextTick() 使用 Node 特有的 next tick queue。它会在当前 JavaScript 操作完成后尽快处理,并且可能先于普通 Promise microtask;递归使用会阻塞 I/O 和其他任务:

console.log('script')

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

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

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

通常会看到:

script
nextTick
promise
immediate

这是 Node 运行时的常见顺序,不是浏览器的通用规则。Node 官方建议不要递归滥用 process.nextTick,如果需要把工作排到 check 阶段,可以考虑 setImmediate

2. setTimeoutsetImmediate

下面的顶层示例不要写死唯一输出:

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

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

在主模块顶层,两者谁先执行可能受到启动时机和系统调度影响。放在 I/O 回调中通常更容易观察到 setImmediate 先于新注册的 setTimeout(0)

const fs = require('node:fs')

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

Node.js 20 及之后的 libuv 计时器阶段细节发生过调整,因此文章中的旧版流程图和 Node 10 输出只能作为历史背景,不能当成跨版本契约。

六、Node.js 的异步、非阻塞 I/O

“异步 I/O”和“非阻塞 I/O”不是完全相同的词:

  • 阻塞/非阻塞主要描述调用与操作系统内核交互时是否等待。
  • 异步 API描述应用如何在操作完成后收到通知。
  • Node.js 让 JavaScript 回调在事件循环线程中执行,I/O 可能由操作系统异步机制或 libuv 线程池完成。

Node.js 的文件系统异步 API 可能使用线程池;网络 I/O 会结合操作系统事件通知机制。不能笼统地说“所有异步 I/O 都由一个线程池完成”,也不能说“Node 只有一个线程”。JavaScript 回调通常在主 Node 线程执行,Worker threads 和 libuv 工作线程是另外的执行资源。

const fs = require('node:fs')

fs.readFile('./test.txt', 'utf8', (error, data) => {
  if (error) {
    console.error(error)
    return
  }
  console.log(data)
})

console.log('readFile 已发起,当前脚本可以继续执行')

可以把流程概括为:Node API 注册请求 → libuv/操作系统或线程池处理 I/O → 完成事件进入事件循环可处理的队列 → JavaScript 回调在合适阶段执行。具体内部对象名称、系统 API 和线程池策略是实现细节,不能当成 ECMAScript 语义。

七、用数组模拟任务队列

下面的代码只是帮助理解“注册、排队、消费”的教学模型,并不是真正的浏览器或 Node.js 事件循环:

const tasks = []

function addTask(task) {
  tasks.push(task)
}

function flush() {
  while (tasks.length > 0) {
    const task = tasks.shift()
    task()
  }
}

addTask(() => console.log('task 1'))
addTask(() => console.log('task 2'))

setTimeout(flush, 0)

如果希望按名称分发,可以使用 Map:

const handlers = new Map()

function subscribe(name, handler) {
  const list = handlers.get(name) ?? []
  list.push(handler)
  handlers.set(name, list)
}

function publish(name, ...args) {
  for (const handler of handlers.get(name) ?? []) {
    handler(...args)
  }
}

subscribe('demo', (value) => console.log(value))
publish('demo', 42)

这个发布-订阅示例没有处理一次性监听、错误隔离、监听器删除、递归发布和内存泄漏。生产代码可以使用平台提供的 EventTarget、Node.js EventEmitter 或成熟的状态管理方案。

八、为什么 Promise 使用 microtask

Promise executor 是同步执行的,但 then/catch/finally 注册的 reaction 不会在调用方法的瞬间同步执行。把 reaction 放入 microtask,可以同时满足:

  1. 不阻塞当前同步代码。
  2. 比等待一个普通 task 更快地处理已经完成的 Promise。
  3. 保持 Promise 链的异步一致性:即使 Promise 已经 fulfilled,后续 handler 也不会同步插入当前调用栈。
const promise = Promise.resolve('value')

promise.then((value) => {
  console.log(value)
})

console.log('after then')

输出为:

after then
value

ECMAScript 只定义 Promise reaction job 的语义,具体 job queue 如何接入浏览器或 Node,是宿主负责的部分。

九、常见错误结论

  • 错误:宏任务只有一个严格 FIFO 队列。修正:HTML 有多个 task source/queue,宿主选择任务;同一来源的顺序和跨来源的选择要区分。
  • 错误:每个宏任务后一定渲染。修正:浏览器有 rendering opportunity,用户代理可以跳过或合并更新。
  • 错误:Promise 本身创建了线程。修正:Promise 是状态和异步 reaction 抽象,不创建 JavaScript 线程。
  • 错误:Node 和浏览器的微任务、定时器顺序完全相同。修正:Node 有 next tick queue、libuv 阶段和 setImmediate;版本变化也会影响可观察顺序。
  • 错误:setTimeout(fn, 0) 立即执行。修正:它只是注册一个不早于约定延迟的后续 task。

总结

理解事件循环时,先确定宿主,再确定当前回调来自哪个任务来源:

  1. 同步代码先在当前执行栈执行。
  2. Promise reaction 和 queueMicrotask 在 microtask checkpoint 中清空。
  3. 浏览器渲染是有条件的更新机会,不是每轮固定动作。
  4. Node.js 还要考虑 next tick queue、libuv 阶段、I/O 和 setImmediate
  5. 任何涉及计时器、渲染或 Node 版本的输出,都应注明运行环境,必要时实际运行验证。

作者:这波能反杀 原文链接:https://www.jianshu.com/p/12b9f73c5a4f/ 来源:简书。原文著作权归作者所有;转载或公开发布时请遵守原作者的授权和署名要求。

参考资料:

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS