Node.js 应用由模块组成,采用 CommonJS 模块规范。
每个文件就是一个模块,有自己的作用域。在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见。
如果想在多个文件分享变量,必须定义为 global 对象的属性。
CommonJS 规范规定,每个模块内部,module 变量代表当前模块。这个变量是一个对象,它的 exports 属性(即 module.exports)是对外的接口。加载某个模块,其实是加载该模块的 module.exports 属性。
CommonJS 模块的特点:
Node.js 内部提供一个 Module 构建函数。所有模块都是 Module 的实例。
// 非 Node NativeModulefunction Module(id, parent) {// 模块标识符this.id = id;// 调用该模块的模块this.parent = parent;// 模块对外输出的值this.exports = {};this.path = path.dirname(id);this.filename = null;this.loaded = false;updateChildren(parent, this, false);this.children = [];}// Native Modulefunction NativeModule(id) {this.filename = `${id}.js`;this.id = id;this.exports = {};this.module = undefined;this.exportKeys = undefined;this.loaded = false;this.loading = false;this.canBeRequiredByUsers = !id.startsWith('internal/');}
每个模块内部,都有一个 module 对象,代表当前模块。它有以下属性。
module.id:模块的识别符,通常是带有绝对路径的模块文件名module.filename:模块的文件名,带有绝对路径module.loaded:返回一个布尔值,表示模块是否已经完成加载module.parent:返回一个对象,表示调用该模块的模块module.children:返回一个数组,表示该模块要用到的其他模块module.exports:表示模块对外输出的值🌰 示例:
// index.jsconst jquery = require('jquery');exports.$ = jquery;console.log(module);
执行该文件,命令行会输出如下信息。
{id: '.',exports: { '$': [Function] },parent: null,filename: '/path/to/example.js',loaded: false,children: [{id: '/path/to/node_modules/jquery/dist/jquery.js',exports: [Function],parent: [Circular],filename: '/path/to/node_modules/jquery/dist/jquery.js',loaded: true,children: [],paths: [Object]}],paths: ['/home/user/deleted/node_modules','/home/user/node_modules','/home/node_modules','/node_modules']}
node index.js,则 module.parent 为 null。require('./index.js),则 module.parent 就是调用它的模块。💡 利用这一点,可以判断当前模块是否为入口脚本。
if (!module.parent) {// ran with `node index.js`app.listen(8080, function() {console.log('app listening on port 8080');});} else {// used with `require('./index.js')`module.exports = app;}
module.exports 属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取 module.exports 变量。
const EventEmitter = require('events').EventEmitter;module.exports = new EventEmitter();setTimeout(function() {module.exports.emit('ready');}, 1000);
上述模块会在加载后 1 秒后,发出 ready 事件。其他文件监听该事件,可以写成下面这样。
const eventEmitter = require('./eventEmitter');eventEmitter.on('ready', function() {console.log('module eventEmitter is ready');});
为了方便,Node 为每个模块提供了一个 exports 变量,指向 module.exports。这等同在每个模块头部,有这样一行命令。
const exports = module.exports;
因此,在对外输出模块接口时,可以向 exports 对象的 属性 添加 方法 和 值。(注意是 exports 对象的属性)
exports.area = function(r) {return Math.PI * r * r;};exports.circumference = function(r) {return 2 * Math.PI * r;};exports.name = 'mrsingsing';exports.age = 25;
注意,不能将 exports 变量直接指向一个值,因为这样等于断了 exports 和 module.exports 的联系。
那如下结果会如何导出?
module.exports = 100;// 相当于断开了与 module.exports 的联系// 亦即时 exports 不再指向 module.exports 的内存地址// 需要 exports.value = 3 才能导出exports = 3;
很显然会导出 100,毕竟 exports 进行了重指向。
以下写法是无效的。
exports = function(x) {console.log(x);};
下面的写法也是无效的。
exports.hello = function() {return 'hello';};module.exports = 'Hello world!';
上面代码,hello 函数是无法对外输出的,因为 module.exports 被重新赋值了。
exports 输出,只能使用 module.exports 输出。exports 身上,例如 exports.a = xx、exports.b = xx。如果觉得 exports 和 module.exports 之间的区别很难分清,一个简单的处理方法,就是放弃使用 exports,只使用 module.exports。
从源码中可以看出 exports 的实质。
const dirname = path.dirname(filename);const require = makeRequireFunction(this, redirects);let result;// 相当于 exports 引用 module.exportsconst exports = this.exports;const thisValue = exports;const module = this;if (requireDepth === 0) statCache = new Map();if (inspectorWrapper) {result = inspectorWrapper(compiledWrapper,thisValue,exports,require,module,filename,dirname);} else {result = compiledWrapper.call(thisValue, exports, require, module, filename, dirname);}
而 Node 中所有的模块代码都会被包裹在这个函数中:
// __filename 当前文件的绝对路径// __dirname 当前文件所在目录的绝对路径(function(exports, require, module, __filename, __dirname) {exports.a = 3;});

参考源码:node/lib/internal/modules/cjs/loader.js
// Check the cache for the requested file.// 1. If a module already exists in the cache: return its exports object.// 2. If the module is native: call// `NativeModule.prototype.compileForPublicLoader()` and return the exports.// 3. Otherwise, create a new module for the file and save it to the cache.// Then have it load the file contents before returning its exports// object.Module._load = function(request, parent, isMain) {let relResolveCacheIdentifier;if (parent) {debug('Module._load REQUEST %s parent: %s', request, parent.id);...}// 查找文件具体位置const filename = Module._resolveFilename(request, parent, isMain);// 存在缓存,则不需要再次执行 返回缓存const cachedModule = Module._cache[filename];if (cachedModule !== undefined) {updateChildren(parent, cachedModule, true);if (!cachedModule.loaded)return getExportsForCircularRequire(cachedModule);return cachedModule.exports;}// 加载node原生模块,原生模块loadNativeModule// 如果有 且能被用户引用 返回 mod.exports(这包括node模块的编译创建module对象,将模块运行结果保存在module对象上)const mod = loadNativeModule(filename, request);if (mod && mod.canBeRequiredByUsers) return mod.exports;// 创建一个模块// Don't call updateChildren(), Module constructor already does.const module = new Module(filename, parent);if (isMain) {process.mainModule = module;module.id = '.';}// 缓存模块Module._cache[filename] = module;if (parent !== undefined) {relativeResolveCache[relResolveCacheIdentifier] = filename;}// 加载执行新的模块module.load(filename);return module.exports;};
Node 缓存的是编译和执行后的对象:
module 对象,执行模块,存储执行后得到的对象,返回执行后的结果 exportsNode 使用 CommonJS 模块规范,内置的 require 命令用于加载模块文件。
require 命令的基本功能就是,读入并执行一个 JavaScript 文件,然后返回该模块的 exports 对象。如果没有发现指定模块,会报错。
// index.jsvar invisible = function() {console.log('invisible');};exports.message = 'hi';exports.say = function() {console.log(message);};
如果模块输出的仅有一个函数,那就不能定义在 exports 对象上,而要定义在 module.exports 变量上。
module.exports = function() {console.log('hello world!');};require('./index.js');
上面代码中,require 命令调用自身,等于是执行 module.exports,因此会输出 hello world!。
require 命令用于加载文件,后缀名默认为 .js。
var foo = require('foo');// 等同于var foo = require('foo.js');
根据参数的不同格式,require 命令去不同路径寻找模块文件。
如果参数字符串以 / 开头,则表示加载的是一个位于绝对路径的模块文件。比如,require('/home/marco/foo.js') 将加载 /home/marco/foo.js。
如果参数字符串以 ./ 开头,则表示加载的是一个位于相对路径(跟当前执行脚本的位置相比)的模块文件。比如,require('./foo') 将加载当前目录下的 foo.js。
如果参数字符串不以 ./ 或 / 开头,则表示加载的是一个默认提供的核心模块(位于 Node 的系统安装目录中),或者一个位于各级 node_modules 目录的已安装模块(全局安装或局部安装)。
举例来说,脚本 /home/user/projects/foo.js 执行了 require('bar.js') 命令,Node 会依次搜索以下文件:
# Node 安装全局依赖的目录/usr/local/lib/node/bar.js# 当前文件夹依赖目录/home/user/projects/node_modules/bar.js# 父目录依赖目录/home/user/node_modules/bar.js# 父目录的父目录依赖目录/home/node_modules/bar.js# 沿路径向上逐级递归,直到根目录下的依赖目录/node_modules/bar.js
这种路径的生成方式与 JavaScript 的原型链或作用域的查找方式十分类似。在加载的过程中,Node 会逐个尝试模块路径中的路径,直到找到目标文件为止。可以看出,当前文件的路径越深,模块查找耗时会越多,这是自定义模块的加载速度是最慢的原因。
如果参数字符串不以 ./ 或 / 开头,而且是一个路径,比如 require('example/path/to/file'),则将先找到 example 的位置,然后再以它为参数,找到后续路径。
如果指定的模块文件没有发现,Node 会尝试为文件名添加 .js、.json、.node 后,再去搜索。.js 件会以文本格式的 JavaScript 脚本文件解析,.json 文件会以 JSON 格式的文本文件解析,.node 文件会以编译后的二进制文件解析。
如果想得到 require 命令加载的确切文件名,使用 require.resolve() 方法。
如果参数字符串为目录的路径,则自动查找该文件夹下的 package.json 文件,然后再加载该文件当中 main 字段所指定的入口文件(若没有 package.json 文件或该文件中无 main 字段,则默认查找该文件夹下的 index.js 文件作为模块来载入)
⚠️ 注意:Node 的系统模块的优先级最高,一旦有第三方模块包与核心模块重名,则以 Node 内置核心模块为准。
通常,我们会把相关的文件会放在一个目录里面,便于组织。这时,最好为该目录设置一个入口文件,让 require 方法可以通过这个入口文件,加载整个目录。
在目录中放置一个 package.json 文件,并且将入口文件写入 main 字段。下面是一个例子。
{"name": "some-library","main": "./lib/some-library.js"}
require 发现参数字符串指向一个目录以后,会自动查看该目录的 package.json 文件,然后加载 main 字段指定的入口文件。如果 package.json 文件没有 main 字段,或者根本就没有 package.json 文件,则会加载该目录下的 index.js 文件或 index.node 文件。
事实上,不同的模块规范会参照
package.json中的不同字段:
main:CommonJS 模块module:ES Module 模块unpkg:打包压缩文件typings:TypeScript 定义文件
第一次加载某个模块时,Node 会缓存该模块。以后再加载该模块,就直接从缓存取出该模块的 module.exports 属性。
require('./example.js');require('./example.js').message = 'hello';require('./example.js').message;// "hello"
上面代码中,连续三次使用 require 命令,加载同一个模块。第二次加载的时候,为输出的对象添加了一个 message 属性。但是第三次加载的时候,这个 message 属性依然存在,这就证明 require 命令并没有重新加载模块文件,而是输出了缓存。
💡 TIPS:如果想要多次执行某个模块,可以让该模块输出一个函数,然后每次 require 这个模块的时候,重新执行一下输出的函数。
所有缓存的模块保存在 require.cache 之中,如果想删除模块的缓存,可以像下面这样写。
// 删除指定模块的缓存delete require.cache[moduleName];// 删除所有模块的缓存Object.keys(require.cache).forEach(function(key) {delete require.cache[key];});
⚠️ 注意:缓存是根据 绝对路径 识别模块的,如果同样的模块名,但是保存在不同的路径,require 命令还是会重新加载该模块。
如果发生模块的 循环加载,即 foo 加载 bar,bar 又加载 foo,则 bar 将加载 foo 的 不完整版本。
// foo.jsconsole.log('foo start');exports.done = false;const bar = require('./bar');console.log('在 foo 中,bar.done = %j', bar.done);exports.done = true;console.log('foo end');// bar.jsconsole.log('bar start');exports.done = false;const foo = require('./foo');console.log('在 bar 中,foo.done = %j', foo.done);exports.done = true;console.log('bar end');// main.jsconsole.log('main start');const foo = require('./foo');const bar = require('./bar');console.log('在 main 中,foo.done=%j,bar.done=%j', foo.done, bar.done);
当 main.js 加载 foo.js 时,foo.js 又加载 bar.js。 此时,bar.js 会尝试去加载 foo.js。 为了防止无限的循环,会返回一个 foo.js 的 exports 对象的未完成的副本给 bar.js 模块。 然后 bar.js 完成加载,并将 exports 对象提供给 foo.js 模块。
# 执行主文件$ node main.jsmain startfoo startbar start在 bar 中,a.done = falsebar 结束在 foo 中,b.done = truefoo 结束在 main 中,a.done=true,b.done=true
require 方法有一个 main 属性,可以用来判断模块是直接执行,还是被调用执行。
node module.js),require.main 属性指向模块本身。require.main === module;// true
require 加载该脚本执行),上面的表达式返回 false。require.resolve 用于获取模块的绝对路径,该方法的执行并不会真正地加载该模块。
// 已安装 React 至 node_modulesconst react = require.resolve('react');console.log(react);// /Users/mrsingsing/Desktop/demo/node_modules/react/index.js
参考资料: