在Node.js出现之前,服务端JavaScript基本上处于一片荒芜的境况,而当时也没有出现ES6的模块化规范。因此,Node.js采用了当时比较先进的一种模块化规范来实现服务端JavaScript的模块化机制,它就是CommonJS,有时也简称为CJS。
本文由Node.js部署神器-Servbay 工具赞助,开发环境管理神器!3分钟部署好你的项目开发环境。
一、CommonJS规范
在Node.js采用CommonJS规范之前,还存在以下缺点:
- 没有模块系统
- 标准库很少
- 没有标准接口
- 缺乏包管理系统
这些问题的存在导致Node.js难以构建大型项目,生态环境也十分贫乏,亟待解决。CommonJS的提出主要是为了弥补当前JavaScript没有模块化标准的缺陷,以达到像Java、Python、Ruby那样能够构建大型应用的阶段,而不是仅仅作为一门脚本语言。Node.js能够拥有今天这样繁荣的生态系统,CommonJS功不可没。
1.1 CommonJS的模块化规范
CommonJS对模块的定义十分简单,主要分为模块引用、模块定义和模块标识三个部分。
1.1.1 模块引用
示例如下:
const fs = require('fs');
在CommonJS规范中,存在一个require
全局方法,它接受一个标识,然后把标识对应的模块的API引入到当前模块作用域中。
1.1.2 模块定义
在Node.js上下文环境中提供了一个module
对象和一个exports
对象。module
代表当前模块,exports
是当前模块的一个属性,代表要导出的一些API。一个文件就是一个模块,把方法或者变量作为属性挂载在exports
对象上即可将其作为模块的一部分进行导出。
// add.js
exports.add = function(a, b) {
return a + b;
};
在另一个文件中,我们可以通过require
引入之前定义的这个模块:
const { add } = require('./add.js');
add(1, 2); // 输出 3
1.1.3 模块标识
模块标识就是传递给require
函数的参数,在Node.js中就是模块的id。它必须是符合小驼峰命名的字符串,或者是以.
、..
开头的相对路径,或者绝对路径,可以不带后缀名。
模块的定义十分简单,接口也很简洁。它的意义在于将类聚的方法和变量限定在私有的作用域中,同时支持引入和导出功能以顺畅的连接上下游依赖。CommonJS这套模块导出和引入的机制使得用户完全不必考虑变量污染。
二、Node.js的模块化实现
Node.js在实现中并没有完全按照规范实现,而是对模块规范进行了一定的取舍,同时也增加了一些自身需要的特性。接下来我们会探究一下Node.js是如何实现CommonJS规范的。
在Node.js中引入模块会经过以下三个步骤:
- 路径分析
- 文件定位
- 编译执行
在了解具体的内容之前我们先了解两个概念:
- 核心模块:Node.js提供的内置模块,比如
fs
、url
、http
等。 - 文件模块:用户自己编写的模块,比如Koa、Express等。
核心模块在Node.js源代码的编译过程中已经编译进了二进制文件,Node.js启动时会被直接加载到内存中,所以在我们引入这些模块的时候就省去了文件定位、编译执行这两个步骤,加载速度比文件模块要快很多。
文件模块是在运行的时候动态加载,需要走一套完整的流程:路径分析、文件定位、编译执行等,所以文件模块的加载速度比核心模块要慢。
2.1 优先从缓存加载
在讲解具体的加载步骤之前,我们应当知晓的一点是,Node.js对于已经加载过一遍的模块会进行缓存,模块的内容会被缓存到内存当中,如果下次加载了同一个模块的话,就会从内存中直接取出来,这样就省去了第二次路径分析、文件定位、加载执行的过程,大大提高了加载速度。无论是核心模块还是文件模块,require()
对同一文件的第二次加载都一律会采用缓存优先的方式,这是第一优先级的。但是核心模块的缓存检查优先于文件模块的缓存检查。
我们在Node.js文件中所使用的require
函数,实际上就是在Node.js项目中的lib/internal/modules/cjs/loader.js
所定义的Module.prototype.require
函数,只不过在后面的makeRequireFunction
函数中还会进行一层封装,Module.prototype.require
源码如下:
Module.prototype.require = function(id) {
validateString(id, 'id');
if (id === '') {
throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string');
}
requireDepth++;
try {
return Module._load(id, this, /* isMain */ false);
} finally {
requireDepth--;
}
};
可以看到它最终使用了Module._load
方法来加载我们的标识符所指定的模块,找到Module._load
:
Module._cache = Object.create(null);
// Check the cache for the requested file.
Module._load = function(request, parent, isMain) {
let relResolveCacheIdentifier;
if (parent) {
const filename = relativeResolveCache[relResolveCacheIdentifier];
if (filename !== undefined) {
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
delete relativeResolveCache[relResolveCacheIdentifier];
}
}
const filename = Module._resolveFilename(request, parent, isMain);
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
return cachedModule.exports;
}
const mod = loadNativeModule(filename, request, experimentalModules);
if (mod && mod.canBeRequiredByUsers) return mod.exports;
const module = new Module(filename, parent);
if (isMain) {
process.mainModule = module;
module.id = '.';
}
Module._cache[filename] = module;
if (parent !== undefined) {
relativeResolveCache[relResolveCacheIdentifier] = filename;
}
let threw = true;
try {
module.load(filename);
threw = false;
} finally {
if (threw) {
delete Module._cache[filename];
if (parent !== undefined) {
delete relativeResolveCache[relResolveCacheIdentifier];
}
}
}
return module.exports;
};
Node.js先会根据模块信息解析出文件路径和文件名,然后以文件名作为Module._cache
对象的键查询该文件是否已经被缓存,如果已经被缓存的话,直接返回缓存对象的exports
属性。否则就会使用Module._resolveFilename
重新解析文件名,再查询一遍缓存对象。否则就会当做核心模块来加载,核心模块使用loadNativeModule
方法进行加载。
如果经过了以上几个步骤之后,在缓存中仍然找不到require
加载的模块对象,那么就使用Module
构造方法重新构造一个新的模块对象。加载完毕之后还会缓存到Module._cache
对象中,以便下一次加载的时候可以直接从缓存中取到。
2.2 路径分析与文件定位
在Node.js中,路径分析与文件定位是通过Module._resolveFilename
方法来实现的。该方法会根据传入的模块标识符和当前模块路径,确定模块文件的完整路径。
-
路径分析
如果模块标识符是核心模块的名称,例如
fs
、http
等,那么Module._resolveFilename
会直接返回该核心模块的名称,而不需进一步分析。 -
文件定位
如果是文件模块,Node.js会按照以下顺序进行文件定位:
- 相对路径:如果标识符以
./
或../
开头,Node.js会将其视为相对路径,从当前模块文件所在目录开始解析。 - 绝对路径:如果标识符以
/
开头,Node.js会将其视为绝对路径。 - 模块路径:如果标识符不是以
.
或/
开头,Node.js会将其视为一个模块路径,按顺序在node_modules
目录中查找。
Node.js会尝试为文件模块添加
.js
、.json
、.node
后缀进行匹配,直到找到一个存在的文件为止。 - 相对路径:如果标识符以
2.3 编译执行
一旦文件定位完成,Node.js会根据文件扩展名选择不同的编译执行策略:
- JavaScript 文件:通过
fs
模块读取文件内容,并使用vm
模块将内容包装在一个函数中执行。 - JSON 文件:通过
fs
模块读取文件内容,并使用JSON.parse
解析。 - C/C++ 扩展文件:使用
process.dlopen
加载并执行。
Node.js将模块的内容包装在一个函数中,以提供模块作用域隔离。这个函数接收exports
、require
、module
、__filename
、__dirname
作为参数,使得模块内部可以使用这些变量。
三、模块加载优化与扩展
3.1 模块缓存
如前所述,Node.js使用Module._cache
缓存已加载的模块,以提高加载速度。缓存机制确保每个模块文件在一次加载后,后续的加载请求都能直接从缓存中获取,避免重复加载。
3.2 扩展模块加载
Node.js允许用户自定义模块加载行为,通过require.extensions
扩展模块加载方式。虽然不推荐在生产环境中使用,但在某些场景下可以用于加载自定义格式的文件。
require.extensions['.txt'] = function(module, filename) {
const content = fs.readFileSync(filename, 'utf8');
module.exports = content;
};
上面的代码示例展示了如何扩展.txt
文件的加载方式,使得文本文件可以被require
引入。
3.3 包装与作用域
在Node.js中,每个模块的代码实际上都被包装在一个函数中。这个函数提供了模块作用域隔离,防止变量污染全局作用域。模块包装器类似于以下形式:
(function(exports, require, module, __filename, __dirname) {
// 模块代码在这里
});
这种机制确保每个模块都有自己的私有作用域,同时可以通过exports
对象导出模块接口。
四、核心模块与文件模块的区别
-
加载速度
核心模块在Node.js启动时已经加载到内存中,可以立即使用,加载速度非常快。文件模块需要经过路径解析、文件定位和编译执行等步骤,速度相对较慢。
-
优先级
在解析模块标识符时,Node.js会优先检查核心模块。如果标识符匹配核心模块,则直接返回核心模块,而不进行文件系统操作。
-
缓存机制
核心模块和文件模块都使用缓存机制,但核心模块的缓存检查优先于文件模块。
五、总结
Node.js的模块系统基于CommonJS规范,但在实现上进行了优化和扩展。通过模块缓存、路径解析、文件定位和编译执行等机制,Node.js实现了高效的模块加载。同时,Node.js的模块系统支持自定义扩展,允许开发者根据需要调整模块加载行为。
这种模块化设计不仅提升了代码的可维护性和可复用性,还支持了Node.js在服务器端的广泛应用。通过对Node.js模块系统的深入理解,开发者可以更有效地组织和管理项目代码,提高开发效率。