JavaScript 事件循环解释:调用堆栈、微任务和计时器以及示例

  1. 首页
  2. 博客
  3. 软件开发

软件开发

JavaScript 事件循环解释:调用堆栈、微任务和计时器示例

为什么 setTimeout 为零仍然最后运行?一个线程如何能够同时处理这么多数据?了解事件循环的实际工作原理,使用我们运行的代码对其进行测试,并解决经典的面试问题。

阅读约 9 分钟
JavaScript 事件循环解释:调用堆栈、微任务和计时器以及示例

要点速览

  • JavaScript 在单线程上运行您的代码,一次运行一段代码。
  • 当调用堆栈为空时,首先运行所有微任务,然后运行一个任务。
  • setTimeout(fn, 0) 意思是“在最短延迟后尽快”,而不是“现在”。
  • 长时间的同步工作会阻塞一切,因此请将其拆分或移至工作人员。

使用 setTimeout 和一个承诺运行几行,输出顺序可能会让您感到惊讶。 JavaScript 事件循环 解释了原因。它是一种让单线程语言处理计时器、网络调用和点击而不会冻结的机制。

一旦你理解了它,一整类令人困惑的错误就开始变得有意义了。我们在 Node.js 24 上运行每个示例,输出来自该运行。核心排序规则也适用于浏览器,我们指出 Node.js 的不同之处。如果您已经了解 JavaScript Promise 的基础知识,您将从中获得更多。

为什么存在事件循环

JavaScript 在一个主线程上执行代码,因此一次只运行其中的一部分。如果它只是等待网络请求,页面将冻结,直到响应返回。

所以 JavaScript 不会等待。缓慢的工作被交给主机环境,即浏览器或 Node.js,它们可以在后台处理它。当该工作完成后,回调将被放置在队列中。事件循环的工作是在堆栈空闲时将排队的回调移至堆栈上。

想象一个厨师独自工作。厨师将菜肴放入烤箱并设置计时器,而不是盯着它。当计时器响起时,任务就会出现在列表中,厨师在完成当前手头的任务后会选择它。

运动部件

部分 它的作用 示例
调用栈 跟踪当前正在运行的函数 你的同步代码
主机API 在代码之外工作 计时器、fetch、文件读取
任务队列 保存准备运行的回调 setTimeout、点击次数、I/O
微任务队列 持有更高优先级的回调 .then、await、queueMicrotask
事件循环 决定接下来运行什么 连续运行

任务有时称为宏任务。严格来说,任务源可能有多个,具体细节因环境而异。

规则

  1. 运行所有同步代码,直到调用堆栈为空。
  2. 运行所有微任务,包括在此过程中添加的任何新任务。
  3. 在浏览器中,如果更新到期则呈现更新。
  4. 从队列中取出一个任务并运行它。
  5. 返回步骤 2。

最重要的细节:所有 微任务,然后是一个 任务。大多数事件循环难题都是通过应用该单一规则来解决的。

查看订单

console.log("1 sync");
setTimeout(() => console.log("5 timeout"), 0);
Promise.resolve().then(() => console.log("3 promise"));
queueMicrotask(() => console.log("4 microtask"));
console.log("2 sync");

// 1 sync
// 2 sync
// 3 promise
// 4 microtask
// 5 timeout

这就是发生的事情。两条 console.log 线是同步的,因此它们先运行。 Promise 回调和 queueMicrotask 进入微任务队列,一旦堆栈为空,它们就会按照添加的顺序运行。计时器回调必须等待任务轮流,即使其延迟为零。

微任务在定时器之间运行

每个任务完成后,微任务队列都会再次清空。这个例子证明了这一点:

setTimeout(() => {
  console.log("timer 1");
  Promise.resolve().then(() => console.log("promise inside timer 1"));
}, 0);
setTimeout(() => console.log("timer 2"), 0);

// timer 1
// promise inside timer 1
// timer 2

在第一个计时器期间创建的 Promise 在第二个计时器之前运行。现代浏览器和 Node.js 11 及更高版本的行为方式都是如此。

哪里适合等待

await 将一个函数一分为二。它之前的所有内容都是同步运行的,而之后的所有内容都被安排为微任务。这让很多人感到惊讶:

async function task() {
  console.log("A before await");
  await null;
  console.log("C after await");
}

task();
console.log("B after calling task");

// A before await
// B after calling task
// C after await

async 函数不会在后台启动。它在同一个线程上运行,直到到达第一个 await。这甚至适用于等待不是真正承诺的事情,例如 null。有关此语法的更多信息,请参阅我们的 JavaScript async/await 指南。

阻塞循环

当同步代码运行时,其他任何东西都不能运行。为了说明这一点,我们将计时器设置为 0 毫秒,然后让线程保持忙碌半秒:

const start = Date.now();
setTimeout(() => console.log("timer ran after", Date.now() - start, "ms"), 0);
while (Date.now() - start < 500) {}

// timer ran after about 500 ms

计时器的延迟是最小值,而不是保证。它仅在堆栈空闲时运行。在网页上,像这样的长循环意味着点击没有响应并且动画停止。在服务器上,每个其他请求都必须等待,这就是为什么一个缓慢的同步调用可能会损害 Node.js 进程的所有用户。

阻塞的常见原因包括巨大的循环、解析非常大的 JSON、请求处理程序中的同步文件函数(如 readFileSync)以及灾难性的正则表达式。

提示: 在 Chrome DevTools 中,性能面板将长度超过 50 毫秒的任务标记为长任务。这是一种快速查找导致页面速度缓慢的原因的方法。

保持积极响应

对于繁重的工作,给事件循环一个喘息的机会。一种方法是时不时地暂停计时器,这样可以运行其他任务:

const items = [0, 1, 2, 3, 4];

async function processAll() {
  for (const item of items) {
    if (item === 2) await new Promise((r) => setTimeout(r, 0));
    console.log("item", item);
  }
}

setTimeout(() => console.log("other work gets a turn"), 0);
processAll();

// item 0
// item 1
// other work gets a turn
// item 2
// item 3
// item 4

另一个计时器在中间运行,因为我们完成了真正的任务。真正的代码每隔这么多的项目或毫秒就会产生一次。对于真正需要大量 CPU 的工作(例如图像处理),请使用浏览器中的 Web Worker 或 Node.js 中的工作线程,它们在单独的线程上运行。

微任务饥饿

由于循环在继续之前完全清空微任务队列,因此不断调度更多的微任务可以停止其他一切。我们用 100,000 个链式微任务对此进行了测试:

let count = 0;
let timerFired = false;
setTimeout(() => { timerFired = true; }, 0);

function spin() {
  count++;
  if (count < 100000) queueMicrotask(spin);
  else console.log("timer fired during microtasks?", timerFired);
}
queueMicrotask(spin);

// timer fired during microtasks? false

在所有任务都完成之前,计时器无法运行。如果该链永远不会结束,计时器将永远不会触发,并且在浏览器中页面永远不会重新绘制。请注意,在已解决的 Promise 上使用 await 进行屈服在这里没有帮助,因为它只是添加了另一个微任务。你需要一个真正的任务,比如计时器,来给其他人轮到时间。

在浏览器中渲染

浏览器通常有机会在任务之间进行绘制。这解释了一个常见的错误:

spinner.hidden = false;
doHeavyWork();              // blocks the thread
spinner.hidden = true;

微调器永远不会出现,因为浏览器没有机会在这些行之间进行渲染。要解决这个问题,请在繁重的工作开始之前让框架通过,或者将该工作移交给工人。对于更改视觉效果的代码,requestAnimationFrame 在下一次绘制之前运行,这使其成为动画的正确工具。

Node.js 差异

Node.js 有自己的事件循环,有几个阶段,包括计时器、I/O 轮询和 setImmediate。它还有 process.nextTick,在自己的队列中运行。以下是我们从异步函数内部运行它们时看到的结果:

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));

// promise
// nextTick
// immediate
// timeout

许多文章指出,nextTick 总是胜过承诺,而计时器胜过 setImmediate。我们的结果表明事情没那么简单。我们的代码已经在微任务中运行,因此承诺队列首先进行。已知 setTimeout 和 setImmediate 的顺序会根据它们的调用位置而变化。因此,不要编写依赖于这些确切顺序的代码。如果一件事必须接连发生,请将它们明确地链接起来。

面试测验

在阅读答案之前尝试计算出顺序:

console.log("a");
setTimeout(() => console.log("b"), 0);
Promise.resolve().then(() => {
  console.log("c");
  setTimeout(() => console.log("d"), 0);
  Promise.resolve().then(() => console.log("e"));
});
console.log("f");

我们的运行打印了 a, f, c, e, b, d。原因如下:

  1. a 和 f 是同步的,因此它们首先运行。
  2. 堆栈为空,因此微任务在同一轮中运行:c,然后是新的微任务 e。
  3. 现在有一个任务:计时器b,它首先排队。
  4. 然后是下一个任务:定时器d,它在微任务期间排队。

常见错误

  • 认为setTimeout是正确的。延迟只是最小的。
  • 认为async意味着另一个线程。在等待之间,异步代码仍然在主线程上运行。
  • 使用睡眠来修复排序。等待实际情况要可靠得多,这也适用于片状测试。
  • 将forEach与await一起使用。它不会在项目之间等待。请参阅 JavaScript 数组方法。
  • 在服务器处理程序中执行繁重的同步工作。 一个请求可能会延迟所有其他请求。

最后的话

请记住核心规则:运行堆栈直到其为空,运行所有微任务,然后运行一个任务,然后重复。其他一切都随之而来。为了让它坚持下去,请编写您自己的上述测验版本,在纸上预测输出,然后运行它。犯错并理解原因比任何图表都更能教会你。

常见问题

JavaScript 中的事件循环是什么?

这是当调用堆栈为空时运行排队回调的机制。它允许单线程 JavaScript 处理计时器、输入和网络响应而不会阻塞。

微任务和任务有什么区别?

微任务(例如 Promise 回调)都会在堆栈清空后立即运行。任务(例如计时器)一次运行一个,并在每个任务之后耗尽微任务。

为什么 setTimeout 为 0 不会立即运行?

它的回调必须等待任务轮流。所有同步代码和微任务都会首先运行,并且延迟是最小的而不是精确的时间。

JavaScript 是单线程的吗?

您的主要 JavaScript 代码在一个线程上运行。浏览器和 Node.js 使用其他线程进行某些后台工作,而工作线程可让您并行运行代码。

wait 会阻塞事件循环吗?

不会。await 仅暂停该异步函数,其他代码可以同时运行。不过,它之前或之后的长同步代码仍然可以阻塞循环。

继续学习

与Stack Quora合作

需要测试、WordPress 或 SEO 方面的帮助?

我们帮助团队交付更好的软件并发展他们的网站。告诉我们您正在做什么,我们将建议明确的下一步。

继续阅读

更多指南 测试、人工智能和现代开发

喜欢这篇文章吗?选择一个主题并直接跳至我们最新的教程。

人工智能

人工智能幻觉解释:语言模型为何会产生幻觉以及如何减少它

人工智能幻觉解释:它们是什么,为什么语言模型产生它们,常见类型,如何通过基础和提示来减少它们,以及我们进行的简单检查。

阅读文章 →
DevOps

环境变量解释:.env 文件、秘密和配置正确完成

使用我们运行的 Python 示例解释环境变量:读取和设置它们、为什么每个值都是字符串、.env 文件、进程继承、秘密和验证。

阅读文章 →
软件开发

API 速率限制解释:算法、标头和处理 429 错误

API 速率限制通过我们运行的模拟进行了解释:令牌桶、固定窗口、滑动窗口、429 响应、速率限制标头、带回退的重试和测试限制。

阅读文章 →
测试自动化

Python 中的模拟:unittest.mock、补丁以及导致测试谎言的错误

使用unittest.mock 在Python 中进行模拟,经过测试:模拟和return_value、side_effect、修补使用名称的位置、spec 和autospec、模拟日期以及常见错误。

阅读文章 →
软件测试

代码覆盖率解释:行覆盖率与分支覆盖率以及 100% 的真正含义

用真实的coverage.py运行来解释代码覆盖率:行、分支和条件覆盖率,阅读报告,为什么100%覆盖率仍然会错过错误,以及合理的目标。

阅读文章 →
数据库与 SQL

SQL 中的 DELETE、TRUNCATE 和 DROP:差异、回滚以及何时使用它们

DELETE、TRUNCATE 和 DROP 通过经过测试的 SQL 进行了解释:各自删除什么、回滚、触发器、身份重置、速度,以及 PostgreSQL、MySQL、SQL Server 和 SQLite 有何不同。

阅读文章 →
数据库与 SQL

SQL 视图解释:创建、使用和避免陷阱(附示例)

SQL 视图通过我们运行的查询进行解释:创建视图、实时结果、隐藏列、性能、可更新和物化视图、破坏的依赖关系和最佳实践。

阅读文章 →
软件开发

TypeScript 接口与类型:真正的差异,使用编译器进行测试

TypeScript 接口与类型,使用真实编译器进行检查:两者都可以做什么,只有一个可以做什么,声明合并,扩展与交集以及要遵循的简单规则。

阅读文章 →
软件开发

Python *args 和 **kwargs 解释:灵活的函数和清晰的示例

Python *args 和 **kwargs 用我们运行的代码进行了解释:打包和解包、参数顺序、仅关键字和仅位置参数、装饰器和常见错误。

阅读文章 →
软件开发

Python Lambda 函数:语法、实际用途以及何时不使用它们

Python lambda 函数用我们运行的代码进行了解释:语法、使用键排序、映射和过滤器、闭包、后期绑定陷阱,以及何时普通 def 是更好的选择。

阅读文章 →
人工智能

什么是人工智能代理?它们是如何工作的,只需运行一个简单的代理循环

什么是人工智能代理?关于代理循环、工具、内存和护栏的简单指南,以及我们运行的一个小型 Python 循环、真正的风险以及何时简单的聊天机器人是更好的选择。

阅读文章 →
DevOps

监控与可观察性:真正的区别,包括日志、指标和跟踪

监控与可观察性解释:已知和未知的故障、日志、指标和跟踪、检查事件演练、黄金信号、OpenTelemetry 和有用的警报。

阅读文章 →
DevOps

Docker Volumes 与 Bind Mounts:以正确的方式保留容器数据

Docker 卷与绑定挂载解释:为什么容器数据消失、命名卷、绑定挂载、tmpfs、Compose 示例、备份、权限以及何时使用它们。

阅读文章 →
测试自动化

TDD 与 BDD:主要区别、以两种方式构建的一个功能以及何时使用每种方式

TDD 与 BDD 的解释是通过一种双向构建的折扣功能:使用我们运行的代码进行红绿重构、Given-When-Then 场景、工具、权衡以及何时使用每种方案。

阅读文章 →
软件测试

用户验收测试 (UAT):流程、清单和真实示例

用户验收测试解释:谁运行它、它与 QA 有何不同、七步流程、UAT 测试用例模板、清单、签核标准和常见错误。

阅读文章 →
数据库与 SQL

SQL NULL 值解释:IS NULL、COALESCE、NULLIF 和要避免的陷阱

通过我们运行的查询解释了 SQL NULL 值:三值逻辑、IS NULL、COALESCE、NULLIF,聚合、连接和排序如何处理 NULL,以及它悄悄引起的错误。

阅读文章 →
数据库与 SQL

SQL CASE WHEN 语句:语法、实例和常见错误

SQL CASE WHEN 解释了我们运行的查询:简单和搜索表单、条件顺序、SUM 透视、COUNT 技巧、自定义排序、更新和要避免的 NULL 陷阱。

阅读文章 →
软件开发

什么是智威汤逊?通过工作示例解释 JSON Web 令牌

什么是智威汤逊? JSON Web Tokens 解释:这三个部分,在我们运行的代码中构建和篡改的令牌,JWT 与会话,存储,刷新令牌和安全错误。

阅读文章 →
软件开发

JavaScript 事件循环解释:调用堆栈、微任务和计时器以及示例

JavaScript 事件循环用我们运行的代码进行了解释:调用堆栈、任务和微任务队列、为什么 setTimeout 0 不是立即执行、阻塞、饥饿和面试测验。

阅读文章 →
软件开发

Python 生成器和 Yield 解释:通过真实示例节省内存

Python 生成器和yield 用我们运行的代码进行了解释:它们如何暂停和恢复、测量内存节省、管道、无限序列、yield from、发送和常见陷阱。

阅读文章 →
人工智能

什么是嵌入?向量和余弦相似度简单解释

什么是嵌入?关于向量、余弦相似度和语义搜索的简单语言指南,其中包含我们运行的一个小示例、常见用途、选择模型和已知限制。

阅读文章 →
DevOps

蓝绿部署与金丝雀部署:差异、权衡以及如何选择

蓝绿部署与金丝雀部署解释:每种部署的工作原理、成本、风险和回滚、我们测试的稳定用户分桶、数据库迁移、滚动更新以及如何选择。

阅读文章 →
测试自动化

不稳定的测试:造成这些问题的原因以及如何彻底解决它们

我们运行的演示解释了不稳定的测试:主要原因、如何确认和诊断、正确的时间修复、共享状态和日期以及隔离过程。

阅读文章 →
软件测试

负载测试与压力测试:差异、指标和 k6 示例

负载测试与压力测试的解释:各自证明什么、峰值和浸泡测试、重要的指标、为什么平均值会误导、k6 个示例以及如何计划测试。

阅读文章 →
软件测试

测试用例与测试场景:主要区别、示例以及何时使用它们

测试用例与测试场景的解释:清晰的定义、并排的表格、从场景到案例的密码重置功能、模板以及何时使用每个功能。

阅读文章 →
数据库与 SQL

SQL 查询优化:读取执行计划并修复慢速查询(带计时)

对一百万行进行 SQL 查询优化测试:读取执行计划、添加正确的索引、修复不可控制的过滤器、使用复合索引和键集分页。

阅读文章 →
数据库与 SQL

SQL UNION 与 UNION ALL:差异、示例和性能测试

SQL UNION 与 UNION ALL 通过我们运行的查询进行了解释:重复、都遵循的规则、真实的数据错误、测量的性能、INTERSECT 和 EXCEPT 以及何时使用它们。

阅读文章 →
软件开发

JavaScript Fetch API 教程:GET、POST、错误处理和超时

针对本地服务器测试的 JavaScript Fetch API 教程:GET 和 POST、为什么不抛出 404、可重用的帮助程序、查询字符串、超时、CORS 和常见错误。

阅读文章 →
软件开发

Python 文件处理:读取、写入、CSV、JSON 和 pathlib 以及示例

使用我们运行的代码解释了 Python 文件处理:打开模式、使用、读取大文件、CSV、JSON、pathlib、二进制文件、安全写入、错误处理和常见错误。

阅读文章 →
软件开发

Python 类和对象:带有实际示例的 OOP 教程

用我们运行的代码解释 Python 类和对象:__init__、方法、类与实例属性、继承、属性、dunder 方法、数据类和常见错误。

阅读文章 →
人工智能

什么是大语言模型?法学硕士如何运作,在没有炒作的情况下进行解释

什么是大语言模型?法学硕士用简单的语言解释:标记、嵌入、注意力、训练、温度和上下文窗口、一个微小的下一个单词演示和真正的限制。

阅读文章 →
DevOps

适合初学者的 Linux 命令:带有示例的基本终端指南

适合初学者的 Linux 命令,按任务分组:导航、文件、查看日志、grep、管道、权限、进程和档案,并附有实用的日志分析示例。

阅读文章 →
测试自动化

Selenium 与 Playwright:主要区别、代码比较以及选择

Selenium 与 Playwright 比较:架构、等待、浏览器和语言支持、调试、两种工具中的相同测试、迁移建议以及如何选择。

阅读文章 →
软件测试

等价划分:通过实例解释测试设计技术

等效分区解释:如何找到有效和无效分区,年龄、密码和优惠券的工作示例,我们运行的测试,以及为什么将其与边界配对。

阅读文章 →
软件测试

软件测试中的严重性与优先级:差异、矩阵和实例

软件测试中的严重性与优先级解释:清晰的定义、带有示例的四向矩阵、级别尺度、谁设置每个级别以及如何运行错误分类。

阅读文章 →
数据库与 SQL

SQL 子查询教程:类型、相关子查询和 EXISTS 以及示例

SQL 子查询教程,其中包含我们运行的查询:标量、IN、FROM 和 SELECT 子查询、相关子查询、EXISTS、NOT IN NULL 陷阱以及子查询与 JOIN 与 CTE。

阅读文章 →
数据库与 SQL

SQL 事务和 ACID 解释:COMMIT、ROLLBACK 和隔离级别

SQL 事务和 ACID 通过我们运行的银行转账进行了解释:COMMIT、ROLLBACK、保存点、锁、隔离级别、脏读、死锁和最佳实践。

阅读文章 →
软件开发

Git merge 与 rebase 的区别:示例及如何选择

用真实的 log 输出、对比表、rebase 黄金法则和安全的团队工作流,讲清 Git merge 与 rebase 的区别。

阅读文章 →
软件开发

Python 列表推导式:语法、示例与何时避免使用

Python 列表推导式详解:语法、过滤、if-else、嵌套循环、实测耗时,以及何时普通 for 循环更合适。

阅读文章 →
数据库与 SQL

SQL JOIN 详解:附示例(INNER、LEFT、RIGHT、FULL)

通过示例表和查询,一步步讲解 SQL JOIN:INNER、LEFT、RIGHT、FULL、CROSS 和 SELF,并介绍常见错误与性能技巧。

阅读文章 →