当前位置: 首页 > news >正文

深入解析JS事件循环机制 (Event Loop)

JavaScript是一门单线程语言,这意味着它在任意时刻只能执行一个任务。然而,在浏览器环境中,我们经常需要处理耗时的操作,如网络请求、定时器和用户交互,而不能因此阻塞主线程,影响用户体验。这就要归功于JavaScript的并发模型核心——事件循环(Event Loop)。

什么是事件循环?

事件循环是JavaScript运行时环境中一个持续不断的过程,它负责执行代码、收集和处理事件以及执行队列中的子任务。简单来说,事件循环就像一个永不疲倦的调度员,时刻监控着任务队列,并按照特定顺序将任务推入执行栈中执行。这种机制使得JavaScript能够在单线程的限制下实现非阻塞的异步操作。

事件循环的核心组成部分

要理解事件循环,首先需要了解其几个核心概念:

  • 调用栈(Call Stack): 一个后进先出(LIFO)的数据结构,用于追踪函数的调用。当一个函数被调用时,它会被推入栈中;当函数执行完毕返回时,它会从栈中被弹出。
  • Web APIs(或Node.js APIs): 由浏览器或Node.js环境提供,用于处理异步操作,例如setTimeoutsetInterval、DOM事件、fetch请求等。这些API在后台处理耗时任务,而不会阻塞主线程。
  • 任务队列(Task Queue / Macrotask Queue): 一个先进先出(FIFO)的队列,用于存放待处理的宏任务(Macrotask)。当一个异步操作(如setTimeout或用户点击)完成后,其回调函数会被放入任务队列中等待执行。
  • 微任务队列(Microtask Queue): 另一个先进先出(FIFO)的队列,用于存放待处理的微任务(Microtask)。微任务的优先级高于宏任务。常见的微任务包括 Promise.then()Promise.catch()Promise.finally()的回调和 async/await 中的代码。

事件循环的运作流程

事件循环的整个过程可以简化为以下几个步骤,并周而复始地进行:

  1. 执行同步代码: JavaScript引擎会首先执行调用栈中的所有同步代码。
  2. 检查微任务队列: 当调用栈为空时(即所有同步代码执行完毕),事件循环会立即检查微任务队列。
  3. 清空微任务队列: 如果微任务队列不为空,事件循环会按顺序执行队列中所有的微任务,直到微任务队列被清空。如果在执行微任务的过程中又产生了新的微任务,这些新的微任务也会被添加到队列的末尾,并在当前轮次中一并执行。
  4. 执行一个宏任务: 当微任务队列为空后,事件循环会从任务队列(宏任务队列)中取出一个宏任务,并将其回调函数推入调用栈中执行。
  5. 重复循环: 一个宏任务执行完毕后,调用栈再次变空。事件循环会再次回到第二步,检查微任务队列,如此循环往复。

关键点: 每一轮事件循环中,只会执行一个宏任务,但会清空整个微任务队列。这就是为什么微任务总是在下一个宏任务之前执行的原因。


10道事件循环面试题

以下问题旨在检验您对事件循环机制的深入理解和在复杂场景下的应用能力。

题目一:基础执行顺序

问题: 预测下面代码的输出顺序,并解释为什么。

console.log('script start');setTimeout(function() {console.log('setTimeout');
}, 0);Promise.resolve().then(function() {console.log('promise1');
}).then(function() {console.log('promise2');
});console.log('script end');

输出顺序:

script start
script end
promise1
promise2
setTimeout

解析:

  1. 首先执行同步代码,打印 script startscript end
  2. setTimeout 的回调函数是一个宏任务,被放入宏任务队列。
  3. Promise.resolve().then() 的回调是微任务,被放入微任务队列。第一个 .then() 返回的仍然是Promise,因此第二个 .then() 也被视为微任务,并紧接着放入微任务队列。
  4. 同步代码执行完毕,调用栈为空。事件循环检查微任务队列,发现里面有 promise1promise2 的回调。
  5. 依次执行微任务,打印 promise1promise2
  6. 微任务队列清空后,事件循环从宏任务队列中取出一个任务执行,即 setTimeout 的回调,打印 setTimeout

题目二:async/awaitPromise的结合

问题: 预测下面 async 函数相关代码的输出顺序。

async function async1() {console.log('async1 start');await async2();console.log('async1 end');
}async function async2() {console.log('async2');
}console.log('script start');setTimeout(function() {console.log('setTimeout');
}, 0);async1();new Promise(function(resolve) {console.log('promise1');resolve();
}).then(function() {console.log('promise2');
});console.log('script end');

输出顺序:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

解析:

  1. 同步代码执行,打印 script start
  2. setTimeout 回调被放入宏任务队列。
  3. 执行 async1()。打印 async1 start
  4. 执行 await async2()。这会立即执行 async2 函数,打印 async2await 右侧的表达式执行完后,会把 await 下方的代码(console.log('async1 end'))作为微任务放入队列。然后跳出 async1 函数。
  5. 继续执行同步代码,new Promise 中的代码是同步的,打印 promise1.then() 的回调被放入微任务队列。
  6. 继续执行同步代码,打印 script end
  7. 同步代码执行完毕,清空微任务队列。await 的后续代码(async1 end)比 promise2 先入队。所以先打印 async1 end,再打印 promise2
  8. 微任务队列清空后,执行宏任务,打印 setTimeout

题目三:宏任务与微任务的嵌套

问题: 预测以下复杂嵌套代码的输出顺序。

setTimeout(_ => console.log(4));new Promise(resolve => {resolve();console.log(1);
}).then(_ => {console.log(3);Promise.resolve().then(_ => {console.log('before timeout');}).then(_ => {Promise.resolve().then(_ => {console.log('also before timeout');})});
});console.log(2);

输出顺序:

1
2
3
before timeout
also before timeout
4

解析:

  1. setTimeout 是宏任务,放入宏任务队列。
  2. new Promise 中的代码是同步的,打印 1。第一个 .then 的回调是微任务,放入微任务队列。
  3. 执行同步代码,打印 2
  4. 同步代码结束,清空微任务队列。执行第一个 .then 的回调,打印 3
  5. 在执行这个微任务的过程中,又注册了新的微任务('before timeout')。根据规则,新产生的微任务会在当前轮次的微任务队列末尾执行。
  6. 因此,打印 'before timeout'。这个微任务又注册了新的微任务 ('also before timeout'),它同样在本轮被执行。
  7. 打印 'also before timeout'
  8. 所有微任务都执行完毕,开始下一轮事件循环,执行宏任务,打印 4

题目四:async/await 的错误处理

问题: 在下面的代码中,try...catch 能捕获到 promise 中的错误吗?如果能,输出是什么?如果不能,应该如何修改才能捕获到?

async function main() {try {console.log('try block start');new Promise((resolve, reject) => {reject('promise error');});console.log('try block end');} catch (e) {console.log('Caught error:', e);}
}main();

回答:
不能捕获到错误。try...catch 只能捕获其所在上下文中的同步代码错误或 await 的异步代码错误。

输出:

try block start
try block end
// Uncaught (in promise) promise error

解析:
new Promise 的执行器函数是同步执行的,但 reject 是异步的。当 reject 被调用时,try...catch 块已经执行完毕了。这个未被处理的 Promise rejection 会在全局作用域中抛出错误。

修改方案:
为了捕获错误,你必须 await 这个 Promise

async function main() {try {console.log('try block start');await new Promise((resolve, reject) => { // 加上 awaitreject('promise error');});console.log('try block end'); // 这行不会执行} catch (e) {console.log('Caught error:', e);}
}main();
// 输出:
// try block start
// Caught error: promise error

题目五:DOM 事件作为宏任务

问题: 用户点击一个按钮会立即触发 console.log 吗?假设用户在 “script end” 打印后、第一个 setTimeout 执行前立即点击了按钮,请解释输出顺序。

<button id="myBtn">Click me</button>
<script>const btn = document.getElementById('myBtn');btn.addEventListener('click', function() {console.log('Button clicked');Promise.resolve().then(() => console.log('Promise in click'));});Promise.resolve().then(() => console.log('Initial Promise'));setTimeout(() => console.log('Initial setTimeout'), 0);console.log('script end');
</script>

输出顺序:

script end
Initial Promise
Initial setTimeout
Button clicked
Promise in click

解析:

  1. 执行同步代码,为按钮添加事件监听器,打印 script end
  2. Initial Promise 的回调是微任务,放入微任务队列。Initial setTimeout 的回调是宏任务,放入宏任务队列。
  3. 同步代码执行完毕,清空微任务队列,打印 Initial Promise
  4. 微任务队列清空,执行第一个宏任务,打印 Initial setTimeout
  5. 此时,第一轮事件循环结束。假设用户在此时点击了按钮。点击事件的回调是一个新的宏任务,被放入宏任务队列。
  6. 事件循环开始新的一轮,从宏任务队列中取出点击事件的回调并执行。打印 Button clicked
  7. 在执行点击回调(宏任务)的过程中,产生了一个新的微任务(Promise in click)。
  8. 点击回调(宏任务)执行完毕后,调用栈为空。事件循环立即检查微任务队列,发现 Promise in click 的回调。
  9. 执行微任务,打印 Promise in click

题目六:渲染时机 (requestAnimationFrame)

问题: requestAnimationFrame (rAF) 的回调与 setTimeoutPromise.then 的回调,在执行时机上有什么本质区别?在下面的代码中,rAFsetTimeout 哪个会先执行?

setTimeout(() => console.log('Timeout'), 0);
requestAnimationFrame(() => console.log('rAF'));
Promise.resolve().then(() => console.log('Promise'));

回答:

  • 本质区别Promise.then 是微任务,在当前同步代码执行完后立刻执行。setTimeout 是宏任务,在微任务队列清空后,并且达到指定延迟时间后执行。requestAnimationFrame (rAF) 的执行时机则与它们都不同,它不是标准的宏任务,而是由浏览器在下一次重绘(Repaint)之前调用的一个特殊任务。其执行时机紧跟在所有微任务之后,但在UI渲染之前。

  • 执行顺序Promise -> rAF -> Timeout 是最常见的顺序。

解析:

  1. Promise 作为微任务,最先被执行。
  2. setTimeout 作为宏任务,被放入宏任务队列。
  3. rAF 的回调被放入一个专门的动画帧请求队列。
  4. 同步代码执行完,清空微任务队列(打印 Promise)。
  5. 接下来,在一轮事件循环中,浏览器会决定是否需要渲染。如果需要,它会在执行下一个宏任务(setTimeout)之前,去处理渲染相关的任务,其中就包括执行 rAF 的回调。因此 rAF 通常会在 setTimeout(fn, 0) 之前执行。
  6. 最后,当渲染工作结束后(或者浏览器决定本轮不渲染),才从宏任务队列中取出任务执行,打印 Timeout

(注意:rAFsetTimeout 的精确顺序有时会因浏览器负载和刷新率而有细微差异,但原理是不变的:rAF 紧随渲染,而setTimeout是标准宏任务。)


题目七:setTimeout(fn, 0)的真正含义

问题: 为什么 setTimeout(fn, 0) 并不会让回调函数立即执行?它在事件循环中扮演什么角色?

回答:
setTimeout(fn, 0) 并不会让回调立即执行,而是尽快执行。它做的事情是:将回调函数 fn 作为一个新的宏任务,添加到宏任务队列的末尾。

角色和原因:

  1. 最小延迟:尽管设置了0毫秒延迟,但浏览器或Node.js环境通常有一个最小的延迟时间(例如在浏览器中通常是4ms左右)。所以它不会真的在0ms后执行。
  2. 事件循环机制:根据事件循环的规则,只有在当前执行栈为空(所有同步代码执行完毕)并且微任务队列也为空时,才会从宏任务队列中取出一个任务来执行。
  3. 应用场景:它的主要作用是改变代码的执行顺序,让我们能将一个任务推迟到下一个事件循环周期中执行。这在需要等待DOM更新完成,或者需要将一个高计算量的任务分片以避免阻塞主线程时非常有用。它提供了一种“让出主线程”的能力。

题目八:Node.js环境下的特殊性 (process.nextTick)

问题: (Node.js 环境)process.nextTickPromise.then 的执行优先级有何不同?预测以下代码在 Node.js 中的输出。

// Node.js Environment
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));console.log('sync');

输出顺序:

sync
nextTick
promise
timeout
immediate

(注意:timeoutimmediate 的顺序在某些情况下可能交换,这取决于Node.js准备timer阶段的时机,但它们一定在 nextTickpromise 之后)

解析:
在Node.js的事件循环中,任务队列的优先级比浏览器更复杂:

  1. process.nextTick 队列:这是一个特殊的队列,它的优先级最高。在当前同步代码执行完毕后,事件循环会立即清空 nextTick 队列,其优先级高于微任务队列
  2. 微任务队列 (Microtask Queue):存放 Promise.then 等微任务。在 nextTick 队列清空后,接着清空微任务队列。
  3. 宏任务队列 (Macrotask Queue)setTimeout, setImmediate, I/O 等宏任务。

执行流程:

  1. 执行同步代码,打印 sync
  2. process.nextTick 的回调放入 nextTick 队列。Promise.then 的回调放入微任务队列。setTimeoutsetImmediate 的回调放入各自的宏任务队列。
  3. 同步代码结束。首先检查 nextTick 队列,执行并打印 nextTick
  4. 然后检查微任务队列,执行并打印 promise
  5. 最后进入宏任务阶段。通常会先进入 timers 阶段,执行 setTimeout 的回调,打印 timeout。然后进入 check 阶段,执行 setImmediate 的回调,打印 immediate

题目九:微任务队列“插队”

问题: 解释为什么在执行微任务的过程中产生的新微任务,会被添加到当前微任务队列的末尾并立即执行,而不是等待下一轮事件循环。请用下面的例子说明。

Promise.resolve().then(() => {console.log('promise1');Promise.resolve().then(() => {console.log('promise2');});
});setTimeout(() => console.log('timeout'), 0);

输出顺序:

promise1
promise2
timeout

解析:
事件循环的一个核心规则是:在进入下一个宏任务之前,必须将微任务队列完全清空

  1. setTimeout 回调进入宏任务队列。promise1 的回调进入微任务队列。
  2. 同步代码执行完毕,事件循环开始处理微任务。它从队列中取出 promise1 的回调并执行,打印 promise1
  3. 在执行 promise1 回调的过程中,promise2 的回调作为一个新的微任务被创建并添加到微任务队列的末尾。
  4. 此时,事件循环并不会结束微任务阶段,因为它会检查到微任务队列还不为空。它会继续从队列中取出下一个任务,也就是 promise2 的回调,并执行它,打印 promise2
  5. 这个过程会一直持续,直到微任务队列被彻底清空。
  6. 微任务队列确认清空后,事件循环才会去宏任务队列中取出 timeout 的回调并执行。

这个机制确保了微任务的响应是即时的,并且总是在下一个宏任务(如UI渲染或setTimeout)之前完成。


题目十:微任务队列“饿死”宏任务队列

问题: 请编写一个代码片段,演示微任务队列如何持续添加任务,从而导致宏任务队列(例如 setTimeout 的回调)永远得不到执行的机会。这在实际开发中会造成什么问题?

代码片段:

setTimeout(() => {// 这个宏任务可能永远不会被执行console.log('Timeout callback executed!');
}, 0);function infiniteMicrotasks() {Promise.resolve().then(() => {// 持续不断地将自己再次添加到微任务队列console.log('Running a microtask...');infiniteMicrotasks();});
}infiniteMicrotasks();console.log('Script finished.');

解析:

  1. setTimeout 的回调被放入宏任务队列。
  2. infiniteMicrotasks() 被调用,它创建了一个 Promise,其 .then() 回调被放入微任务队列。
  3. 同步代码结束。事件循环开始清空微任务队列。
  4. 它执行第一个微任务,打印 Running a microtask...。然而,在这个微任务的内部,又调用了 infiniteMicrotasks(),这会再次将一个新的微任务添加到微任务队列的末尾。
  5. 事件循环检查微任务队列,发现它仍然不为空,于是继续执行下一个微任务。这个过程无限循环。
  6. 由于微任务队列永远无法被清空,事件循环将永远无法进入下一个阶段去处理宏任务队列。因此,setTimeout 的回调被“饿死”(starved),永远没有机会执行。

实际问题:
这种情况在实际开发中是灾难性的。它会导致:

  • 页面完全冻结:浏览器UI渲染是一个宏任务。如果微任务无限循环,浏览器将永远没有机会重新渲染页面,导致页面卡死,对用户输入(点击、滚动)无响应。
  • 异步操作无法完成:所有其他的宏任务,如 setTimeout, setInterval, I/O 操作的回调,都将无法执行。
http://www.dtcms.com/a/442164.html

相关文章:

  • 亭湖区建设局网站小红书推广计划
  • 吃透大数据算法-时间轮(TimingWheel)
  • 从输入URL到展示出页面的这个过程~
  • WebDAV 与 SMB 在钓鱼攻击中的区别
  • 8. Pandas 日期与时间序列数据处理
  • 免费网站模板做零食的网站有哪些
  • 从零开始的C++学习生活 2:类和对象(上)
  • 家纺营销型网站网站建设服务费怎么记账
  • css其他选择器(精细修饰)
  • 一般设计网站页面用什么软件做引擎网站
  • 生成式 AI 重构内容创作:从辅助工具到智能工厂
  • 华为S5720配置telnet远程
  • 面试复盘:哔哩哔哩、蔚来、字节跳动、小红书面试与总结
  • Your ViT is Secretly an Image Segmentation Model
  • 海口网站建设运营网站开发公司选择
  • 日语学习-日语知识点小记-进阶-JLPT-N1阶段应用练习(4):语法 +考え方17+2022年7月N1
  • RAG:解锁大语言模型新能力的关键钥匙
  • 广州网站建设海珠信科网站建设推广方法
  • Oracle Linux 7.8 静默安装 Oracle 11g R2 单机 ASM 详细教程
  • 旅游公司网站建设方案网站的布局结构三种
  • Django ORM 无法通过 `ForeignKey` 自动关联,而是需要 **根据父模型中的某个字段(比如 ID)去查询子模型**。
  • 吉林省建设厅信息网站网站建设的评价
  • 分布式专题——26.5 一台新机器进行Web页面请求的历程
  • 怎么让别人看到自己做的网站万维网网站301重定向怎么做
  • css样式学习记录
  • 网站服务器关闭网站数据库地址是什么
  • 每日一个C语言知识:C程序结构
  • Amazon RDS:云端数据库管理的革新之路
  • wordpress登录可见站内seo是什么意思
  • STM32简单的串口Bootloader入门