【建议收藏】2025最全JS事件循环详解:从底层机制到浏览器渲染

你好,未来的前端大师!

你是否曾经对 setTimeout(fn, 0) 为什么不是"立即"执行感到困惑?或者在面试中被问到 Promise 和 setTimeout 的执行顺序时,心里有点发虚?

别担心,这些问题都指向同一个核心概念------Event Loop(事件循环)。这不仅仅是面试高频题,更是理解 JavaScript 异步编程的基石。它决定了你的代码以何种顺序执行,直接关系到应用的性能和用户的体验。

今天,就让我们用最通俗易懂的语言,结合实战代码,彻底征服这个"磨人的小妖精",让你对 JS 的执行机制有一个全新的、透彻的理解!

一、为什么要有事件循环?JS 的"单线程"宿命

我们都知道,JavaScript 是一门单线程语言。这意味着在任何一个时刻,它只能做一件事。这就像一个专心致志的工匠,一次只能打磨一个零件。

这种设计的初衷是为了简单和安全。想象一下,如果 JS 是多线程的,一个线程在删除某个 DOM 节点,而另一个线程同时在修改这个节点的样式,那页面岂不是要"精神分裂"了?

但"专一"也带来了挑战:如果一个任务非常耗时(比如一次网络请求),那么整个程序都会被阻塞,页面会卡住不动,无法响应用户的任何操作。这显然是无法接受的。

为了解决这个问题,JavaScript 的设计者们引入了一套聪明的异步执行机制,而事件循环正是这套机制的核心与灵魂。

二、事件循环的核心:宏任务与微任务

为了实现异步,JS 把任务分成了两类:同步任务异步任务。同步任务在主线程上排队执行,而异步任务则进入了"任务队列"。

关键在于,这个"任务队列"又被细分为两种,它们的执行时机完全不同:

1. 宏任务 (Macro Task)

可以理解为比较"宏观"的、独立的任务单元。每次事件循环都从一个宏任务开始。

常见的宏任务:

  • 整个 <script> 代码块
  • setTimeout()setInterval()
  • DOM 事件监听 (addEventListener 的回调)
  • I/O 操作,如 fetch
  • requestAnimationFrame() (比较特殊,与渲染紧密相关)

2. 微任务 (Micro Task)

微任务则更加"紧急"和"精细"。它们通常在当前宏任务执行过程中产生,并且需要在当前宏任务执行结束之后、下一个宏任务开始之前立即"插队"执行完毕。

常见的微任务:

  • Promise.prototype.then(), .catch(), .finally()
  • MutationObserver: 它可以在 DOM 发生变化时,在页面重新渲染前得到通知。
  • queueMicrotask(): 一个专门用来创建微任务的现代 API。
  • process.nextTick() (Node.js 环境专属,最高优先级的微任务)

三、事件循环的完整流程:一次完美的轮转

好了,了解了宏任务和微任务,我们现在可以完整地描绘出事件循环的执行过程了:

  1. 执行宏任务 :从宏任务队列中取出一个任务(首次是 <script> 脚本)开始执行。
  2. 执行同步代码:执行这个宏任务中的所有同步代码。
  3. 清空微任务队列 :当宏任务的同步代码执行完毕,立即检查微任务队列。如果队列不为空,则循环执行其中的所有微任务,直到队列被完全清空
  4. UI 渲染 (浏览器环境):微任务队列清空后,浏览器会判断是否需要进行 UI 渲染(重绘/重排)。
  5. 开始下一个宏任务:结束本轮循环,回到第一步,从宏任务队列中取出下一个任务。

核心要义一次 Event Loop = 执行一个宏任务 + 清空所有产生的微任务

四、实战演练:从入门到进阶

理论说再多,不如代码跑一波!

入门篇:宏任务与微任务的基础碰撞

来看这段代码:

javascript 复制代码
console.log('script start');

Promise.resolve().then(() => console.log('Promise 1'));
Promise.resolve().then(() => console.log('Promise 2'));

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

console.log('script end');

分析:

  1. 宏任务1 (script) 开始 :
    • 打印 script start
    • 遇到两个 Promise.then,将它们的回调(微任务)依次放入微任务队列。
    • 遇到 setTimeout,将其回调(一个新的宏任务)放入宏任务队列。
    • 打印 script end
  2. 清空微任务 :
    • 执行微任务队列,依次打印 Promise 1Promise 2
  3. 宏任务2 (setTimeout) 开始 :
    • 打印 setTimeout

所以,最终输出 : script start -> script end -> Promise 1 -> Promise 2 -> setTimeout

进阶篇:宏任务中产生微任务

这个例子开始变得有趣了,它展示了宏任务内部如何"孕育"出新的微任务。

javascript 复制代码
const p4 = new Promise((resolve, reject) => {
    // 这里的代码是 Promise 的一部分,同步执行
    console.log('Promise constructor');
    setTimeout(() => {
        // 这是未来的一个宏任务
        console.log('setTimeout body');
        resolve(1000); // resolve 时,才会把 .then 的回调推入微任务队列
    }, 0);
});

p4.then((res) => {
    console.log('p4.then 1, res:', res);
}).then(() => {
    console.log('p4.then 2');
});

console.log('script end');

分析:

  1. 宏任务1 (script) 开始 :
    • new Promise 的构造函数是同步的,打印 Promise constructor
    • 遇到 setTimeout,将其回调注册为宏任务2
    • p4.then 被注册,但此时 p4 状态还是 pending,所以什么都不做。
    • 打印 script end
  2. 清空微任务: 微任务队列为空。
  3. 宏任务2 (setTimeout 回调) 开始 :
    • 打印 setTimeout body
    • 执行 resolve(1000)。此时 p4 的状态变为 fulfilled第一个 .then 的回调被推入微任务队列
  4. 清空微任务 :
    • 执行第一个 .then 的回调,打印 p4.then 1, res: 1000
    • 这个 .then 执行完后,第二个 .then 的回调也被推入微任务队列
    • 执行第二个 .then 的回调,打印 p4.then 2

最终输出 : Promise constructor -> script end -> setTimeout body -> p4.then 1, res: 1000 -> p4.then 2


五、解密浏览器渲染:JS 和 UI 的微妙关系

一个很多开发者会忽略的关键点:页面渲染(UI Rendering)本身不属于宏任务或微任务!

关于 UI 渲染的关键:UI 渲染发生在"当前宏任务执行完毕,且所有微任务都已清空"之后,到"下一个宏任务开始"之前这个间隙。

这意味着:

  • JS 执行会阻塞渲染:如果你的同步代码或微任务队列执行时间过长,浏览器就没机会去渲染页面,导致卡顿。
  • MutationObserverqueueMicrotask 可以在下次渲染前执行代码,这是它们的重要应用场景。

看这个 html 的例子:

html 复制代码
<script>
const target = document.createElement('div');
document.body.appendChild(target);

// MutationObserver 的回调是微任务
const observer = new MutationObserver(() => {
  console.log('微任务: MutaionObserver - DOM 变了,但在渲染前通知我');
});

observer.observe(target, { attributes: true });

// 同步修改DOM
target.setAttribute('data-set', '123'); 
// 控制台会先打印 '微任务...', 然后你才能在Elements面板看到data-set属性
</script>

六、深入幕后:浏览器的多线程协作

你可能会好奇,既然 JS 是单线程,那 setTimeout 的计时和 fetch 的下载是在哪里完成的呢?

答案是:浏览器是多进程的,而一个渲染进程(通常对应一个 Tab 页)是多线程的!

除了我们熟知的 JS 主线程,还有这些"幕后英雄":

  • GUI 渲染线程 :负责解析 HTML、CSS,布局和绘制页面。它与 JS 主线程是互斥的。
  • 定时器触发线程setTimeoutsetInterval 就在这里计时。时间到了,它会把回调函数放入宏任务队列。
  • 事件触发线程:管理用户的点击、滚动等事件,并将回调放入宏任务队列。
  • 异步 HTTP 请求线程:处理网络请求,完成后将回调放入宏任务队列。

正是这些线程与我们的 JS 主线程通过事件循环机制完美协作,才构建出了流畅而强大的 Web 应用。

最终总结:你需要记住的

  • 一个中心:JavaScript 是单线程的。
  • 两个基本点:任务分为宏任务和微任务。
  • 一个核心流程:一次事件循环就是【执行一个宏任务 -> 清空所有微任务 -> (可能进行UI渲染)】的重复。
  • 一个关键推论Promise.then (微任务) 总是在 setTimeout (下一个宏任务) 之前执行。
  • 一个性能忠告:保持你的同步代码和微任务简短,以避免阻塞渲染。

现在,你应该能清晰地解释为什么 setTimeout(fn, 0) 不会立即执行了------因为它需要等待当前宏任务和所有微任务执行完毕后,才有机会从宏任务队列中被取出。

掌握了事件循环,你不仅能从容应对面试,更能写出性能更优、逻辑更严谨的 JavaScript 代码。希望这篇文章能成为你前端进阶路上的一个坚实台阶!

相关推荐
长安城没有风2 分钟前
更适合后端宝宝的前端三件套之HTML
前端·html
伍哥的传说3 分钟前
Vue3 Anime.js超级炫酷的网页动画库详解
开发语言·前端·javascript·vue.js·vue·ecmascript·vue3
欢乐小v24 分钟前
elementui-admin构建
前端·javascript·elementui
霸道流氓气质1 小时前
Vue中使用vue-3d-model实现加载3D模型预览展示
前端·javascript·vue.js
溜达溜达就好1 小时前
ubuntu22 npm install electron --save-dev 失败
前端·electron·npm
慧一居士1 小时前
Axios 完整功能介绍和完整示例演示
前端
Leo Chaw1 小时前
40 - ScConv卷积模块
pytorch·深度学习·神经网络·机器学习·cnn
晨岳1 小时前
web开发-CSS/JS
前端·javascript·css
22:30Plane-Moon1 小时前
前端之CSS
前端·css