探索 Node.js 源码,详解cjs 模块的加载过程

1. 背景

Node.js 是一个基于 V8 引擎的 JavaScript 运行环境,它可以使我们在服务器端构建高性能的网络应用程序。

在 Node.js 中,模块化是一个很重要的概念,因为它的模块化系统支持很多种不同的模块加载方式,而 CommonJS(以下简称 cjs)模块是其中最为常用的一种。

2. cjs 模块的加载过程

2.1 require 函数

在 Node.js 中,我们使用 require 函数来加载一个模块。

require 函数的定义如下:

function require(path) {

// ...

}

它接收一个参数:模块的路径,返回该模块导出的对象。

当我们在一个模块中调用 require 函数时,Node.js 会在特定的几个路径下查找该模块的源文件,并执行该文件。

通常,一个模块的路径可以是相对路径或绝对路径。当我们传入一个相对路径时,Node.js 会根据该模块的位置进行查找,例如:

const foo = require('./foo');

在上面的例子中,Node.js 会在当前模块的目录下查找 foo 模块。

当我们传入一个绝对路径时,Node.js 会直接使用该路径进行查找,例如:

const bar = require('/Users/username/projects/bar');

在上面的例子中,Node.js 会直接使用绝对路径 /Users/username/projects/bar 进行查找 bar 模块。

2.2 module 对象

在一个模块的源文件中,我们可以访问 module 对象。

module 对象是 Node.js 为每个模块创建的一个对象,其中包含该模块的一些信息,例如该模块的文件名、导出的对象等。

在一个模块的源文件中,我们可以通过在文件顶部添加如下代码来访问 module 对象:

console.log(module.filename); // 当前模块的文件名

console.log(module.exports); // 当前模块的导出对象

需要注意的是,module.exports 对象是每个模块导出的对象,该对象的内容会被 require 函数返回给调用方。

2.3 匿名包装函数

在 Node.js 的实现中,每个模块都被包装在一个匿名包装函数中。

这个函数接收 5 个参数:exports、require、module、__filename、__dirname,分别对应导出对象、require 函数、module 对象、当前模块的文件名、当前模块的目录名。

当我们在一个模块中定义变量或函数时,它们都会成为该匿名包装函数的局部变量,不能被其他模块直接访问。

例如,我们在一个模块中定义如下变量:

const x = 1;

对于其他模块来说,变量 x 是不可见的。

2.4 cjs 模块的加载过程

cjs 模块的加载过程可以大致分为以下几个步骤:

解析模块的路径

查找模块的源文件

读取模块的源文件

将模块的代码包装在一个匿名包装函数中

执行匿名包装函数,返回该模块的导出对象

下面我们来详细地说明一下这些步骤。

2.4.1 解析模块的路径

当我们在一个模块中调用 require 函数时,Node.js 会首先根据该模块的路径解析出该模块的绝对路径。

例如,当我们在一个模块中调用 require('./foo') 时,Node.js 会根据当前模块的目录和 './foo' 得出 foo 模块的绝对路径,例如:

const path = require('path');

const filename = module.filename;

const basepath = path.dirname(filename);

const filepath = path.resolve(basepath, './foo');

需要注意的是,模块的解析规则是可以被修改的。在 Node.js 中,可以通过设置 module.paths 属性来修改模块的查找路径,例如:

module.paths.push('/my/path');

上面的代码会将 /my/path 添加到模块的查找路径中,使 Node.js 在查找模块时也会在该目录下查找模块。

2.4.2 查找模块的源文件

当 Node.js 解析出模块的绝对路径后,它会在一系列路径中查找该模块的源文件,具体的路径列表是通过 module.paths 属性定义的。

在默认情况下,Node.js 会在如下的路径列表中查找模块的源文件:

当前模块的目录

当前模块的父目录

当前模块的上级目录

...

全局模块目录

需要注意的是,Node.js 提供了多种方式来修改模块的查找路径,例如:

添加 NODE_PATH 环境变量

修改 require.main.paths 属性

在 require 函数中指定 paths 参数

2.4.3 读取模块的源文件

当 Node.js 找到一个模块的源文件后,它会读取该文件的内容,并将其包装在一个匿名包装函数中。

具体来说,Node.js 会使用 fs 模块中的 fs.readFileSync 函数来读取模块的源文件,例如:

const fs = require('fs');

const content = fs.readFileSync(filepath, 'utf8');

2.4.4 包装模块的代码

当 Node.js 读取模块的源文件后,它会使用一个模板函数来将源代码包装在一个匿名包装函数中,例如:

(function(exports, require, module, __filename, __dirname) {

// 模块的源代码

});

需要注意的是,在模块中定义的变量和函数都会成为该匿名包装函数的局部变量。

例如,当我们在一个模块中定义如下函数时:

function foo() { return 'foo'; }

该函数是成为匿名包装函数的局部变量,其他模块无法访问它。

2.4.5 执行匿名包装函数

当 Node.js 将模块的代码包装在了一个匿名包装函数中后,它会立即执行该函数,并将函数的返回值作为模块的导出对象。

例如,当我们在一个模块中导出如下对象时:

module.exports = { foo: 'bar' };

该对象会被读取并返回给 require 函数调用方。

需要注意的是,由于每个模块都是通过一个匿名包装函数来进行包装和执行的,在一个模块中定义的变量和函数都是该函数的局部变量,所以其他模块无法直接访问它们。

3. 总结

在 Node.js 中,模块是一个重要的概念,而 cjs 是其中最为常用的一种模块加载方式。

正确地理解 cjs 模块的加载过程对于编写高质量的 Node.js 应用程序至关重要。

在本文中,我们介绍了 cjs 模块的加载过程,包括 require 函数、module 对象、匿名包装函数以及模块的查找规则等内容。

希望本文能够对大家理解 Node.js 的模块化有所帮助。