JavaScript事件循环机制:从底层原理到幽默解读

引言:JavaScript的"单核"困境与"多线程"梦想

想象一下,你是一个超级英雄,但你只有一个大脑,一次只能处理一件事情。这就是JavaScript的宿命------一个"单线程"的家伙。在浏览器环境中,JavaScript的主线程负责执行代码、渲染页面、响应用户交互,它忙得不可开交。如果有一个耗时任务(比如从遥远的服务器获取数据,或者执行一个复杂的计算)阻塞了主线程,那么整个页面就会"卡死",用户会觉得你这个超级英雄简直是"废物"!

为了解决这个"单核"困境,让JavaScript在保持单线程特性的同时,也能处理异步操作,实现"非阻塞"的"多线程"效果,一个幕后英雄悄然登场,它就是------事件循环(Event Loop) 。它就像一个高效的调度员,确保JavaScript的"单核"大脑能够有条不紊地处理各种任务,既能及时响应用户的"呼唤",又能优雅地处理那些"慢吞吞"的异步请求。

本文将带你深入JavaScript事件循环的底层原理,揭开它神秘的面纱,让你彻底理解这个看似复杂实则精妙的机制。我们将从JavaScript运行时的核心组件讲起,详细探讨宏任务(Macrotasks)和微任务(Microtasks)的执行顺序,并通过生动的例子和幽默的语言,让你在轻松愉快的氛围中掌握这一核心概念。

JavaScript运行环境:那些你可能不知道的"幕后玩家"

在深入事件循环之前,我们得先认识一下JavaScript代码运行的"舞台"和"演员"。别以为JavaScript只是简单地从上到下执行代码,它背后可有一群"幕后玩家"在默默工作。

1. 调用栈(Call Stack):"任务执行官"

调用栈,顾名思义,就是一个栈结构(先进后出)。它负责跟踪函数执行的顺序。每当一个函数被调用,它就会被推入栈中;当函数执行完毕,它就会从栈中弹出。JavaScript引擎在执行代码时,会不断地检查调用栈,只要栈不为空,它就会一直执行栈顶的函数。这就像一个"任务执行官",雷厉风行,不把当前任务搞定绝不罢休。

scss 复制代码
function multiply(a, b) {
    return a * b;
}
​
function square(n) {
    return multiply(n, n);
}
​
function printSquare(n) {
    const result = square(n);
    console.log(result);
}
​
printSquare(4);
// 调用栈的执行顺序:
// 1. printSquare(4) 被推入栈
// 2. square(4) 被推入栈
// 3. multiply(4, 4) 被推入栈
// 4. multiply(4, 4) 执行完毕,弹出栈
// 5. square(4) 执行完毕,弹出栈
// 6. console.log(result) 被推入栈
// 7. console.log(result) 执行完毕,弹出栈
// 8. printSquare(4) 执行完毕,弹出栈

2. 堆(Heap):"记忆仓库"

堆是内存中用于存储对象和变量的地方。与调用栈不同,堆没有严格的结构,它更像一个"记忆仓库",存放着程序运行时需要的所有数据,比如对象、数组、函数等。当你在代码中创建变量或对象时,它们就被存储在堆中。JavaScript引擎会通过垃圾回收机制自动管理堆内存,回收不再使用的内存空间,防止内存泄漏。你可以把它想象成一个巨大的储物柜,里面放满了各种各样的"宝贝",但你不需要手动去整理,有专门的"清洁工"帮你打扫。

3. 任务队列(Task Queue / Callback Queue):"待办事项清单"

既然JavaScript是单线程的,那异步操作(比如setTimeoutAjax请求、DOM事件等)怎么处理呢?它们不会直接进入调用栈,而是会被放到一个"待办事项清单"里,这个清单就是任务队列。当异步操作完成并准备好执行时,它们的回调函数就会被放入任务队列中排队。任务队列又分为宏任务队列和微任务队列,我们稍后会详细介绍。你可以把任务队列想象成一个"排队叫号机",异步任务的回调函数在这里乖乖排队,等待被"叫号"执行。

4. Web APIs / Node.js APIs:浏览器/Node.js提供的"超能力"

JavaScript引擎本身只负责执行JavaScript代码,它并没有处理DOM操作、网络请求、定时器等"超能力"。这些"超能力"是由宿主环境(浏览器或Node.js)提供的。在浏览器中,这些API被称为Web APIs,比如setTimeoutDOM事件、XMLHttpRequest等。在Node.js中,则有相应的Node.js APIs,比如文件系统操作、网络模块等。当JavaScript代码调用这些API时,它们会将相应的任务交给宿主环境处理,而不会阻塞JavaScript主线程。这就像JavaScript引擎把一些"脏活累活"外包给了"专业团队",自己则继续处理主线任务。

这些"幕后玩家"协同工作,共同构成了JavaScript的运行时环境。而事件循环,正是连接这些玩家的"桥梁",它决定了任务的执行顺序,确保JavaScript的单线程特性不会成为性能瓶颈。接下来,我们将深入事件循环的核心机制,看看它是如何巧妙地调度这些任务的。

事件循环(Event Loop):JavaScript的"心脏"与"调度员"

终于,我们来到了本文的核心------事件循环。如果说JavaScript引擎是"大脑",那么事件循环就是驱动这个大脑跳动的"心脏",也是那个运筹帷幄的"调度员"。它是一个永不停歇的循环,不断地检查调用栈和任务队列,决定接下来要执行什么任务。

1. 宏任务(Macrotasks):"普通VIP"任务

宏任务是那些"普通VIP"任务,它们在每次事件循环迭代中,只会被执行一个。当一个宏任务执行完毕后,事件循环会检查微任务队列。常见的宏任务包括:

  • script (整体代码) :是的,你没看错,整个JavaScript文件本身就是一个宏任务。当浏览器加载一个<script>标签时,它会作为一个宏任务被执行。
  • setTimeout / setInterval:定时器回调函数。当设定的时间到达后,回调函数会被放入宏任务队列。
  • I/O 操作 :例如网络请求(fetchXMLHttpRequest)的回调,文件读写等。
  • UI 渲染:浏览器会根据需要进行页面的重绘和回流。
  • 用户交互事件 :例如clickkeydown等事件的回调。

你可以把宏任务想象成一个餐厅里的"普通桌位",每次只能安排一位客人入座,等这位客人吃完饭,才能安排下一位。即使外面排队的人再多,也得一个一个来。

2. 微任务(Microtasks):"插队VIP"任务

微任务是那些"插队VIP"任务,它们拥有更高的优先级。在每个宏任务执行完毕后,事件循环会立即清空微任务队列,也就是说,所有排队的微任务都会被一次性执行完毕,然后才会进入下一个宏任务的执行。常见的微任务包括:

  • Promise.then() / Promise.catch() / Promise.finally() :Promise的回调函数。当Promise状态改变时,相应的回调会被放入微任务队列。
  • MutationObserver:用于监听DOM变化的API。当DOM发生变化时,其回调会被放入微任务队列。
  • queueMicrotask() :一个专门用于将任务放入微任务队列的API。
  • process.nextTick() (Node.js) :Node.js环境中特有的微任务,优先级甚至高于Promise。

微任务就像餐厅里的"VIP包厢",一旦有VIP客人来了,服务员会立刻安排他们入座,并且会优先服务他们,直到所有VIP客人都服务完毕,才会继续服务普通桌位的客人。

3. 事件循环的执行顺序:一圈又一圈的"生命周期"

现在,我们把这些概念串联起来,看看事件循环是如何工作的:

  1. 执行同步代码:当JavaScript代码开始执行时,它会首先执行所有的同步代码,这些代码会直接进入调用栈并立即执行。这就像餐厅刚开门,所有已经预定好的客人(同步任务)立刻入座。
  2. 清空调用栈:当调用栈中的同步代码全部执行完毕,调用栈变为空。这表示当前这一轮的"主线任务"已经完成。
  3. 执行所有微任务:此时,事件循环会检查微任务队列。如果微任务队列中有任务,事件循环会立即将它们全部取出,并推入调用栈执行,直到微任务队列清空。这就像服务员优先服务完所有VIP包厢的客人。
  4. 执行一个宏任务:微任务队列清空后,事件循环会从宏任务队列中取出一个任务,并推入调用栈执行。这就像服务员开始安排普通桌位的客人入座,但每次只安排一位。
  5. 重复步骤2-4:当这个宏任务执行完毕,调用栈再次清空,事件循环会再次检查微任务队列,然后是宏任务队列,如此循环往复,直到所有任务都执行完毕。

这个过程可以形象地比喻为:

一圈(Event Loop Tick) = 执行一个宏任务 + 清空所有微任务

让我们通过一个经典的例子来理解这个过程:

javascript 复制代码
console.log('script start'); // 同步任务
​
setTimeout(() => { // 宏任务
    console.log('setTimeout');
}, 0);
​
Promise.resolve().then(() => { // 微任务
    console.log('promise');
});
​
console.log('script end'); // 同步任务
​
// 预期输出:
// script start
// script end
// promise
// setTimeout

执行分析:

  1. 第一轮事件循环开始

    • console.log('script start'):同步任务,立即执行,输出 script start
    • setTimeout:宏任务,其回调被放入宏任务队列。
    • Promise.resolve().then():微任务,其回调被放入微任务队列。
    • console.log('script end'):同步任务,立即执行,输出 script end
  2. 同步代码执行完毕,调用栈清空

  3. 检查微任务队列 :发现 promise 的回调,立即执行,输出 promise。微任务队列清空。

  4. 检查宏任务队列 :发现 setTimeout 的回调,将其取出并推入调用栈执行,输出 setTimeout

  5. 宏任务执行完毕,调用栈清空。事件循环继续,但此时宏任务队列和微任务队列都已清空,程序结束。

这个例子清晰地展示了微任务优先于宏任务执行的特性。理解这一点,是掌握JavaScript异步编程的关键。

特殊任务解析:process.nextTickMutationObserver

在微任务和宏任务的大家庭中,有几个"特立独行"的成员,它们在某些特定环境下表现出独特的优先级,值得我们单独拎出来"盘一盘"。

1. Node.js的"特权微任务":process.nextTick()

如果你在Node.js环境中进行开发,那么你一定会遇到一个特殊的微任务------process.nextTick()。它的特殊之处在于,它的优先级比Promise.then()还要高!是的,你没听错,它就是微任务中的"超级VIP",拥有最高的插队权。

在Node.js的事件循环中,process.nextTick()的回调会在当前宏任务执行结束后,所有其他微任务(包括Promise回调)之前执行。这使得它成为在当前操作结束后立即执行某些代码的理想选择,而不会等到下一个事件循环周期。

让我们看一个Node.js环境下的例子:

javascript 复制代码
console.log('Start');
// node 微任务
// process 进程对象
process.nextTick(()=>{
    console.log('Process Next Tick');
})
// 微任务
Promise.resolve().then(()=>{
    console.log('Promise Resolved');
})
// 宏任务
setTimeout(()=>{
    console.log('haha')
    Promise.resolve().then(()=>{
        console.log('inner Promise')
    })
},0)
console.log('end');
​
// 预期输出:
// Start
// end
// Process Next Tick
// Promise Resolved
// haha
// inner Promise

执行分析:

  1. 同步代码执行console.log('Start')console.log('end') 立即执行,输出 Startend
  2. process.nextTick回调入队process.nextTick的回调被放入nextTick队列。
  3. Promise.then回调入队Promise.resolve().then的回调被放入微任务队列。
  4. setTimeout回调入队setTimeout的回调被放入宏任务队列。
  5. 清空nextTick队列 :在当前宏任务(即整个script脚本)执行完毕后,事件循环会首先清空nextTick队列,所以 Process Next Tick 被输出。
  6. 清空微任务队列 :接着清空微任务队列,Promise Resolved 被输出。
  7. 执行下一个宏任务 :从宏任务队列中取出setTimeout的回调执行,输出 haha
  8. setTimeout内部的Promise.then回调入队 :在setTimeout回调执行过程中,又创建了一个Promise.then微任务,它被放入微任务队列。
  9. 清空微任务队列setTimeout回调执行完毕后,再次清空微任务队列,输出 inner Promise

这个例子完美地展示了process.nextTick()在Node.js环境中"插队"的强大能力。

2. DOM变化的"侦察兵":MutationObserver

MutationObserver是一个用于监听DOM变化的API,它的回调也是微任务。这意味着,当你使用MutationObserver监听DOM变化时,它的回调会在当前DOM操作完成后,但在浏览器进行下一次渲染之前执行。这对于需要对DOM变化做出即时反应,但又不想阻塞UI渲染的场景非常有用。

让我们看一个浏览器环境下的例子:

xml 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>微任务</title>
</head>
<body>
    <script>
        const target = document.createElement('div');
        document.body.appendChild(target);
        const observer = new MutationObserver(()=>{
            console.log('微任务:MutationObserver');
​
        })
        // 监听target 节点的变化
        observer.observe(target,{
            attributes: true,
            childList: true
        })
​
        target.setAttribute('data-set','123');
        target.appendChild(document.createElement('span'));
        target.setAttribute('style','background-color:green;');
    </script>
</body>
</html>
​
// 预期输出:
// 微任务:MutationObserver

执行分析:

  1. 同步代码执行 :创建div元素并添加到body中,初始化MutationObserver并开始监听target元素的属性和子节点变化。

  2. DOM操作触发微任务

    • target.setAttribute('data-set','123'):触发属性变化,MutationObserver的回调被放入微任务队列。
    • target.appendChild(document.createElement('span')):触发子节点变化,MutationObserver的回调再次被放入微任务队列(注意:多次触发的MutationObserver回调会合并为一次)。
    • target.setAttribute('style','background-color:green;'):再次触发属性变化,MutationObserver的回调再次被放入微任务队列。
  3. 同步代码执行完毕,调用栈清空

  4. 清空微任务队列 :事件循环发现微任务队列中有MutationObserver的回调,将其取出并执行,输出 微任务:MutationObserver

这个例子说明了MutationObserver的回调作为微任务,会在当前同步代码执行完毕后立即执行,这对于在DOM更新后进行一些操作(例如计算布局、更新样式等)非常有用。

浏览器渲染与事件循环的"爱恨情仇"

在浏览器环境中,JavaScript的事件循环不仅仅要处理代码的执行,还要和浏览器的渲染引擎"打交道"。这就像一个多面手,既要管好自己的"一亩三分地",又要协调好和"邻居"的关系。理解浏览器渲染过程与事件循环的交互,对于优化前端性能至关重要。

1. 渲染时机:宏任务之间的"喘息之机"

浏览器渲染(包括布局、绘制等)通常发生在两次宏任务之间。也就是说,当一个宏任务执行完毕,并且所有的微任务也都被清空之后,浏览器才有机会进行一次渲染。这就像事件循环在处理完一波任务后,会给浏览器一个"喘息之机",让它把最新的DOM变化呈现在屏幕上。

这意味着,如果你在一个宏任务中进行了大量的DOM操作,并且没有给浏览器渲染的机会,那么用户可能会看到页面"卡顿"或者"闪烁"的效果。因为所有的DOM变化都会被累积起来,直到当前宏任务执行完毕,微任务队列清空后,浏览器才会一次性地进行渲染。

2. requestAnimationFrame:渲染前的"最后通牒"

requestAnimationFrame是一个特殊的API,它的回调函数会在浏览器下一次重绘之前执行。这使得它成为执行动画和视觉更新的理想选择。requestAnimationFrame的回调通常被认为是宏任务的一部分,但它的执行时机非常特殊:它会在浏览器准备渲染之前被执行,而不是在普通的宏任务队列中排队。

你可以把requestAnimationFrame想象成一个"渲染前的最后通牒",它告诉浏览器:"嘿,等一下,我这里还有一些视觉上的更新需要做,你先等我一下,我搞定了你再渲染!"

ini 复制代码
let count = 0;
function animate() {
    count++;
    document.getElementById("box").style.transform = `translateX(${count}px)`;
    if (count < 200) {
        requestAnimationFrame(animate);
    }
}
​
// 假设页面中有一个id为"box"的元素
// requestAnimationFrame(animate);

使用requestAnimationFrame进行动画的好处是,它能够与浏览器的刷新率同步,避免了丢帧和卡顿,使得动画更加流畅。

3. 复杂场景下的事件循环:嵌套与交织

现在,让我们来一个更复杂的例子,看看当宏任务、微任务、以及嵌套的异步操作交织在一起时,事件循环是如何工作的。这就像一场"多线程"的华尔兹,舞步虽然复杂,但只要掌握了节奏,就能跳出优美的舞姿。

考虑以下代码:

javascript 复制代码
console.log('同步Start')
const promise1=Promise.resolve('First Promise');
const promise2 = Promise.resolve('Second Promise');
const promise3 = new Promise(resolve =>{
    console.log('promise3');
    resolve('Third Promise');
})
promise1.then(value=> console.log(value));
promise2.then(value=>console.log(value));
promise3.then(value=>console.log(value));
setTimeout(()=>{
    console.log('下一把再见');
    const promise4 = Promise.resolve('Forth Promise');
    promise4.then(value => console.log(value));
},0)
setTimeout(()=>{
    console.log('下下把再见')
},0)
console.log('同步end')
​
// 预期输出:
// 同步Start
// promise3
// 同步end
// First Promise
// Second Promise
// Third Promise
// 下一把再见
// Forth Promise
// 下下把再见

执行分析:

  1. 第一轮事件循环开始(整体script作为宏任务)

    • console.log('同步Start'):同步执行,输出 同步Start
    • promise1promise2:立即resolve的Promise,它们的.then回调被放入微任务队列。
    • promise3:Promise构造函数中的同步代码立即执行,输出 promise3。其.then回调被放入微任务队列。
    • setTimeout (第一个):回调被放入宏任务队列。
    • setTimeout (第二个):回调被放入宏任务队列。
    • console.log('同步end'):同步执行,输出 同步end
  2. 同步代码执行完毕,调用栈清空

  3. 清空微任务队列

    • promise1.then回调执行,输出 First Promise
    • promise2.then回调执行,输出 Second Promise
    • promise3.then回调执行,输出 Third Promise。 微任务队列清空。
  4. 执行第一个宏任务 :从宏任务队列中取出第一个setTimeout的回调执行。

    • console.log('下一把再见'):输出 下一把再见
    • promise4:立即resolve的Promise,其.then回调被放入微任务队列。
  5. 宏任务执行完毕,调用栈清空

  6. 清空微任务队列promise4.then回调执行,输出 Forth Promise。微任务队列清空。

  7. 执行第二个宏任务 :从宏任务队列中取出第二个setTimeout的回调执行。

    • console.log('下下把再见'):输出 下下把再见
  8. 宏任务执行完毕,调用栈清空。所有任务执行完毕,程序结束。

这个例子展示了事件循环如何在一个宏任务执行过程中,再次产生微任务,并且这些微任务会在当前宏任务结束后立即执行,而不是等到下一个宏任务周期。理解这种嵌套和交织的执行顺序,是掌握复杂异步场景的关键。

总结:掌握事件循环,成为JavaScript"时间管理大师"

通过本文的深入探讨,相信你已经对JavaScript事件循环机制有了底层且详尽的理解。这个看似复杂的机制,实际上是JavaScript实现单线程非阻塞异步编程的基石。它就像一个精密的"时间管理大师",巧妙地安排着各种任务的执行顺序,确保JavaScript引擎能够高效、流畅地运行。

核心要点回顾:

  • JavaScript是单线程的:一次只能执行一个任务,但通过事件循环实现了非阻塞。
  • 运行时环境:由调用栈、堆、任务队列(宏任务队列和微任务队列)以及Web APIs/Node.js APIs组成。
  • 宏任务与微任务:宏任务是"普通VIP",每次事件循环只执行一个;微任务是"插队VIP",在每个宏任务执行后,会清空所有微任务。
  • 执行顺序 :同步代码 -> process.nextTick (Node.js) -> 微任务 -> 宏任务(一个)。
  • 浏览器渲染:发生在宏任务和微任务清空之后,下一个宏任务开始之前。

理解事件循环不仅仅是为了面试,更是为了写出高性能、无阻塞的JavaScript代码。当你遇到页面卡顿、动画不流畅、或者异步操作不如预期的情况时,首先想到的就应该是事件循环。它能帮助你定位问题,并找到优雅的解决方案。

希望以上文章能对你有所帮助

相关推荐
遂心_5 分钟前
React初学者必备:用“状态管家”Reducer轻松管理复杂状态!
前端·javascript·react.js
用户33790448021712 分钟前
ECMA6 ---- Class篇 (重难点个人向)
javascript
李明卫杭州13 分钟前
前端实现多标签页通讯
前端·javascript
在钱塘江18 分钟前
《你不知道的JavaScript-上卷》第二部分-this和对象原型-笔记-6-行为委托
前端·javascript
Point18 分钟前
[ahooks] useControllableValue源码阅读
前端·javascript
HexCIer19 分钟前
cbT.js: 一个让模板继承变得优雅的 Node.js 模板引擎
javascript·node.js
独立开阀者_FwtCoder26 分钟前
踩坑无数后,我终于总结出这份最全的 Vue3 组件通信实战指南
前端·javascript·vue.js
20261 小时前
12. npm version方法总结
前端·javascript·vue.js
帅夫帅夫1 小时前
JavaScript继承探秘:从原型链到ES6 Class
前端·javascript
小赖同学啊2 小时前
将Blender、Three.js与Cesium集成构建物联网3D可视化系统
javascript·物联网·blender