CommonJS 模块

CommonJS 是最初内置于 Node.js 中的第一个模块系统。Node.js 的 CommonJS 实现遵循 CommonJS 规范,并添加了一些自定义扩展。

让我们总结一下 CommonJS 规范的两个主要概念:

  • require 是一个允许您从本地文件系统导入模块的函数

  • exports 和 module.exports 是特殊变量,可用于从当前模块导出公共功能

目前这些信息已经足够了; 我们将在接下来的几节中了解 CommonJS 规范的更多细节和一些细微差别。

自制的模块加载器

为了了解 CommonJS 在 Node.js 中的工作原理,让我们从头开始构建一个类似的系统。 下面的代码创建了一个函数,该函数模仿 Node.js 原始 require() 函数的功能子集。

让我们首先创建一个函数来加载模块的内容,将其包装到私有范围中,并对其进行计算:

function loadModule (filename, module, require) {
    const wrappedSrc =
    `(function (module, exports, require) {
        ${fs.readFileSync(filename, 'utf8')}
    })(module, module.exports, require)`
    eval(wrappedSrc)
}

模块的源代码本质上被包装到一个函数中,就像揭示(revealing)模块模式一样。 这里的区别在于我们将变量列表传递给模块,特别是 module、exports 和 require。 记下包装函数的 exports 参数如何使用 module.exports 的内容进行初始化,我们稍后将讨论这一点。

另一个需要提及的重要细节是我们使用 readFileSync 来读取模块的内容。 虽然通常不建议使用文件系统 API 的同步版本,但在这里这样做是有意义的。 原因是 CommonJS 中加载模块是故意的同步操作。 这种方法可以确保,如果我们导入多个模块,它们(及其依赖项)会按正确的顺序加载。 我们将在本章后面详细讨论这方面。

请记住,这只是一个示例,您很少需要在实际应用程序中执行某些源代码。 eval() 等功能或 vm 模块 (nodejsdp.link/vm) 的功能很容易以错误的方式或错误的输入使用,从而为系统提供代码注入攻击。 应始终极其小心地使用它们或完全避免使用它们。

现在让我们实现 require() 函数:

function require (moduleName) {
    console.log(`Require invoked for module: ${moduleName}`)
    const id = require.resolve(moduleName) // (1)
    if (require.cache[id]) { // (2)
        return require.cache[id].exports
    }

    // module metadata
    const module = { // (3)
        exports: {},
        id
    }

    // Update the cache
    require.cache[id] = module // (4)

    // load the module
    loadModule(id, module, require) // (5)

    // return exported variables
    return module.exports // (6)
}
require.cache = {}
require.resolve = (moduleName) => {
    /* resolve a full module id from the moduleName */
}

前面的函数模拟了 Node.js 中用于加载模块的原始 require() 函数的行为。当然,这只是出于教学目的,并不能准确或完全反映真正 require() 函数的内部行为,但了解 Node.js 模块系统的内部结构,包括模块是如何定义和加载的,还是很有帮助的。

我们自制模块系统的工作原理如下:

  1. 接受模块名称作为输入,我们要做的第一件事就是解析模块的完整路径,我们称之为 id。 这个任务委托给 require.resolve(),它实现了具体的解析算法(我们稍后会讲到)。

  2. 如果模块过去已经加载过,则它应该在缓存中可用。 如果是这种情况,我们会立即返回。

  3. 如果模块以前从未加载过,我们为第一次加载设置环境。 特别是,我们创建一个 module 对象,其中包含使用空对象字面值初始化的 exports 属性。 该对象将由模块的代码填充以导出其公共 API。

  4. 第一次加载后,module 对象被缓存。

  5. 从文件中读取模块源代码并对代码进行执行,如我们之前所见。 我们为模块提供刚刚创建的模块对象以及对 require() 函数的引用。 模块通过操作或替换 module.exports 对象来导出其公共 API。

  6. 最后,将代表模块公共 API 的 module.exports 的内容返回给调用者。

正如我们所见,Node.js 模块系统的工作原理并不神奇。诀窍在于我们为模块源代码创建的封装以及运行模块的人造环境。

定义模块

通过查看自定义 require() 函数的工作原理,我们现在应该能够理解如何定义模块。 下面的代码给了我们一个例子:

// load another dependency
const dependency = require('./anotherModule')

// a private function
function log() {
    console.log(`Well done ${dependency.username}`)
}

// the API to be exported for public use
module.exports.run = () => {
    log()
}

要记住的基本概念是模块内的所有内容都是私有的,除非将其分配给 module.exports 变量。 当使用 require() 加载模块时,该变量的内容将被缓存并返回。

module.exports vs exports

对于许多还不熟悉 Node.js 的开发人员来说,一个常见的混淆来源是使用 exports 和 module.exports 公开公共 API 之间的区别。 我们的自定义 require() 函数的代码应该再次消除任何疑问。 exports 变量只是对 module.exports 初始值的引用。 我们已经看到,这样的值本质上是在模块加载之前创建的简单对象字面值。

这意味着我们只能将新属性附加到 exports 变量引用的对象上,如以下代码所示:

exports.hello = () => {
    console.log('Hello')
}

重新给 exports 变量赋值没有任何效果,因为它不会更改 module.exports 的内容。 它只会重新分配变量本身。 因此下面的代码是错误的:

exports = () => {
    console.log('Hello')
}

如果我们想要导出对象字面值以外的东西,例如函数、实例,甚至字符串,我们必须重新分配 module.exports,如下所示:

module.exports = () => {
    console.log('Hello')
}

require 函数是同步的

我们应该考虑的一个非常重要的细节是我们自制的 require() 函数是同步的。 事实上,它使用简单的直接样式返回模块内容,并且不需要回调。 对于原始 Node.js require() 函数也是如此。因此,对 module.exports 的任何赋值也必须是同步的。例如,下面的代码是不正确的,会带来麻烦:

setTimeout(() => {
    module.exports = function() {...}
}, 100)

require() 的同步特性对我们定义模块的方式产生了重要影响,因为它限制了我们在定义模块时主要使用同步代码。这也是 Node.js 核心库提供同步 API 以替代大多数异步 API 的最重要原因之一。

如果我们需要对模块进行一些异步初始化步骤,我们总是可以定义并导出一个未初始化的模块,并在稍后时间对其进行异步初始化。但这种方法的问题在于,使用 require() 加载这样一个模块并不能保证它可以随时使用。在第 11 章 “高级配方” 中,我们将详细分析这个问题,并介绍一些优雅地解决这个问题的模式。

出于好奇,您可能想知道,在早期,Node.js 曾经有一个异步版本的 require(),但它很快就被删除了,因为它使实际上仅用于使用的功能过于复杂 在初始化时以及异步 I/O 带来的复杂性多于优势。

解析(resolving)算法

所谓 “依赖地狱”(dependency hell),是指程序的两个或多个依赖项反过来依赖于一个共享依赖项,但却需要不同的不兼容版本。Node.js 根据模块的加载位置加载不同版本的模块,从而优雅地解决了这一问题。该功能的所有优点都归功于 Node.js 包管理器(如 npm 或 yarn)组织应用程序依赖关系的方式,以及 require() 函数中使用的解析算法。

现在让我们来简要介绍一下这种算法。正如我们所看到的,resolve() 函数将模块名称(我们称之为 moduleName)作为输入,并返回模块的完整路径。然后,该路径将用于加载模块代码,并用于唯一标识模块。解析算法可分为以下三个主要分支:

  • 文件模块: 如果 moduleName 以 / 开头,则已被视为模块的绝对路径,并按原样返回。如果以 ./ 开头,则 moduleName 将被视为相对路径,从需要模块的目录开始计算。

  • 核心模块: 如果 moduleName 的前缀不是 / 或 ./,算法将首先尝试在 Node.js 核心模块中进行搜索。

  • 包模块: 如果没有找到与 moduleName 匹配的核心模块,则会继续搜索,在从需求模块开始向上浏览的目录结构中找到的第一个 node_modules 目录中寻找匹配模块。算法会继续在目录树的下一个 node_modules 目录中寻找匹配模块,直至到达文件系统的根目录。

对于文件和软件包模块,文件和目录都可以匹配 moduleName。具体而言,算法会尝试匹配以下内容:

  • <moduleName>.js

  • <moduleName>/index.js

  • 在 <moduleName>/package.json 的 main 属性中指定的目录/文件

解析(resolve)算法的完整、正式文档可以在 nodejsdp.link/resolve 中找到。

node_modules 目录实际上是软件包管理器为每个软件包安装依赖项的地方。这意味着,根据我们刚才描述的算法,每个软件包都可以有自己的私有依赖关系。例如,请看下面的目录结构:

myApp
├── foo.js
└── node_modules
        ├── depA
        │     └── index.js
        ├── depB
        │     ├── bar.js
        │     └── node_modules
        │             └── depA
        │                   └── index.js
        └── depC
            ├── foobar.js
            └── node_modules
                    └── depA
                          └── index.js

在上例中,myApp、depB 和 depC 都依赖于 depA。然而,它们都有自己的私有版本依赖关系!根据解析算法的规则,使用 require('depA') 会根据需要它的模块加载不同的文件,例如:

  • 从 /myApp/foo.js 调用 require('depA') 将加载 /myApp/node_modules/depA/index.js

  • 从 /myApp/node_modules/depB/bar.js 调用 require('depA') 将加载 /myApp/node_modules/depB/node_modules/depA/index.js

  • 从 /myApp/node_modules/depC/foobar.js 调用 require('depA') 将加载 /myApp/node_modules/depC/node_modules/depA/index.js

解析算法是 Node.js 依赖性管理强大功能背后的核心部分,它使得在一个应用程序中拥有数百甚至数千个软件包而不会出现碰撞或版本兼容性问题成为可能。

当我们调用 require() 时,解析算法会被透明地应用。 但是,如果需要,任何模块仍然可以通过简单地调用 require.resolve() 来直接使用它。

模块缓存

每个模块只有在第一次需要时才会被加载和执行,因为随后调用 require() 函数只会返回缓存版本。看看我们自制的 require() 函数的代码就会明白这一点。缓存对性能至关重要,但也有一些重要的功能影响:

  • 它使得模块依赖关系中存在循环成为可能

  • 在某种程度上,它保证当需要给定包中的相同模块时始终返回相同的实例

模块缓存通过 require.cache 变量公开,因此在需要时可以直接访问。一个常见的用例是通过删除 require.cache 变量中的相对键来使任何缓存模块失效,这种做法在测试时可能有用,但如果在正常情况下使用,则非常危险。

循环依赖

许多人认为循环依赖是一个固有的设计问题,但在实际项目中却有可能发生,因此至少让我们了解一下 CommonJS 是如何工作的,还是很有帮助的。如果我们再看一下自制的 require() 函数,就会立刻了解到它的工作原理和注意事项。

不过,让我们一起通过一个示例来看看 CommonJS 在处理循环依赖关系时的表现。假设我们遇到了图 2.1 所示的情况:

image 2024 05 04 22 31 18 997
Figure 1. 图 2.1:循环依赖的示例

名为 main.js 的模块需要 a.js 和 b.js。 反过来,a.js 需要 b.js。 但 b.js 也依赖 a.js! 很明显,我们这里有一个循环依赖,因为模块 a.js 需要模块 b.js,而模块 b.js 需要模块 a.js。 我们看一下这两个模块的代码:

  • 模块 a.js:

    exports.loaded = false
    const b = require('./b')
    module.exports = {
        b,
        loaded: true // overrides the previous export
    }
  • 模块 b.js:

    exports.loaded = false
    const a = require('./a')
    module.exports = {
        a,
        loaded: true
    }

现在,让我们看看 main.js 如何需要这些模块:

const a = require('./a')
const b = require('./b')
console.log('a ->', JSON.stringify(a, null, 2))
console.log('b ->', JSON.stringify(b, null, 2))

如果我们运行 main.js,我们将看到以下输出:

a -> {
    "b": {
        "a": {
            "loaded": false
        },
        "loaded": true
    },
    "loaded": true
}
b -> {
    "a": {
        "loaded": false
    },
    "loaded": true
}

这个结果揭示了 CommonJS 循环依赖的警告,也就是说,我们应用程序的不同部分对于模块 a.js 和模块 b.js 导出的内容会有不同的视图,具体取决于这些依赖项的加载顺序。 虽然这两个模块在 main.js 模块需要时都会完全初始化,但 a.js 模块在从 b.js 加载时将是不完整的。 特别是,它的状态将是需要 b.js 时所达到的状态。

为了更详细地了解幕后发生的事情,让我们逐步分析不同模块是如何解释的以及它们的本地范围如何变化:

image 2024 05 04 22 39 40 543
Figure 2. 图 2.2:Node.js 中如何管理依赖循环的直观表示

步骤如下:

  1. 处理从 main.js 开始,它立即需要 a.js

  2. 模块 a.js 做的第一件事就是将一个名为 “loaded” 的导出值设置为 false

  3. 此时模块 a.js 需要模块 b.js

  4. 与 a.js 一样,模块 b.js 所做的第一件事是将名为 loaded 的导出值设置为 false

  5. 现在,b.js 需要 a.js(循环)

  6. 由于 a.js 已经被遍历过了,所以它当前导出的值会立即复制到模块 b.js 的作用域中

  7. 模块 b.js 最后将加载的值更改为 true

  8. 现在 b.js 已完全执行,控制权返回到 a.js,a.js 现在在其自己的范围内保存了模块 b.js 当前状态的副本

  9. 模块 a.js 的最后一步是将其加载的值设置为 true

  10. 模块 a.js 现在已完全执行,控制权返回到 main.js,现在 main.js 在其内部范围内拥有模块 a.js 当前状态的副本

  11. main.js 需要 b.js,立即从缓存加载

  12. 模块 b.js 的当前状态被复制到模块 main.js 的范围中,我们最终可以看到每个模块状态的完整图片

正如我们所说,这里的问题是模块 b.js 具有模块 a.js 的部分视图,并且当 main.js 中需要 b.js 时,该部分视图就会传播。 这种行为应该会激发一种直觉,如果我们交换 main.js 中所需的两个模块的顺序,就可以确认这种直觉。 如果您实际尝试一下,您会发现这次 a.js 模块将收到不完整版本的 b.js。

我们现在明白,如果我们失去对首先加载哪个模块的控制,这可能会变得相当模糊,如果项目足够大,这种情况很容易发生。

在本章后面,我们将看到 ESM 如何以更有效的方式处理循环依赖。 同时,如果您使用 CommonJS,请非常小心这种行为及其影响您的应用程序的方式。

在下一节中,我们将讨论在 Node.js 中定义模块的一些模式。