Node.js 底层原理 #2 - 理解 JavaScript

2025-05-28

Node.js 底层原理 #2 - 理解 JavaScript

在我们之前的文章中,我们讨论了一些关于 C++ 的内容,包括什么是 Node.js、什么是 JavaScript、它们的故事、它们的起源以及现在的情况。我们还讨论了 Node.js 中文件系统函数的具体实现,以及 Node.js 是如何划分组件的。

现在,让我们进入本系列的第二篇文章,在本文中我们将探讨 JavaScript 的一些方面。

JavaScript 底层原理

让我们把事情理顺。现在,我们大致了解了在 Node.js 中编写的所有乱码底层运行的实际 C++ 代码。由于 JavaScript 是 Node.js 的最高层组件,我们首先要问的是,我们的代码是如何运行的?JavaScript 究竟是如何工作的?

大多数人实际上都知道一些单词并不断重复它们:

  • JavaScript 是单线程的
  • V8 为 Chrome JavaScript 引擎提供动力
  • JavaScript 使用回调队列
  • 存在某种事件循环

但他们是否深入探究过这些问题?

  • 单线程是什么意思?
  • JS 引擎到底是什么?V8 又是什么?
  • 这些回调队列是如何工作的?只有一个队列吗?
  • 什么是事件循环?它是如何工作的?谁提供了它?它是 JS 的一部分吗?

如果您能够回答其中 2 个以上的问题,那么您可以认为自己已经高于平均水平,因为大多数 JavaScript 开发人员一般甚至不知道这种语言背后有什么……但是,不要害怕,我们会帮助您,所以让我们更深入地了解 JavaScript 的概念以及它是如何工作的,最重要的是,为什么其他人会欺负它。

JavaScript 引擎

如今,最流行的 JavaScript 引擎是 V8(继 Git 之后,人类有史以来最优秀的软件之一)。这源于一个简单的事实:最常用的浏览器是 Chrome,或者像 Opera、Brave 等等一样,基于 Chromium(Chrome 的开源浏览器引擎)……然而,Chromium 并非唯一的引擎。我们有微软为 Edge 浏览器编写的 Chakra,有 Netscape 编写的 SpiderMonkey(现在为 Firefox 提供支持),还有许多其他引擎,例如 Rhino、KJS、Nashorn 等等。

但是,由于 Chrome 和 Node.js 都使用 V8,所以我们坚持使用它。它的外观非常简化,如下所示:

图片来自 Session Stack,参考文献

该引擎主要由两个部分组成:

  • 内存:所有内存分配发生的地方
  • 调用堆栈:我们的代码被构建和堆叠以执行

我们稍后会单独发布一篇关于 V8 的文章

JavaScript 运行时

开发者使用的大多数 API 都是引擎本身提供的,就像我们在前面章节编写代码时看到的那样readFile。然而,我们使用的一些 API 并非由引擎提供,例如setTimeout任何类型的 DOM 操作,document甚至是 AJAX(XMLHttpRequest对象)。这些 API 从何而来?让我们将之前的想象带入我们生活的残酷现实:

图片来自 Session Stack,链接见参考资料

引擎只是构成 JavaScript 的一小部分,嗯……JavaScript……浏览器提供了一些 API,我们称之为Web API——或者称为外部 API——这些 API(例如DOMAJAXsetTimeout)由浏览器供应商提供——在本例中,对于 Chrome 来说,是 Google——或者由运行时本身提供,例如 Node(具有不同的 API)。而这正是大多数人讨厌(并且仍然讨厌)JavaScript 的主要原因。当我们审视当今的 JavaScript 时,我们会看到它充斥着各种包和其他东西,但各方面都大同小异。嗯……它并非一直如此。

回想当年,在 ES6 和 Node.js 的概念出现之前,浏览器端如何实现这些 API 并没有达成共识,所以每个厂商要么有自己的实现,要么没有……这意味着我们必须不断检查和编写仅适用于特定浏览器(还记得 IE 吗?)的代码,这样某个浏览器的实现就可能XMLHttpRequest与其他浏览器略有不同,或者该setTimeout函数在某些实现中被命名sleep;在最坏的情况下,API 甚至根本不存在。这种情况正在逐渐改变,现在,值得庆幸的是,我们已经就哪些 API 应该存在以及如何实现它们达成了一些共识和一致,至少是最常用和最基本的 API 是这样。

除此之外,我们还有臭名昭著的事件循环和回调队列。我们稍后会讨论。

调用堆栈

大多数人都听说过 JS 是一门单线程语言,并且把它当成了宇宙的终极真理,却从未真正理解其中的奥秘。单线程意味着我们只有一个调用堆栈,换句话说,我们一次只能执行一件事。

调用堆栈不是 JavaScript 本身的一部分,而是其引擎的一部分,在我们的例子中是 V8。但我把它放在这里,是为了让我们了解流程中事物应该如何运作。

关于堆栈

堆栈是一种抽象数据类型,用于表示元素的集合。“堆栈”这个名称源于一个比喻,即一堆堆叠在一起的盒子。虽然从堆栈顶部取出一个盒子很容易,但取出更深的盒子可能需要先取出其他几个物品。

堆栈有两种主要方法:

  • push:向集合中添加另一个元素
  • pop:删除最近添加的、尚未从堆栈中删除的元素并返回其值

关于栈,需要注意的一点是,元素的入栈和出栈顺序非常重要。在栈中,元素出栈的顺序称为LIFO (后进先出) ,这是L ast I n First O ut的缩写,其含义不言而喻。

此外,我们可以使用另一种方法peek,该方法读取最近添加的项目(堆栈顶部)而不删除它。

关于堆栈,我们需要了解以下主题:

  • 它们是一种数据结构,堆栈中的每个项目都包含一个值,在我们的例子中,是一条指令或调用
  • 新的项目(调用)被添加到堆栈顶部
  • 移除的物品也会从堆栈顶部移除

堆栈和 JavaScript

简单来说,在 JS 中,栈记录了程序当前执行的位置。如果我们进入并调用一个函数,我们会将该调用放在栈顶。从函数返回后,我们会弹出栈顶。这些调用被称为栈帧 (Stack Frame)

让我们以一个简单的程序作为第一个例子,它与我们之前的程序不同:



function multiply (x, y) {
    return x * y
}

function printSquare (x) {
    const s = multiply(x, x)
    console.log(s)
}

printSquare(5)


Enter fullscreen mode Exit fullscreen mode

readFile当我们把所有部分粘合在一起后,我们将运行我们的代码

当引擎运行代码时,一开始调用栈是空的。每执行完一步,调用栈都会填充以下内容:

让我们一点一点来看一下:

  • 步骤 0(未显示)是空栈,这意味着我们程序的最开始
  • 第一步,我们添加第一个函数调用。调用printSquare(5),因为所有其他行都只是声明。
  • 第二步,我们进入printSquare函数定义
    • 看看我们如何调用const s = multiply(x, x),所以让我们将添加multiply(x, x)到堆栈顶部
    • 稍后,我们进入multiply,没有函数调用,堆栈中没有任何内容被添加。我们只是计算x * y并返回它。
    • 返回意味着函数已经运行完毕,因此我们可以将其从堆栈中弹出
  • 在步骤 3 中,我们不再有引用 ​​的堆栈框架multiply(x, x)。现在让我们继续执行上一行之后的行,也就是console.log
    • console.log是一个函数调用,让我们添加到堆栈顶部
    • 运行后console.log(s),我们可以将其从堆栈中弹出
  • 在步骤 4 中,我们现在只有一个堆栈框架:printSquare(5),这是我们添加的第一个
    • 因为这是第一个函数调用,并且它之后没有其他代码,所以这意味着函数已经完成。将其从堆栈中弹出
  • 步骤 5 等于步骤 0,即空栈

堆栈正是异常抛出时构建堆栈跟踪的方式。堆栈跟踪基本上是异常发生时打印出来的调用堆栈状态:



function foo () {
    throw new Error('Exception');
}

function bar () {
    foo()
}

function start () {
    bar()
}

start()


Enter fullscreen mode Exit fullscreen mode

这应该打印类似以下内容:



Uncaught Error: Exception foo.js:2
    at foo (foo.js:2)
    at bar (foo.js:6)
    at start (foo.js:10)
    at foo.js:13


Enter fullscreen mode Exit fullscreen mode

这些at短语只是我们的调用堆栈状态。

堆栈溢出

不,这个堆栈并非以该网站命名,很抱歉。实际上,该网站是以计算诞生以来编程中最常见的错误之一命名的:堆栈溢出。

当达到最大调用堆栈大小时,就会发生堆栈溢出错误。堆栈是一种数据结构,这意味着它们分配在内存中,而内存不是无限的,所以这种情况很容易发生,尤其是在未经过清理的递归函数中,例如:



function f () {
  return f()
}

f()


Enter fullscreen mode Exit fullscreen mode

每次调用时,f我们都会把数据堆积f在栈中,但是,正如我们所见,在数据执行到结束之前,也就是代码执行到没有函数被调用的时候,我们永远无法从栈中移除数据。因此,由于没有终止条件,我们的栈会被烧毁:

值得庆幸的是,引擎正在监视我们,并意识到该函数会一直调用自身,从而导致堆栈溢出。这是一个非常严重的错误,因为它会导致整个应用程序崩溃。如果不停止,可能会导致整个运行时崩溃或损坏堆栈内存。

单线程的优缺点

在单线程环境中运行可以非常自由,因为它比在多线程环境中运行要简单得多,因为在多线程环境中我们必须担心竞争条件和死锁。在单线程环境中,这些事情根本不存在,毕竟我们一次只做一件事。

然而,单线程也存在很大的局限性。我们只有一个堆栈,如果这个堆栈被一些运行缓慢的代码阻塞了,会发生什么?

这就是我们将在下一篇文章中发现的内容……

文章来源:https://dev.to/_staticvoid/node-js-under-the-hood-2-understanding-javascript-48cn
PREV
Node.js 底层原理 #3 - 深入探究事件循环
NEXT
Node.js 底层原理 #1 - 了解我们的工具