模块化其实很早就在不少高级语言中如Java
、Ruby
、Python
出现了,甚至在C
语言都有相似的模块化,好比include
语句引入头文件,各类库等等。而在前端中,JavaScript做为主要语言,它设计之初并无实现模块化。随着Web的发展,JavaScript地位愈来愈高,同时也出现了如下主要问题:javascript
这可谓是一大痛点呐!可是广大的软件工程师们也不是吃素的,因而解决方案如AMD
、CMD
、ES Module
、CommonJS
便雨后春笋般涌现出来了。html
现现在AMD
、CMD
已经慢慢淡出咱们的视野了,咱们接触最多就是两种模块规范:ES Module
和CommonJS
,前者应用在ECMAScript中然后者在Node中。前端
CommonJS规范是一个超级大的概念,和ECMAScript规范同样,它是整个语言层面的规范,模块化只是偌大的规范中的一种,我相信不少人容易搞混淆,在此仍是说明一下。java
若是仍是不理解,我举个例子吧:在CommonJS规范中实现了如下规范:node
我相信你应该能够理解了,下面我会介绍一下我所学习的CommonJS模块规范。c++
主要分为三部分:模块引用
、模块定义
、模块表示
。web
Node模块类型分为两种:
核心模块
和文件模块
,并经过require
方法来引入模块。前者是Node中内置的模块,然后者通常是用户本身定义的模块。后面提到的自定义模块也属于文件模块,只是为了区分说明。npm
代码以下:json
// 引入`http`内置模块
const http = require('http')
// 引入文件模块
const sum = require('./sum')
// 引入第三方包`koa`,这是一个自定义模块
const koa = require('koa')
复制代码
require命令的基本功能是,读入并执行一个JavaScript文件,而后返回该模块的exports对象。若是没有发现指定模块,会报错。数组
在CommonJS模块规范中,一个文件就是一个模块,并经过
module.exports
和exports
两种方式来导出模块中的变量或函数。
代码以下:
// 经过exports导出一个`sum`函数
exports.sum = (x, y) => x + y;
// 经过module.exports导出一个`sum` 函数
module.exports = (a, b) => a - b;
复制代码
为了方便,Node为每一个模块提供一个exports变量,指向module.exports。等价于:
var exports = module.exports;
复制代码
若是exports导出的变量类型是引用类型如函数,则会断开与module.exports
的地址指向,致使变量导出失败。由于最终仍是要靠module.exports
来导出变量的。
exports = function() {...};
复制代码
用图来表示大概就是这个样子:
同理,若是你要使用module.exports
直接导出一个对象或者函数也会从新指向新地址,而你还使用exports
导出原来地址中的变量或函数是没有用的。
// 在原来的空对象中存储一个a变量
exports.a = function() {}
// 经过module.exports 直接导出一个引用类型变量
// 前面导出的变量失效了
module.exports = {...}
复制代码
模块标识是
require
方法中的参数,该参数就是引入的模块文件的路径,能够没有后缀,可是必须符合小驼峰命名规范。
在上面的模块引用中,http
、./sum
、koa
就是模块标识。具体有如下几类:
.
或..
开始的相对路径模块/
开始的绝对路径模块从Node中引入模块,主要经历了四个过程:
下面来具体看看它们的过程。
无论是内置模块仍是文件模块,在第一次加载模块后,会把模块编译执行并放在缓存中。从而之后再次加载模块的时候,会直接去缓存中找相应的模块。
内置模块跟文件模块不一样的是,它在Node源代码编译过程当中直接编译成了二进制可执行文件,在启动Node进程的同时就从内存中加载了核心模块,并缓存起来。因此内置模块的加载跳过了文件定位
和编译执行
的步骤,而且优先于文件模块加载。
缓存通常放在了require.cache
,若是想删除模块的缓存,能够像下面这样写。
// 删除指定模块的缓存
delete require.cache[moduleName];
// 删除全部模块的缓存
Object.keys(require.cache).forEach(function(key) {
delete require.cache[key];
})
复制代码
注意,缓存是根据绝对路径识别模块的,若是一样的模块名,可是保存在不一样的路径,require命令仍是会从新加载该模块。
路径分析主要是对模块标识符分析,根据不一样类型的模块标识符使用不一样规则分析路径。
下面是模块加载速度比较:
核心模块 > 文件模块 > 自定义模块。
核心模块在Node启动的时候就已经编译成了二进制文件了,因此加载速度最快。 文件模块由于带有.
、..
、/
路径标识,具体标识了文件的位置,因此模块加载速度仅次于核心模块。自定义模块是三者最慢的了,具体缘由咱们在下面会有说明。
值得注意的是,若是自定义模块和核心模块重名了,则不会加载自定义模块,由于核心模块优先于自定义模块。
Node是如何去寻找文件模块和自定义模块路径并加载的呢?下面我要先介绍一个很特殊的对象module
。
Node内部提供一个Module构建函数。全部模块都是Module的实例。每一个模块内部,都有一个module对象,表明当前模块。
Module
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
// ...
复制代码
为了测试,我创建了一个项目结构:
module.exports = (x, y) => x + y;
复制代码
main.js
const sum = require('./sum')
const result = sum(1, 2);
module.exports = result;
console.log(module);
复制代码
咱们在main.js
中打印module
,它存放了当前main.js
模块的全部信息:
Module {
id: '.',
path: 'e:\\web\\font-end-code\\Node\\01-Commonjs',
exports: 3,
parent: null,
filename: 'e:\\web\\font-end-code\\Node\\01-Commonjs\\main.js',
loaded: false,
children: [
Module {
id: 'e:\\web\\font-end-code\\Node\\01-Commonjs\\sum.js',
path: 'e:\\web\\font-end-code\\Node\\01-Commonjs',
exports: [Function],
parent: [Circular],
filename: 'e:\\web\\font-end-code\\Node\\01-Commonjs\\sum.js',
loaded: true,
children: [],
paths: [Array]
}
],
paths: [
'e:\\web\\font-end-code\\Node\\01-Commonjs\\node_modules',
'e:\\web\\font-end-code\\Node\\node_modules',
'e:\\web\\font-end-code\\node_modules',
'e:\\web\\node_modules',
'e:\\node_modules'
]
}
复制代码
简单介绍一下它其中的每一个属性。
id
:模块的识别符,一般是带有绝对路径的模块文件名。
path
:当前模块的绝对路径。
export
:表示模块对外输出的值。我这里导出了一个3
。
parent
:返回一个对象,表示调用该模块的模块。没有就返回null
filename
:模块的文件名,带有绝对路径。
loaded
:返回一个布尔值,表示模块是否已经完成加载。
children
:返回一个数组,表示该模块要用到的其余模块。
paths
:当前模块查找的绝对路径数组。它遵循必定的模块路径查询规则。
咱们能够利用parent
属性来判断当前文件是否是一个入口文件:
if(!module.parent) {
// do something
} else {
// export something
}
复制代码
咱们了解了module
对象后,对接下来分析模块路径查询规则颇有帮助了。
上面咱们已经看到了在module
对象中有个很重要的属性paths
,里面存放了一个路径数组。如今换成自定义模块的写法来引入sum
模块:
main.js
const sum = require('sum');
const result = sum(1, 2);
module.exports = result;
console.log('---------------main.js-----------')
console.log(module.paths);
复制代码
而后在main.js
同级目录下建立一个node_modules
目录,建立一个sum.js
模块:
node_modules/sum.js
module.exports = (x, y) => x + y;
console.log('---------------node_module/sum.js-----------')
console.log(module.paths);
复制代码
执行main.js
:
咱们发现,经过自定义模块方式引入的sum.js
和文件模块中的paths
是同样的结果。因此咱们能够得出一个规则:
node_modules
路径node_modules
路径node_modules
路径node_modules
路径奇怪的是,在main.js
中咱们发现node_modules
目录也被打印了出来,但是,咱们看到的是main.js
并不在该目录下面啊,这是怎么回事呢? 这里留个思考题。
路径分析好了后,下面要具体定位文件的位置了,主要分为两个步骤:文件拓展名分析
和目录分析
。
咱们使用require
引入模块的时候,能够不加文件的后缀名。好比:
const sum = require('./sum')
复制代码
这个时候Node就会进行文件拓展名分析,会依次分析下面三个拓展名:
.js
.node
.json
在分析的过程当中,Node会同步阻塞式调用fs
模块来判断文件是否存在。若是查找不到这个文件,而获得了一个目录,那么将会进行目录(包)分析。
为了测试,咱们来整理下目录结构:
有同窗确定仍是要问,为啥要放在node_modules
下面呢,这个还真很差回答,请翻到上面再看一下路径分析,但愿对你有帮助哦。
package.json
{
"main": "sum.js"
}
复制代码
其他代码不变。执行main.js
后发现可以正常打印结果就能说明模块加载成功了。上面其实就是一个目录分析
的过程了。
sum
这个目录(或者叫包)package.json
文件,若是有,使用JSON.parse
解析JSON
对象,找到main属性名对应的文件名,我这里的属性名就是sum.js
,若是文件名没有拓展名就先进行拓展名分析,而后定位到这个模块就能够了。package.json
文件,就会在当前目录下依次寻找index.js
、index.node
、index.json
若是你理解了这个过程,你也就理解了npm install
的时候为何会自动生成node_modules
文件夹,而且文件夹下有好多包。而后咱们引入的方式就是自定义模块引入的方式。
上面的步骤完成后,也就是说如今已经找到了模块了,咱们须要对模块进行编译,并执行模块里面的代码,把须要暴露出来的变量都暴露出来。不一样的文件拓展名,载入的编译方法是不同的。
.js
:经过fs模块同步载入后编译执行。.node
:这是c/c++编写的拓展文件,须要调用dlopen()方法来编译。.json
:经过fs模块同步载入后使用JSON.parse解析结果。每个编译成功的模块都会将文件的绝对路径看成索引缓存在Module._cache
对象上,来提高二次引入的性能。
这一块的编译主要是经过Node同步调用fs模块读取JSON文件内容后,使用JSON.parse方法解析,而后将解析后的结果放到exports
对象暴露出去。它通常都是做为一个配置文件说明,并且通常都是node本身加载pacakage.json
。处理代码以下:
Module._extensions['.json'] = function(module, filename) {
var content = NativeModule.require('fs).readFileSync(filename, 'utf-8');
try {
module.exports = JSON.parse(stripBOM(content));
} catch(err) {
err.message = filename + ':' + err.message;
thow err;
}
}
复制代码
node文件主要是C/C++ 的拓展,其实C/C++ 已经编译好了封装在了libuv
层,只须要在node曾调用procee.dlopen
方法就能够加载执行。这一块难度较大,就很少说了。
编译JavaScript文件过程当中,给当前模块包上一层函数,采用闭包来解决全局变量污染问题。下面是一个简单的实现。
(function(exports, require, module, __dirname, __filename){
var load = function (exports, module) {
// 读取的main.js代码:
const sum = require('./sum');
const result = sum(1,2);
module.exports.result = result;
// main.js代码结束
return module.exports;
};
var exported = load(module.exports, module);
// 保存module:
save(module, exported);
})
复制代码
包装完后,将须要导出的变量经过module.exports
导出去了。其余模块就只能访问导出来的变量,其他变量是访问不到的
CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。 咱们将main.js代码改变一下:
let sum = require('./sum');
sum = { a: 1 };
console.log(require('./sum'))
console.log(sum)
复制代码
能够看到,两个模块是不会影响的。
可是在ES Module中是不同的,它是静态加载。也就是在代码静态解析阶段就已经确认好模块依赖关系了。一句话总结就是:
CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
因此在ES Module模块与模块是互相影响的。
以前对CommonJS
模块化的理解模模糊糊的,这篇文章算是个人一个笔记吧,毕竟大部份内容是从前辈们的文章或书籍里面借鉴的,跟着他们的脚步来走的。即使如此,我发现仍是收获很多,再次感谢它们的文章和书籍。虽然还有不少不完善的地方,但我对本身仍是有信心的,争取之后有更多本身的理解。
【1】《深刻浅出Node.js》朴灵编著
【2】阮一峰 CommonJS规范
【3】廖雪峰 模块