模块
不同与其他高级语言,Java有类文件,Python有import机制。。。Javascript只能通过script
标签引入代码,显得杂乱无章,并且会有命名冲突,
我们在开发过程中必须非常谨慎,同时也很受限制。
Commonjs
-
初衷
commonjs希望Javascript能在任何地方运行。
Javascript在发展历程中,主要在浏览器中发光发热,而对于后端来说,Javascript还有很多弊端和缺陷:
- 没有模块系统
- 没有标准接口
- 标准库较少,像文件操作、IO流等常见需求都没有对应的库可用
- 缺乏包管理系统,无法进行依赖的安装,无法快速构建应用
commonjs希望Javascript能够编写出这些应用,运行在各个宿主平台:
- 命令行工具
- 服务端应用程序
- 桌面图形界面应用程序
- 混合应用(Adobe AIR等)
Node借鉴Commonjs的Modules
规范实现了一套非常易用的模块系统,NPM
对Packages的支持让开发者事半功倍。
Commonjs模块规范
三大核心:
-
模块引用
通过
require()
方法来引入对应的模块API,它接收一个模块标识
-
模块定义
当前上下文提供一个
exports
对象用于导出当前模块的方法和变量,它是导出模块的唯一出口
,还有一个module
对象, 他代表模块自身,而exports对象就是module的属性。Node中,一个文件就是一个模块,我们可以直接将方法挂载在exports对象上
, 就能导出该方法// a.js exports.sayHi = function() { console.log('hello'); } // b.js const a = require('a'); exports.sayHello = a.sayHi;
-
模块标识
就是传递给require方法的参数,
必须符合小驼峰命名
的字符串,或者路径字符串,末尾可以不加文件后缀名js。
模块的意义:
把类聚的方法和变量,限定在私有作用域内,提供引入和导出功能,顺畅连接上下文,每个模块独立,互不干扰。
Node的模块机制
Node并没有完全按照Commonjs的规范,它增加了自身需要的特性。
在Node中,引入一个模块有如下三个过程:
- 1.路径分析
- 2.文件定位
- 3.编译执行
模块分类:
-
核心模块:Node内部提供的;
在Node源码编译过程中,这些模块就被
编译成了二进制执行文件
,在Node进程启动时,这些模块就被加载进内存,这部分模块在加载过程中就只有路径分析
阶段,加载速度是最快的。 -
文件模块:用户自己开发的;
属于动态加载,三个过程都会经历,稍慢一些。
优先从缓存加载
对于相同模块的二次引入,Node会从缓存中去查找引入过的模块,节省二次引入的开销。实际缓存的是编译和执行后的对象
。
加载模块时,从缓存中获取是第一优先级,不管是核心模块还是文件模块。
路径分析和文件定位
标识符有几种形式,不同的标识符,模块的查找和定位有不同程度的差异。
模块标识符分析
-
核心模块,http、fs、path等;
优先级仅次于缓存,如果想要加载一个标识符与核心模块相同的自定义文件模块,是不会成功的。
-
相对路径的文件模块;
require方法会将传入的路径转化为真实路径,把真实路径作为索引,将编译执行后的结果存放在缓存中,方便下次加载。 由于指明了文件路径,所以加载起来仅次于核心模块速度。
-
以/开始的绝对路径的文件模块;
同上
-
非路径形式的文件模块,自定义的与核心模块命名不冲突的模块;
最慢的加载速度,一种特殊的文件模块,可能是一个包或者文件,
--模块路径--
Node在查找文件模块时制定的查找策略,具体表现为一个
路径数组
。如我们创建一个js文件,内容为
console.log(module.paths)
,执行它,在windows中应该打印出类似这样的:[ 'D:\\workspace\\node_modules', 'D:\\node_modules' ]
所以,我们大概可以得出这个模块路径的生成规则:
从当前目录的node_modules开始,逐级往上,直到根目录下的node_modules。,这也跟Javascript中原型链的作用方式一样, 很容易看出,路径越深,耗时约长。
文件定位
-
文件扩展名分析
上面我们说了,模块标识符可以不带文件后缀名,这种情况下,搜索顺序为
.js
,.json
,.node
。 在搜索过程中,需要调用fs模块同步阻塞地判断文件是否存在
,所以这可能会引起性能问题,我们在引入json或者node文件时,尽可能 加上后缀名,这样可以提升速度。 -
目录分析和包
如果在没有扩展名的情况下,并没有找到对应的文件,而是找到一个
目录
,那么此时Node会将这个目录当作一个包
, 该过程Node就对Commonjs的包规范做了一定的支持:- 在当前目录下查找package.json(commonjs的包描述文件),通过JSON.parse解析包描述对象,取出
main
属性指定的 文件名,进行定位。如果缺少后缀名,则再次进入扩展名分析阶段。 - 如果
main
属性指定错误,或者不存在该属性/不存在package.json,此时Node会将index
作为文件名进行扩展名分析查找。 - 目录分析中没有找到对应的文件,则进入下一个
模块路径
中重复上述动作。 - 模块路径数组遍历完成,还没找到,则报错。
- 在当前目录下查找package.json(commonjs的包描述文件),通过JSON.parse解析包描述对象,取出
模块编译 每一个文件模块都是一个对象
function Module(id, parent) {
this.id = id;
this.exports = {};
this.parent = parent;
if (parent && parent.children) {
parent.children.push(this);
}
this.filename = null;
this.loaded = false;
this.children = [];
}
编译和执行是引入一个模块的最后步骤,定位到具体文件后,Node会新建一个对象,根据路径载入并编译,以文件扩展名区分:
- .js文件,通过fs模块同步读取并编译执行;
- .node文件,这是
用C/C++编写的扩展文件
,通过dlopen
方法加载最后编译生成的文件; - .json文件,用fs模块同步读取文件后,用
JSON.parse()
解析返回结果; - 其余扩展名文件都按js文件处理
每一次编译成功的模块都会将其模块路径作为key
缓存在Module.cache
对象中。
根据不同的文件扩展名,Node会调用不同的方式进行读取,如.json文件
Module.extensions['.json'] = function (module, fileName) {
const content = NativeModule.require('fs').readFileSync(fileName, 'utf-8');
try {
module.exports = JSON.parse(stripBOM(content));
} catch (e) {
throw e;
}
}
其中的Module.extensions会被赋值到require.extensions
属性上。
在确定文件的扩展名后,Node会调用具体的编译方式执行文件后再将结果返回给调用者。
-
Javascript文件的编译
Commonjs中,每个模块文件中有
require、module、exports、__filename、__dirname
变量存在, 但是我们并未声明过,那它们是怎么来的呢?在编译过程中,Node将整个Javascript文件做了头尾包装,如下:
(function (exports, require, module, __filename, __dirname) { var math = require('math'); exports.area = function (radius) { return Math.PI * radius * radius; }; });
每个文件都进行了
作用域隔离
,包装后的代码会通过vm原生对象的runInThisContext
方法(类似eval,只是明确上下文,不污染全局)执行, 返回一个function对象。再将当前模块的exports、require、module
以及在文件定位中获取的文件路径和目录作为参数传给这个函数执行。
为什么不直接exports = xxx
,而是module.exports = xxx
?
因为exports是作为形参传入的,直接赋值改变形参
会改变对形参的引用,无法修改作用域外部的变量。
-
C/C++模块的编译 Node调用
process.dlopen
方法进行加载和执行,.node文件并不需要编译,执行过程中,模块的exports对象与.node模块产生联系,返回给调用者。 -
.json文件的编译 直接利用fs模块同步读取文件内容,通过JSON.parse方法解析后赋值给exports对象。
核心模块
核心模块分为:
- C/C++模块:存放在Node项目的src目录
- Javascript模块:存放在lib目录
Javascript核心模块的编译
在编译所有C/C++模块之前,会将所有Javascript模块编译成C/C++代码,但此时并不是可执行文件
。
-
转存为C/C++代码
Node采用了V8内部的
js2c.py
工具,将所有内置的Javascript代码src/node.js,lib/*.js
转为C++里面的数组, 生成node_natives.h
文件。此时Javascript代码是
以字符串的形式存在Node命名空间中
,不可执行,Node进程启动后,加载进内存。 -
编译Javascript核心模块
与文件模块一样,在引入核心模块过程中,也经历了头尾包装,执行并导出exports对象,不同的是,源文件的获取方式与缓存执行结果的位置。
其源文件通过
process.binding('natives')
取出,缓存在NativeModule._cache
对象上,而文件模块是存放在Module._cache
对象上。
内建模块
核心部分由C/C++编写完成、封装对象由Javascript完成是Node提升性能的主要方式,而纯C/C++
编写的模块我们称为内建模块,
几乎不被用户所调用。fs Buffer
等模块都是不是内建模块。
AMD
Node的模块引入几乎都是同步的(调用fs模块同步加载文件),如果在前端系统中采用这种方式,那很可能会阻塞UI渲染。 所以另一个模块规范出现了,AMD,异步模块定义。
-
定义模块
define(id?, dependencies?, factory)
;factory实际上就是模块代码内容。 具体例子:define(function() { var exports = {}; exports.sayHi = function() { alert('hi'); }; return exports; });
与Node内部不同,这里需要显式define定义一个模块(同样为了封闭作用域),而且需要返回导出对象。
CMD
这种规范更接近Commonjs,与AMD不同,CMD支持依赖动态引入,他将require、exports、module
三个变量当作参数传入define方法,
进行模块定义。
define(function(require, exports, module) {
// do something
});
AMD在定义模块时就必须把依赖全部引入,不管是否用得到,CMD则可以灵活引入。
总结:
- 为了把类聚的方法和变量,限定在私有作用域内,提供引入和导出功能,顺畅连接上下文,每个模块独立,互不干扰。Nodejs的模块功能出现了;
- 遵循Commonjs规范,模块主要有三个变量
exports、require、module
; - 模块的三个核心:模块引用、模块定义、模块标识;
- 模块引入的流程:
- 路径分析(模块标识符分析)
- 核心模块优先
- 相对、绝对路径其次
- 自定义模块最后
- 文件定位
- 扩展名分析,顺序为
.js->.json->.node
- 目录和包,会查找package.json文件
- 扩展名分析,顺序为
- 编译执行
- 新建一个对象
- 载入文件并执行,将返回结果根据
文件路径
缓存起来 - 将执行结果返回给调用者
- 路径分析(模块标识符分析)
- 模块分为核心模块与文件模块(开发者自己编写的),加载机制不同,导致加载速度也不同。缓存加载是第一优先级。
- Javascript文件的编译会经历一个头尾包装的过程,这也是
module、require、exports、__filename、__dirname
五个变量的由来。 - Commonjs同步加载模块的方式只适合在Node环境,在浏览器环境,应该异步加载模块,所以AMD出现了。
- AMD在模块定义时就需要把所有依赖当作参数传入,而CMD则可以动态引入依赖。