本文深入解析 Node.js 模块系统,涵盖 CommonJS 规范下的 module.exports 与 require 用法、模块加载优先级及缓存机制。详细阐述循环依赖的执行顺序与原理,并介绍 Node.js 从 v12 开始对 ES Module 的原生支持及配置方法,帮助开发者掌握模块管理的核心知识。
Node.js 模块系统概述
Node.js 的模块
JavaScript 最初作为网页交互脚本语言诞生时,并未内置模块系统。随着应用复杂度提升,单文件加函数划分的方式已无法满足需求。ES6 引入了 class 和 module,提供了类似高级语言的代码组织方式。
import _ from 'lodash';
class Fun {}
export default Fun;
上述代码展示了模块系统的两个核心要素:export用于定义对外接口,import用于引入其他模块的功能。
CommonJS 规范与接口导出
在 ES6 普及前,社区主要存在 CommonJS 和 AMD 两种加载方案。Node.js 诞生早于 ES6,采用了类似 CommonJS 的实现,并遵循以下原则:
1. 每个文件视为独立模块,内部变量作用域局限于该文件内。
2. 通过 module.exports 对象导出对外接口。
3. 使用 require 引入其他模块。
circle.js
const { PI } = Math;
module.exports = function area(r) {
PI * r ** 2;
};
上述代码实现了一个 Node.js 模块,该模块未依赖其他模块,仅导出一个计算圆面积的 area 方法。
test.js
const area = require('./circle.js');
console.log(`半径为 4 的圆的面积是 ${area(4)}`);
该模块依赖 circle.js,通过调用其导出的 area 方法计算并输出半径为 4 的圆面积。
module.exports
模块通过 module.exports 暴露接口,常见用法包括为其添加属性或直接赋值给新对象。
test.js
// 添加属性
module.exports.prop1 = xxx;
module.exports.funA = xxx;
module.exports.funB = xxx;
// 赋值到全新对象
module.exports = {
prop1,
funA,
funB,
};
这两种写法在功能上等价,实际使用时无明显区别。
const mod = require('./test.js');
console.log(mod.prop1);
console.log(mod.funA());
此外还可以直接使用 exports 对象添加属性,但不能直接赋值给新对象,原因将在后文解释。
// 正确的写法:添加属性
exports.prop1 = xxx;
exports.funA = xxx;
exports.funB = xxx;
// 赋值到全新对象
module.exports = {
prop1,
funA,
funB,
};
require('id')
模块加载机制与路径解析
模块类型
require 的 id 参数支持模块名和文件路径两种形式。
模块名
const fs = require('fs');
const _ = require('lodash');
示例中的 fs 和 lodash 均为模块名。fs 是 Node.js 内置核心模块,lodash 是通过 npm 安装的第三方模块。若出现重名,优先加载系统内置模块。
由于项目中可能存在多个 node_modules 文件夹(Node.js 的设计特性),第三方模块查找遵循就近原则逐层向上回溯(可通过打印 module.paths 查看具体路径),直至 NODE_PATH 环境变量指定的文件系统根目录。详细过程可参考官方文档。
此外,Node.js 还会搜索以下全局目录:
- $HOME/.node_modules
- $HOME/.node_libraries
- $PREFIX/lib/node
其中 $HOME 为用户主目录,$PREFIX 为 Node.js 配置的 node_prefix。强烈建议将所有依赖置于本地 node_modules 目录,以获得更快的加载速度和更高的可靠性。
文件路径
模块也可通过文件路径加载,这是项目内自定义模块的通用方式。路径可省略扩展名,Node.js 会按 .js、.json、.node 的顺序尝试解析。
- 以 '/' 为前缀的模块表示绝对路径,按系统路径查找。
- 以 './' 为前缀的模块表示相对于当前调用 require 方法文件的路径,不受后续模块使用位置的影响。
模块缓存与循环依赖处理
单次加载 & 循环依赖
模块在首次加载后会被缓存到 Module._cache ,若每次调用 require('foo') 均解析至同一文件,则返回相同对象,且多次调用 require(foo) 不会致使模块代码被执行多次。Node.js 依据实际文件名缓存模块,故从不同层级目录引用相同模块不会重复加载。
理解单次加载机制有助于理解模块循环依赖时的行为。
a.js
console.log('a 开始');
exports.done = false;
const b = require('./b.js');
console.log('在 a 中,b.done = %j', b.done);
exports.done = true;
console.log('a 结束');
b.js
console.log('b 开始');
exports.done = false;
const a = require('./a.js');
console.log('在 b 中,a.done = %j', a.done);
exports.done = true;
console.log('b 结束');
main.js
console.log('main 开始');
const a = require('./a.js');
const b = require('./b.js');
console.log('在 main 中,a.done=%j,b.done=%j', a.done, b.done);
当 main.js 加载 a.js 时,a.js 内部又加载 b.js,此时 b.js 尝试加载 a.js。
为防止无限循环,Node.js 会返回 a.js 的 exports 对象的未完成副本给 b.js。b.js 加载完成后,将完整的 exports 对象提供给 a.js。
因此示例的输出结果为:
main 开始 a 开始 b 开始 在 b 中,a.done = false b 结束 在 a 中,b.done = true a 结束 在 main 中,a.done=true,b.done=true
若对上述过程感到困惑,无需深究,因为日常开发中应避免使用循环依赖。
模块工作原理与封装器
工作原理
Node.js 中每个文件是一个模块,内部变量为局部变量,不污染全局。执行模块代码前,Node.js 会使用如下函数封装器进行封装:
(function(exports, require, module, __filename, __dirname) {
// 模块的代码实际上在这里
});
- __filename:当前模块文件的绝对路径
- __dirname:当前模块文件所在目录的绝对路径
- module:当前的模块实例
- require:加载其他模块的方法,是 module.require 的快捷方式
- exports:导出模块接口的对象,是 module.exports 的快捷方式
回顾前文问题:为什么 exports 对象不支持赋值为其他对象?在上述函数中添加一句 exports 的来源定义即可明了:
const exports = module.exports;
(function(exports, require, module, __filename, __dirname) {
// 模块的代码实际上在这里
});
其他模块 require 获取的始终是 module.exports 对象。若将 exports 直接赋值给新对象,将断开与 module.exports 的连接,导致导出失效。
ES Module 在 Node.js 中的支持
在 Node.js 中使用 ES Module
随着 ES6 的普及,Node.js 也支持了 ES6 Module,主要有以下几种方式:
babel 构建
在 v12 之前版本,使用 babel 构建是最简单通用的方式,具体配置参考 @babel/preset-env。
.babelrc
{
"presets": [
["@babel/preset-env", {
"targets": {
"node": "8.9.0",
"esmodules": true
}
}]
]
}
原生支持
在 v12 之后,可以使用原生方式支持 ES Module。
- 开启
--experimental-modules - 将模块名修改为
.mjs(强烈不推荐使用)或在 package.json 中设置"type": module
这样 Node.js 会将 js 文件视为 ES Module 处理,更多详情参考官方文档。
以上就是谈谈node.js中的模块系统的详细内容,更多关于node.js 模块的资料请关注本站其它相关文章!