发布于 2026-01-06 0 阅读
0

JavaScript 执行上下文、调用堆栈和事件循环

JavaScript 执行上下文、调用堆栈和事件循环

这篇文章会比较长——先泡杯咖啡☕️,慢慢看吧……

你是否曾经看过一段 JavaScript 代码,明明知道执行后的结果是什么,却又完全不明白它是如何实现的?或者,你是否曾经看过一些异步代码,比如点击事件处理程序或 AJAX 调用,却不明白回调函数究竟是如何知道何时触发的?

JavaScript 无处不在。浏览器、桌面、移动应用,以及我们周围的日常事物中都充斥着它。阿特伍德定律似乎每天都在得到印证——“任何可以用 JavaScript 编写的应用程序,最终都会用 JavaScript 编写。”

JavaScript 的应用范围极其广泛,每天都有大量开发者使用它,这已不是什么秘密。然而,精通 JavaScript 却往往并非易事。这是因为 JS 属于那种只需掌握基本知识就能应付日常工作,而无需深入钻研的语言。

本文旨在通过理解 JavaScript 代码的执行方式,加深我们对 JavaScript 的理解。这些规则由执行上下文、调用栈和事件循环的交互作用所决定。正是这三个概念的相互作用,使得我们的代码得以执行。深入理解这些基础概念对于理解作用域和闭包等更高级的内容至关重要。让我们开始吧。

每当你编写并运行 JavaScript 代码时,你都依赖于一个引擎来执行代码。这个引擎会因你所处的环境而异,甚至在同一环境的不同实现之间也会有所不同。例如,Chrome 浏览器和 Firefox 浏览器就使用不同的引擎(前者使用 V8,后者使用 SpiderMonkey)。

引擎负责接收并执行你的代码。它遵循一系列步骤——第一步是创建全局执行上下文。这个全局执行上下文通常是一个匿名函数,它充当运行你编写的所有代码的空间。

执行上下文

var a = 42;

function foo(num) {
  return num * num;
}

var b = foo(a);
Enter fullscreen mode Exit fullscreen mode

我们来看一段相当简单的代码。在这个例子中,我们给变量赋值一个数值a,声明一个函数,foo然后调用foo该函数a并传入一个参数,最后将函数的返回值存储到变量中b。如果我问你这段代码的执行结果是什么,我相信你肯定能轻松理解并给出正确答案。但是,如果我问JavaScript是如何得出这个结果的,你可能就无法直接回答了。让我们一起来探究这个问题的答案。

上述代码中,引擎首先会做的就是创建一个执行上下文。引擎会遵循一套精确的步骤,这个过程分为两个阶段:创建阶段和执行阶段。

代码首次运行时,Global Execution Context会创建一个实例。在创建阶段,引擎会执行以下几项操作:

  • 创建一个全局对象。例如,window在浏览器中或globalNode.js中可以调用此对象。
  • 创建一个this指向上面创建的对象的对象绑定。
  • 设置一个内存堆来存储变量和函数引用
  • 将函数声明存储在上面的内存堆中,并将上下文中的每个变量存储起来,并undefined赋予其值。

创建全局执行上下文

在我们的示例中,在创建阶段,引擎会存储变量ab函数声明foo,并会undefined为这两个变量赋值。

此阶段完成后,引擎进入执行阶段。在执行阶段,代码逐行运行。正是在此阶段,变量被赋值,函数被调用。

如果你的代码中没有函数调用,那么故事到此结束。但是,对于你调用的每个函数,引擎都会创建一个新的上下文Function Execution Context。这个上下文与上面的上下文相同,但这次创建的不是全局对象,而是一个包含所有传递给函数的参数的引用的参数对象。

回到上面的例子,在执行阶段,引擎首先会执行到变量声明部分a,并为其赋值42。然后,它会跳转到给变量赋值的那一行b。由于该行代码调用了一个函数,引擎会创建一个新的变量Function Execution Context,并重复上述步骤(这次会创建一个参数对象)。

但它是如何跟踪所有这些执行上下文的呢?尤其是在存在多个嵌套函数调用或条件语句的情况下?它如何知道哪个函数调用处于活动状态,哪个函数调用已经完全执行完毕?

这就很好地引出了我们的下一个概念——调用栈。

调用栈

调用栈是一种数据结构,用于跟踪和管理一段 JavaScript 代码中的函数执行。它的作用是存储代码执行期间创建的所有执行上下文,并记录当前所在的执行上下文以及栈上剩余的执行上下文。当你调用一个函数时,引擎会将该函数推到栈顶,然后创建一个执行上下文。从我们前面对执行上下文的探讨可知,这个上下文要么是全局上下文,要么是函数执行上下文。

每个函数运行时,调用栈都会将其弹出并继续执行下一个函数,直到调用栈为空,所有函数都已执行完毕。这种顺序被称为后进先出(LIFO

函数调用时,会创建一个栈帧。栈帧是内存中存储参数和变量的区域(还记得我们前面提到的内存堆吗?)。函数返回(无论是隐式返回还是显式返回)时,这部分内存会被清空,整个上下文也会从调用栈中弹出。

执行上下文在执行完毕后会逐个从堆栈中弹出,每个上下文都会创建一个堆栈帧。当我们抛出错误时,我们会得到所谓的堆栈跟踪,顾名思义,它会跟踪从错误发生点到我们经过的所有上下文的所有执行上下文。

调用栈也可能因为帧数超过栈的设计容量而导致栈溢出。这种情况可能发生在递归调用函数而没有设置退出条件时,或者像我们所有人都可能遇到的情况一样——运行无限循环时。

请看这段代码:

function thirdFunc() {
  console.log("Greetings from thirdFunc()");
}

function secondFunc() {
  thirdFunc();
  console.log("Greetings from secondFunc()");
}

function firstFunc() {
  secondFunc();
  console.log("Greetings from firstFunc()");
}

firstFunc();

// Greetings from thirdFunc()
// Greetings from secondFunc()
// Greetings from firstFunc()
Enter fullscreen mode Exit fullscreen mode

我们又是如何得到这样的结果的呢?

当我们运行这段代码时,引擎首先会访问调用栈,并将一个 ` main()or`函数添加global()到调用栈中。这是 JavaScript 代码的主执行线程。我们在上一节中描述的执行上下文会先进入创建阶段,然后才会进入执行阶段。当引擎在firstFunc()执行阶段执行到 `or` 函数时,会再次访问调用栈,并将 `or` 函数的执行上下文firstFunc()推到调用栈的顶部main()(如下步骤 2 所示)。

现在,由于引擎firstFunc()位于调用栈的顶部,它将开始执行。引擎会创建一个本地执行上下文,并分配本地内存来存储变量、参数和函数声明。(作用域的概念与此相关。)

这是第一行firstFunc()调用secondFunc()。此时,引擎会再次引用调用栈,并将调用secondFunc()栈顶的代码置于栈顶,重复上述过程。secondFunc()第一行代码再次引用了另一个函数thirdFunc(),这个过程又重复了一次。

现在thirdFunc(),我们不进行任何函数调用,而是简单地在控制台输出字符串“来自 thirdFunc() 的问候”。这段代码执行后,由于函数中没有更多指令,它会隐式返回。此时,调用栈弹出thirdFunc()(如上文步骤 4 所示),并secondFunc()到达栈顶。引擎将从我们上次中断的地方继续执行,并在控制台输出字符串“来自 secondFunc() 的问候”。同样,由于该函数中没有更多指令,它将返回,调用栈再次弹出,将secondFunc()我们带回执行上下文firstFunc(),我们继续执行并输出字符串“来自 firstFunc() 的问候”。执行完这段代码后,firstFunc()调用栈弹出,控制权返回到主执行上下文,该上下文中没有更多指令要执行,因此也会被弹出。一旦调用栈为空,程序就会停止运行。

调用栈的特性反映了 JavaScript 本质上是单线程的,一次只能运行一个执行上下文。这意味着当一个函数正在执行时,引擎无法同时运行另一个上下文。这也意味着,每次一个函数被压入调用栈时,它就成为当前活动的执行上下文,并将控制流从调用它的函数中转移出去,直到它显式(通过 ` return` 语句)或隐式(所有指令都已执行完毕)返回为止。return

如果故事就此结束,那么 JavaScript 除了最简单的应用之外就几乎毫无用处,更不用说在同时触发大量事件(例如用户输入、资源请求和 API 调用)的 Web 应用中了。每个事件都会阻塞其他事件,直到自身运行完毕。这意味着,当一个函数被调用时——例如向服务器请求图片——页面上的其他任何操作都无法进行,直到图片加载完成。如果在图片加载完成之前点击了链接,该事件也只有在图片加载完成后才会被处理。

那么,我们如何实现异步 JavaScript,从而营造出多件事同时发生的假象呢?这就需要用到事件循环了。

事件循环

正如我们上面看到的,JavaScript 引擎一次只能执行一件事。它从代码顶部开始,向下遍历,根据需要创建新的执行上下文,并将它们推入和弹出调用栈。

如果有一个阻塞函数需要很长时间才能执行,那么当该函数位于调用栈顶部时,浏览器将无法执行任何操作。不会有新的执行上下文或代码执行。这意味着即使是滚动和按钮点击等用户操作也无法生效。

相反,当某个函数可能需要很长时间才能完成时,我们通常会提供一个回调函数。这个回调函数封装了我们希望在阻塞操作(例如网络调用)完成后执行的代码。这样,我们就可以将控制权交还给 JavaScript 引擎,并将剩余的执行延迟到调用栈清空之后。这就是 JavaScript 中的异步概念。

让我们对之前的代码进行一些调整,使其能够应用这个新概念:

function thirdFunc() {
  setTimeout(function() {
    console.log("Greetings from thirdFunc()");
  }, 5000);
}

function secondFunc() {
  thirdFunc();
  console.log("Greetings from secondFunc()");
}

function firstFunc() {
  secondFunc();
  console.log("Greetings from firstFunc()");
}

firstFunc();

// Greetings from secondFunc()
// Greetings from firstFunc()
// approx. 5 seconds later...
// Greetings from thirdFunc()
Enter fullscreen mode Exit fullscreen mode

在上面的代码中,执行过程与之前的示例相同。然而,当引擎执行到第三个函数时,它不会立即将消息记录到控制台,而是setTimeout()调用浏览器环境提供的 API。该 API 接受一个“回调”函数,该函数将被存储在我们尚未讨论过的回调队列结构中。thirdFunc()然后,该 API 将完成执行,并将控制权返回给secondFunc()` get_callback` 函数。最后,至少firstFunc()5 秒后(下文将详细介绍),`get_callback` 函数中的消息才会记录到控制台。thirdFunc()

在 JavaScript 中,我们实现代码异步执行的机制是通过环境 API(Node 和浏览器都提供了一些 API 来向我们公开底层功能)、回调队列和事件循环。

通过这些额外的机制可以实现并发(或并发的假象)。

正如我们之前所说,调用栈用于跟踪当前正在执行的函数上下文,而回调队列则用于跟踪任何需要在稍后执行的执行上下文,例如传递给 `setTimeout` 函数的回调或 Node.js 的异步任务。在我们的代码被调用期间,事件循环会定期检查调用栈是否为空。一旦调用栈中所有执行上下文都已执行完毕,事件循环就会将第一个进入回调队列的函数添加到调用栈中以待执行。然后,它会重复此过程,不断检查调用栈和回调队列,并在调用栈为空时将回调队列中的函数添加到调用栈中。

还记得我们说过 `setTimeout` 回调函数会在调用 `setTimeout` 后“至少”等待 5 秒吗?这是因为 `setTimeout` 并非在超时结束后直接将代码插入调用栈,而是必须先将其传递给回调队列,然后等待事件循环在调用栈为空时将其放入调用栈。只要调用栈中还有未执行的元素,`setTimeout` 回调函数就不会执行。让我们详细了解一下。

我们的代码按上述方式运行,直到执行到thirdFunction`setTimeout` 函数。此时,`setTimeout` 函数被调用,从调用栈中移除并开始倒计时。代码继续执行 ` setTimeout`secondFuncfirstFunc`setTimeout` 函数,并依次在 `console.log` 中记录它们的消息。与此同时,`setTimeout` 函数的倒计时几乎立即结束——耗时 0 秒——但它无法将回调函数直接放入调用栈。相反,当倒计时结束时,它将回调函数传递给了回调队列。事件循环持续检查调用栈,但在此期间secondFunc,` firstFuncsetTimeout` 和 `setTimeout` 函数占用了调用栈的空间。直到这两个函数执行完毕,调用栈被清空后,事件循环才将传递给 `setTimeout` 函数的回调函数放入setTimeout调用栈中执行。

这就是为什么有时你会发现调用 `setTimeout` 来0延迟执行传递给它的回调函数中的代码。我们只是想确保所有其他同步代码在setTimeout回调函数中的代码之前运行。

还需要注意的是,“回调函数”是指由另一个函数调用的函数,但我们上面讨论的回调函数,例如传递给某个函数的回调函数,setTimeout是“异步回调函数”。区别在于,异步回调函数会被传递到回调队列中,等待事件循环将其放入调用栈中,以便在稍后执行。

至此,我们已经涵盖了 JavaScript 代码执行的主要概念,以及 JavaScript 引擎如何处理异步代码。我们已经了解到,JS 引擎是单线程的,只能同步执行代码。我们也了解了如何在不阻塞执行线程的情况下实现异步代码。此外,我们对函数的执行顺序以及相关的规则也有了更深入的理解。

这些概念可能比较复杂,但值得花时间真正掌握,因为它们是深入理解 JavaScript 的基础。这不仅包括语法,还包括对 JavaScript 执行这些语法时具体发生了什么的var a = 2整体理解。这些概念也是理解作用域和闭包等其他概念的基石。这类主题需要更多资源,欢迎深入研究以下内容:

文章来源:https://dev.to/thebabscraig/the-javascript-execution-context-call-stack-event-loop-1if1