Node.js 中的 CommonJS 模块
CommonJS modules
CommonJS 模块是 Node.js 打包 JavaScript 代码的原始方式。 Node.js 还支持浏览器和其他 JavaScript 运行时使用的 ECMAScript 模块 标准。
在 Node.js中,每个文件都被视为一个单独的模块。例如:
// foo.js 文件
const circle = require('./circle.js');
console.log(`The area of a circle of radius 4 is ${circle.area(4)}`);
foo.js
文件中加载与 foo.js
在同一目录下的 circle.js
模块。
以下是 circle.js
文件的内容:
const { PI } = Math;
exports.area = (r) => PI * r ** 2;
exports.circumference = (r) => 2 * PI * r;
模块 circle.js
导出了函数 area()
和 circumference()
。通过在特殊对象 exports
上指定附加属性,将函数和对象添加到模块的根。
模块的本地变量将是私有的,因为模块被 Node.js
包装在一个函数中(可参阅 模块包装器)。在此示例中,变量 PI
是 circle.js
私有的。
module.exports
属性可以赋值一个新值(例如函数或对象)。
如下,bar.js
使用了 square
模块,它导出了一个 Square 类:
const Square = require('./square.js');
const mySquare = new Square(2);
console.log(`The area of mySquare is ${mySquare.area()}`);
square.js
定义如下:
// 赋值给 exports 不会修改模块,必须赋值给 module.exports
module.exports = class Square {
constructor(width) {
this.width = width;
}
area() {
return this.width ** 2;
}
};
CommonJS 模块系统在 module
core module 中实现。
模块识别
Node.js 有两个模块系统:CommonJS 模块和 ECMAScript 模块 。
默认情况下,Node.js 会将以下模块视为 CommonJS模块:
-
扩展名为
.cjs
的文件; -
扩展名为
.js
的文件,并且最接近的父package.json
文件,包含一个顶层字段 "type" ,值为"commonjs"
。 -
扩展名为
.js
的文件,并且最近的父package.json
文件没有包含一个顶层字段 "type" ,但是包的package.json
包含了 "type",值为"commonjs"
包作者应该包含 "type" 字段,即使所有源代码都是 CommonJS 形式的包。通过明确指定包的类型,可以使构建工具和加载器更容易确定解释包中的文件。
-
扩展名不是
.mjs
、.cjs
、.json
、.node
或.js
的文件(当最近的父package.json
文件包含顶级 "type" 字段, 值为"module"
的时,这些文件仅当它们通过require()
包含时才会被识别为 CommonJS 模块,而不是用作程序的命令行入口点时)。
更多详细信息,请参见 Determining module system。
调用 require()
总是使用 CommonJS 模块加载器。调用 import()
总是使用 ECMAScript 模块加载器。
访问主模块
当一个文件直接从 Node.js 运行时,require.main
被设置到它的 module
。这意味着可以通过测试 require.main === module
来判断一个文件是否已经被直接运行。
例如:
// foo.js 文件
console.log(require.main === module);
如果通过 node foo.js
运行则为 true
,但如果通过 require('./foo')
运行则为 false
。
当入口点不是 CommonJS 模块时,require.main
为 undefined
。
包管理器技巧
Node.js require()
函数的语义被设计得足够通用,以支持合理的目录结构。dpkg
、rpm
和 npm
等包管理程序,将有望发现无需修改即可从 Node.js 模块构建本机包的可能性。
下面我们给出一个可行的建议目录结构:
假设我们想让 /usr/lib/node/<some-package>/<some-version>
的文件夹保存特定版本包的内容。
包之间可以相互依赖。为了安装 foo
包,可能需要安装特定版本的 bar
包。bar
包本身可能有依赖关系,在某些情况下,它们甚至可能会发生冲突或形成循环依赖关系。
因为 Node.js 会查找它加载的任何模块的 realpath
路径(也就是说,它解析符号链接),然后 在 node_modules 文件夹中查找它们的依赖项,所以可以使用以下架构解决这种情况:
/usr/lib/node/foo/1.2.3/
:foo
包的内容,版本 1.2.3/usr/lib/node/bar/4.3.2/
:foo
所依赖的bar
包的内容/usr/lib/node/foo/1.2.3/node_modules/bar
:指向/usr/lib/node/bar/4.3.2/
的符号链接。/usr/lib/node/bar/4.3.2/node_modules/*
:bar
依赖的包的符号链接。
因此,即使遇到循环依赖,或者存在依赖冲突,每个模块都将能够获得它可以使用的依赖版本。
当 foo
包中的代码确实 require('bar')
时,它将获得符号链接到 /usr/lib/node/foo/1.2.3/node_modules/bar
的版本。然后,当 bar
包中的代码调用 require('quux')
时,它将获得符号链接到 /usr/lib/node/bar/4.3.2/node_modules/quux
的版本。
此外,为了使模块查找过程更加优化,我们可以将它们放在 /usr/lib/node_modules/<name>/<version>
中,而不是直接将包放在 /usr/lib/node
中。这样 Node.js 就不会在 /usr/node_modules
或 /node_modules
中寻找缺失的依赖项了。
为了使模块可用于 Node.js REPL,将 /usr/lib/node_modules
文件夹添加到 $NODE_PATH
环境变量中可能是有用的。由于使用 node_modules
文件夹的模块查找都是相对的,并且基于调用 require()
的那个文件的真实路径,包本身可以在任何地方。
符号链接(Symbolic Links),也被称为软链接(Soft Links),是计算机文件系统中的一种特殊类型的文件。它们创建了一个指向另一个文件或目录的链接。
.mjs
扩展名
由于 require()
的同步特性,不可能用它来加载 ECMAScript 模块文件。尝试这样做会抛出 ERR_REQUIRE_ESM
错误。请改用 import()
。
.mjs
扩展名无法通过 require()
加载,是为 ECMAScript 模块保留的。关于哪些文件被解析为 ECMAScript 模块,更多信息请参阅 Determining module system。
require() 算法
要获取调用 require()
时将加载的确切文件名,请使用 require.resolve()
函数。
以下是将以上所有内容放在一起,require()
的高级算法伪代码:
参考: Node.js 解析算法概述
缓存
模块在第一次加载后被缓存。这意味着多次调用 require('foo')
,如果它们都解析为同一个文件的话,那么它们是同一个对象,走的是缓存。模块代码不会多次执行,只会在第一次加载的时候执行。
这是一个重要的特征。有了它,就可以返回 "部分完成" 的对象,从而允许加载传递依赖项,即使它们会导致循环。(可参考:循环 require 调用)
要让一个模块多次执行代码,请导出一个函数,然后调用该函数。
模块缓存注意事项
模块根据其解析的 文件名(就是绝对路径)进行缓存。由于模块可能会根据调用模块的位置(从 node_modules
文件夹加载)解析为不同的文件名,因此不能保证 require('foo')
将始终返回同一个对象。
此外,在不区分大小写的文件系统或操作系统上,不同的解析文件名可以指向同一个文件,但缓存仍会将它们视为不同的模块,并将多次重新加载文件。例如,require('./foo')
和 require('./FOO')
返回两个不同的对象,无论 ./foo
和 ./FOO
是否是同一个文件。
核心模块
Node.js 有几个模块编译成二进制文件。这些模块在 node.js 文档有更详细的描述。
核心模块在 Node.js 源代码中定义,位于 lib/
文件夹中。
核心模块可以使用 node:
前缀来识别,在这种情况下它会绕过 require
缓存。例如,require('node:http')
将始终返回内置的 HTTP 模块,即使存在该名称的 require.cache
记录也是如此。
可以在不使用 node:
前缀的情况下加载核心模块,将其标识符传递给 require()
,则始终优先加载某些核心模块。例如,require('http')
将始终返回内置的 HTTP 模块,即使存在该名称的文件也是如此。而且核心模块列表被暴露在 module.builtinModules 属性上。
const http = require("node:http");
// 或
const http = require("http")
循环 require
调用
当存在循环 require()
调用时,模块在返回时可能尚未完成执行。
考虑以下情景:
a.js
console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
b.js
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
main.js
console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);
当 main.js
加载 a.js
时,a.js
中加载 b.js
。此时,b.js
尝试加载 a.js
。为了防止无限循环,将 a.js
导出对象的未完成副本返回给 b.js
模块。然后 b.js
完成加载,并将其 exports
对象提供给 a.js
模块。
当 main.js
加载两个模块时,它们都已完成。因此,该程序的输出将是:
$ node main.js
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true
需要仔细规划,使循环模块依赖项在应用程序中正常工作。
文件模块
如果找不到确切的文件名,那么 Node.js 将尝试加载所需的文件名并添加扩展名:.js
、.json
,最后是 .node
。加载具有不同扩展名(例如 .cjs
)的文件时候,必须将其全名传递给 require()
,包括其文件扩展名(例如 require('./file.cjs')
)。
.json
文件被解析为 JSON 文本文件,.node
文件被解释为使用 process.dlopen()
加载的编译插件模块。使用任何其他扩展名(或根本没有扩展名)的文件被解析为 JavaScript 文本文件。请参阅 确定模块系统,了解将使用什么解析目标
以 "/"
为前缀的模块必须是文件的绝对路径。例如,require('/home/marco/foo.js')
将加载位于 /home/marco/foo.js
的文件。
以 "./"
为前缀的模块必须与调用 require()
的文件相关。也就是说,circle.js
必须与 foo.js
位于同一目录中,以便 require('./circle')
找到它。
如果没有前缀 "/"
、"./"
或 "../"
来指示文件,则该模块必须是核心模块 或 从 node_modules
文件夹加载。
如果给定路径不存在,require()
将抛出 MODULE_NOT_FOUND 错误。
文件夹模块
可以通过三种方式将文件夹作为参数传递给 require()
。
首先是在文件夹的根目录下创建一个package.json 文件,里面指定了一个 main
模块。package.json
文件可能如下所示:
{ "name" : "some-library",
"main" : "./lib/some-library.js" }
如果它位于 ./some-library
的文件夹中,则 require('./some-library')
将尝试加载 ./some-library/lib/some-library.js
。
如果目录中不存在 package.json
文件,或者 "main"
字段丢失或无法解析,那么 Node.js 将尝试从该目录加载 index.js
或 index.node
文件。例如:
./some-library/index.js
./some-library/index.node
如果这些尝试失败,则 Node.js 将报告整个模块丢失并显示默认错误:
Error: Cannot find module 'some-library'
在上述三种情况下, import('./some-library')
调用将导致 ERR_UNSUPPORTED_DIR_IMPORT 错误。也可以使用包的 子路径导出 或 子路径导入 可以提供与文件夹作为模块一样的配置优势,并且适用于 require
和 import
。
从 node_modules 文件夹加载
如果传递给 require()
的模块标识符不是 核心模块 ,并且不是以 '/'
、'../'
或 './'
开头,则 Node.js 从当前模块的目录开始,并且添加 /node_modules
,并尝试从该位置加载模块。Node.js 不会将 node_modules
附加到已经以 node_modules
结尾的路径。
如果没有找到,那么它将移动到父目录,依此类推,直到到达文件系统的根目录。
例如,如果位于 "/home/ry/projects/foo.js"
的文件调用了 require('bar.js')
,那么 Node.js 将按以下顺序查找以下位置:
/home/ry/projects/node_modules/bar.js
/home/ry/node_modules/bar.js
/home/node_modules/bar.js
/node_modules/bar.js
这允许程序本地化它们的依赖关系,这样它们就不会发生冲突。
通过在模块名称后包含路径后缀,可以引入模块中特定的文件或子模块。例如 require('example-module/path/to/file')
将解析 path/to/file
相对于 example-module
所在的位置。后缀路径遵循相同的模块解析语义。换句话说,可以通过在模块名后面添加路径后缀,精确指定需要引入的文件或子模块。
从全局文件夹加载
如果 NODE_PATH
环境变量设置为以冒号分隔的绝对路径列表,那么如果在其他地方找不到模块,Node.js 将在这些路径中搜索模块。
在 Windows 上,NODE_PATH
由分号 (;) 而非冒号分隔。
在定义当前 模块解析算法 之前,NODE_PATH
最初是为了支持从不同路径加载模块而创建的。
NODE_PATH
仍然受支持,但由于 Node.js 生态系统已经确定了定位依赖模块的约定,因此不再那么必要了。有时,当人们没有意识到必须设置 NODE_PATH
时,依赖于 NODE_PATH
的部署会表现出令人惊讶的行为。有时模块的依赖关系会发生变化,导致在搜索 NODE_PATH
时加载不同的版本(甚至不同的模块)。
此外,Node.js 将在以下 GLOBAL_FOLDERS
列表中搜索:
- 1:
$HOME/.node_modules
- 2:
$HOME/.node_libraries
- 3:
$PREFIX/lib/node
其中 $HOME
是用户的主目录(home),$PREFIX
是 Node.js 配置的 node_prefix
。
这些主要是出于历史原因。
强烈建议将依赖项放在本地 node_modules
文件夹中。将会加载得更快,更可靠。
模块包装器
在执行模块的代码之前,Node.js 将使用如下所示的函数包装器对其进行包装:
(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});
通过这样做,Node.js 实现了一些事情:
- 它将顶级变量(用
var
、const
或let
定义)的范围限制在模块而不是全局对象中。 - 它有助于提供一些实际上特定于模块的全局变量,例如
module
和exports
对象,实现者可以使用这些对象从模块中导出值。- 便利变量
__filename
和__dirname
,包含模块的绝对文件名和目录路径。
模块作用域
__dirname
类型: string
当前模块的目录名,指的是模块所在的绝对路径目录。与 __filename 应用于 path.dirname() 相同。
示例:从 /Users/mjr
运行 node example.js
const path = require('node:path');
console.log(__dirname);
// Prints: /Users/mjr
console.log(path.dirname(__filename) === __dirname);
// true
__filename
类型:string
当前模块的文件名。这是经过符号链接解析的当前模块文件的绝对路径。
对于主程序,这不一定与命令行中使用的文件名相同。
示例:从 /Users/mjr
运行 node example.js
console.log(__filename);
// Prints: /Users/mjr/example.js
给定两个模块:a
和 b
,其中 b
是 a
的依赖项,目录结构为:
/Users/mjr/app/a.js
/Users/mjr/app/node_modules/b/b.js
在 b.js
中引用 __filename
,将返回 /Users/mjr/app/node_modules/b/b.js
,而在 a.js
中引用 __filename
,将返回 /Users/mjr/app/a.js
。
exports
类型:object
exports
是 module.exports
的简写。有关何时使用 exports
以及何时使用 module.exports
的详细信息,请参阅 exports 快捷方式 部分。
module
类型:module
当前模块的引用,可参阅 模块对象 部分。特别是,module.exports
用于定义模块导出和提供的内容,并通过 require()
使其可用。
require(id)
- 参数
id
:string
,模块名称或路径 - 返回值:
any
,导出模块内容
用于导入模块、JSON
、本地文件。模块可以从 node_modules
中导入。可以使用相对路径(例如 ./
、./foo
、./bar/baz
、../foo
)来导入本地模块和 JSON
文件,这些路径将根据 __dirname
(如果定义)或当前工作目录命名的目录进行解析。POSIX
风格的相对路径以与操作系统无关的方式解析,这意味着上述示例在 Windows
和 Unix
系统上的行为方式相同。
// 导入一个本地模块,其路径相对于 `__dirname`
// 或当前工作目录 (在 Windows 上, 这将解析为 .\path\myLocalModule.)
const myLocalModule = require('./path/myLocalModule');
// 导入 JSON 文件:
const jsonData = require('./path/filename.json');
// 从 node_modules 或 Node.js 内置模块中导入模块:
const crypto = require('node:crypto');
require.cache
类型:object
当模块被 require
时,它们会被缓存在这个对象中。从该对象中删除指定模块键值,下一次的 require
将重新加载该模块。但对于 native addons,重新加载会导致错误。
还可以添加或替换缓存记录。在进行模块加载时,Node.js 会先检查这个缓存对象,然后再检查内置模块。如果缓存中存在与内置模块名称匹配的名称,那么只有以 node:
为前缀的 require
调用才会接收到内置模块。这是为了确保内置模块的加载顺序和行为不受到缓存的影响。然而,使用这个特性需要小心,确保正确处理内置模块的加载和使用。
const assert = require('node:assert');
const realFs = require('node:fs');
const fakeFs = {};
require.cache.fs = { exports: fakeFs };
assert.strictEqual(require('fs'), fakeFs);
assert.strictEqual(require('node:fs'), realFs);
require.extensions
注意!已弃用
类型:object
指导 require
如何处理某些文件扩展名。
将扩展名为 .sjs
的文件处理为 .js
:
require.extensions['.sjs'] = require.extensions['.js'];
此 require.extensions
对象会按需编译非 JavaScript 模块加载到 Node.js 中。然而,在实践中,有更好的方法可以做到这一点,例如通过其他一些 Node.js 程序加载模块,或者提前将它们编译成 JavaScript。这样做的好处是可以充分利用 Node.js 的生态系统和工具链,以及 JavaScript 的广泛支持和优化。可以提高模块的性能、可维护性和跨平台兼容性。
避免使用 require.extensions
。使用可能会导致细微的错误,并且每个注册的扩展都会导致解析扩展变慢。
require.main
类型:module | undefined
Module
对象表示在 Node.js 进程启动时加载的入口脚本,如果程序的入口点不是 CommonJS 模块,则为 undefined
。请参阅 "Accessing the main module"。
entry.js
脚本:
console.log(require.main);
node entry.js
Module {
id: '.',
path: '/absolute/path/to',
exports: {},
filename: '/absolute/path/to/entry.js',
loaded: false,
children: [],
paths:
[ '/absolute/path/to/node_modules',
'/absolute/path/node_modules',
'/absolute/node_modules',
'/node_modules' ] }
require.resolve(request[,options])
- 参数
request
:string
,要解析的模块路径 - 参数
options
:object
paths
属性:string[]
, 将按照给定的路径顺序逐个尝试解析模块,找到后停止解析,舍弃后面路径。当这些路径存在时,它们将代替默认的解析路径来确定模块的位置,全局文件夹 除外,如$HOME/.node_modules
,它们始终被包括在内。这些路径中的每一个路径都用作模块解析算法的起点,意味着如果request
为非相对路径,则会从这些路径开始检查node_modules
的层次结构。
- 返回值:
string
全局文件夹 始终被包括在解析路径中。这是为了兼容全局安装的模块,使得全局安装的模块可以在任何位置被正确地解析和引用。
使用内部 require()
机制来查找模块的位置,而不是加载模块,只返回解析文件的绝对路径。
如果找不到模块,则抛出 MODULE_NOT_FOUND
错误。
// 解析内置模块的路径:
const path = require.resolve('fs');
console.log(path); // 输出内置模块 'fs' 的绝对路径
// 解析 npm 安装的模块的路径:
const path = require.resolve('lodash');
console.log(path); // 输出 npm 安装模块 'lodash' 的绝对路径
// 解析相对路径模块的路径:
const path = require.resolve('./utils');
console.log(path); // 输出相对路径模块 'utils' 的绝对路径
// 使用 `options` 参数来控制解析行为:
const path = require.resolve('my-module', { paths: ['/path/to/custom/dir'] });
console.log(path); // 从指定的自定义路径 '/path/to/custom/dir' 解析模块 'my-module' 的绝对路径
对于在特定路径下存放模块或使用非标准的模块组织结构时非常有用。
require.resolve.paths(request)
- 参数
request
:string
将要解析的模块路径。 - 返回值:
string
|null
返回一个由 request
解析期间,将要搜索的路径组成的数组,如果 request
字符串引用核心模块,例如 http
或 fs
,则返回 null
。
// main.js
console.log(require.resolve.paths('childModule2'))
main.js
存放在 /node/playground/
路径下
$ node main.js
[
'/node/playground/node_modules',
'/node/node_modules',
'/node_modules',
`$HOME/.node_modules`
`$HOME/.node_libraries`
`$PREFIX/lib/node`
]
以上是解析 childModule2
模块,将要搜索的路径。
搜索路径规则可参考 从 node_modules 文件夹加载 和 从全局文件夹加载
module
对象
类型:object
在每个模块中,module
变量表示当前模块对象的引用。为了方便起见,module.exports
也可以通过 exports
(module-global 特定于模块的全局变量) 访问。module
也是 module-global。
module.children
类型:module[]
包含了当前模块所加载的子模块的引用(不包含内置模块)。
请注意以下情况:
const childModule1 = require('./childModule1');
console.log(module.children);
const childModule2 = require('./childModule2');
执行 module.children
之后再导入 childModule2
模块,childModule2
模块不会被包含在内。
module.exports
类型:objecct
module.exports
对象由 module
系统创建的。有时候,这种方式可能不符合需求,许多人希望他们的模块是某个类的实例。为了实现这一点,可将所需的导出对象赋值给 module.exports
。而如果将所需对象赋值给 exports
,只会重新绑定本地的 exports
变量,这可能不是所期望的结果。
例如,假设我们正在制作一个名为 a.js
的模块:
const EventEmitter = require('node:events');
module.exports = new EventEmitter();
// 做一些工作,一段时间后释放
// 来自模块本身的 'ready' 事件。
setTimeout(() => {
module.exports.emit('ready');
}, 1000);
然后在另一个文件中我们可以这样做:
const a = require('./a');
a.on('ready', () => {
console.log('module "a" is ready');
});
必须立即完成对 module.exports
赋值。它不能在任何回调中完成。这不起作用:
x.js
setTimeout(() => {
module.exports = { a: 'hello' };
}, 0);
y.js
const x = require('./x');
console.log(x.a);
exports
快捷方式
exports
变量在模块的文件级作用域中可用,并等同于 module.exports
赋值之前的值。
它是一种简写,使得 module.exports.f = ...
可以更简洁地写为 exports.f = ...
。然而,请注意,和任何变量一样,如果将新值赋给 exports
,它将不再绑定到 module.exports
上:
module.exports.hello = true; // 从模块的 require 导出
exports = { hello: false }; // 不导出,仅在模块中可用
当 module.exports
属性被一个新对象完全替换时,exports
也需要重新赋值:
module.exports = exports = function Constructor() {
// ... etc.
};
为了说明这种行为,下面为 require()
的假设实现,它与 require()
实际实现非常相似:
function require(/* ... */) {
const module = { exports: {} };
((module, exports) => {
// 模块代码在这里。在本例中,定义一个函数。
function someFunc() {}
exports = someFunc;
// 此时, exports 不再和 module.exports 为相同引用
// 这个模块仍然会导出一个空的默认对象。
module.exports = someFunc;
// 此时,模块现在将导出someFunc,而不是默认对象
})(module, module.exports);
return module.exports;
}
module.filename
类型:string
当前模块的绝对路径。
module.id
类型:string
当前模块的标识符。通常值为当前模块的绝对路径,也可能为其它,如初始值:'.'
,受到环境、加载方式以及模块系统的具体实现等因素的影响。
module.isPreloading
类型:boolean
如果模块在 Node.js 预加载阶段运行,则为 true
。
module.loaded
类型:boolean
模块是否已经完成加载,或者正在加载。
module.parent
类型:module | null
| undefined
注意!废弃,请使用
require.main
和module.children
替代
当前模块的父模块对象。当一个模块被另一个模块通过 require
引入时,module.parent
属性会指向引入它的模块对象,如果当前模块是当前进程的入口点,则为 null
,如果模块是由非 CommonJS 模块加载的(例如:REPL 或 import
),则为 undefined
。
module.path
类型:string
模块的目录名称,指的是模块所在的绝对路径目录。
与 __dirname 和 __filename 应用于 path.dirname() 相同。
示例:从 /Users/mjr
运行 node example.js
const path = require('node:path');
console.log(module.path);
// Prints: /Users/mjr
console.log(module.path === __dirname);
// true
console.log(module.path === path.dirname(__filename));
// true
module.paths
类型:string[]
模块的搜索路径。
示例:从 /Users/mjr
运行 node example.js
console.log(module.paths)
[
'/Users/mjr/node_modules',
'/Users/node_modules',
'/node_modules'
]
module.require(id)
- 参数
id
:string
- 返回值:
any
,导出模块内容
module.require()
方法提供了一种加载模块的方法,和 require()
从原始模块调用一样。
转载自:https://juejin.cn/post/7239526353523261501